1use crate::reference::Reference;
12use crate::values::{ComponentValues, ProcHints, ProcValues, RenderOptions};
13use citum_schema::locale::{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 NumberVariable::CitationNumber => hints.citation_number.map(|n| {
96 if options.context == crate::values::RenderContext::Citation
97 && let Some(sub_label) = &hints.citation_sub_label
98 {
99 return format!("{n}{sub_label}");
100 }
101 n.to_string()
102 }),
103 NumberVariable::CitationLabel => {
104 let Some(citum_schema::options::Processing::Label(config)) =
105 options.config.processing.as_ref()
106 else {
107 return None;
108 };
109 let params = config.effective_params();
110 let base = crate::processor::labels::generate_base_label(reference, ¶ms);
111 if base.is_empty() {
112 return None;
113 }
114 let suffix = if hints.disamb_condition && hints.group_index > 0 {
115 crate::values::int_to_letter(hints.group_index as u32).unwrap_or_default()
116 } else {
117 String::new()
118 };
119 Some(format!("{base}{suffix}"))
120 }
121 _ => None,
122 }
123}
124
125fn label_form_to_term_form(label_form: &LabelForm) -> TermForm {
127 match label_form {
128 LabelForm::Long => TermForm::Long,
129 LabelForm::Short => TermForm::Short,
130 LabelForm::Symbol => TermForm::Symbol,
131 }
132}
133
134fn resolve_number_label<F: crate::render::format::OutputFormat<Output = String>>(
136 number: &NumberVariable,
137 label_form: &LabelForm,
138 value: &str,
139 requested_gender: Option<GrammaticalGender>,
140 effective_rendering: &citum_schema::template::Rendering,
141 options: &RenderOptions<'_>,
142 fmt: &F,
143) -> Option<String> {
144 if let Some(locator_type) = number_var_to_locator_type(number) {
145 let plural = check_plural(value, &locator_type);
147 let term_form = label_form_to_term_form(label_form);
148
149 options
150 .locale
151 .resolved_locator_term(&locator_type, plural, &term_form, requested_gender)
152 .map(|t| {
153 let term_str = if crate::values::should_strip_periods(effective_rendering, options)
154 {
155 crate::values::strip_trailing_periods(&t)
156 } else {
157 t
158 };
159 fmt.text(&format!("{term_str} "))
160 })
161 } else {
162 None
163 }
164}
165
166#[must_use]
172fn number_var_to_general_term(var: &NumberVariable) -> Option<GeneralTerm> {
173 match var {
174 NumberVariable::Edition => Some(GeneralTerm::Edition),
175 NumberVariable::Volume => Some(GeneralTerm::Volume),
176 _ => None,
177 }
178}
179
180fn split_numeric_term(term: &str) -> (Option<String>, Option<String>) {
189 if let Some((before, after)) = term.split_once("%s") {
190 let prefix = (!before.is_empty()).then(|| before.to_string());
191 let suffix = (!after.is_empty()).then(|| after.to_string());
192 (prefix, suffix)
193 } else {
194 (None, Some(format!(" {term}")))
195 }
196}
197
198fn render_ordinal(value: String, options: &RenderOptions<'_>) -> String {
203 let Ok(count) = value.parse::<u64>() else {
204 return value;
205 };
206 let args = MessageArgs {
207 count: Some(count),
208 value: Some(&value),
209 ..MessageArgs::default()
210 };
211 options
212 .locale
213 .resolve_message("number.ordinal", &args)
214 .unwrap_or(value)
215}
216
217impl ComponentValues for TemplateNumber {
218 fn values<F: crate::render::format::OutputFormat<Output = String>>(
219 &self,
220 reference: &Reference,
221 hints: &ProcHints,
222 options: &RenderOptions<'_>,
223 ) -> Option<ProcValues<F::Output>> {
224 let fmt = F::default();
225
226 let value = resolve_number_value(
227 &self.number,
228 reference,
229 hints,
230 options,
231 self.show_with_locator.unwrap_or(false),
232 );
233
234 value.filter(|s| !s.is_empty()).map(|value| {
235 let effective_rendering = &self.rendering;
237
238 let value = if let Some(tc) = effective_rendering.text_case {
241 crate::values::text_case::apply_text_case(&value, tc)
242 } else {
243 value
244 };
245 let value_is_numeric = is_numeric(&value);
246
247 let value = if self.form == Some(NumberForm::Ordinal) {
248 render_ordinal(value, options)
249 } else {
250 value
251 };
252
253 let label_prefix = if let Some(label_form) = &self.label_form {
255 resolve_number_label(
256 &self.number,
257 label_form,
258 &value,
259 self.gender.clone(),
260 effective_rendering,
261 options,
262 &fmt,
263 )
264 } else {
265 None
266 };
267
268 let (numeric_prefix, numeric_suffix) = self
273 .when_numeric
274 .as_ref()
275 .filter(|_| value_is_numeric)
276 .and_then(|form| {
277 let general_term = number_var_to_general_term(&self.number)?;
278 options.locale.resolved_general_term(
279 &general_term,
280 &label_form_to_term_form(form),
281 self.gender.clone(),
282 )
283 })
284 .map(|term| split_numeric_term(&term))
285 .unwrap_or((None, None));
286
287 let prefix = match (label_prefix, numeric_prefix) {
288 (Some(label), Some(numeric)) => Some(format!("{label}{numeric}")),
289 (Some(label), None) => Some(label),
290 (None, Some(numeric)) => Some(numeric),
291 (None, None) => None,
292 };
293 let suffix = numeric_suffix;
294
295 ProcValues {
296 value,
297 prefix,
298 suffix,
299 url: crate::values::resolve_effective_url(
300 self.links.as_ref(),
301 options.config.links.as_ref(),
302 reference,
303 citum_schema::options::LinkAnchor::Component,
304 ),
305 substituted_key: None,
306 pre_formatted: false,
307 }
308 })
309 }
310}
311
312fn is_numeric(value: &str) -> bool {
322 let value = value.trim();
323 let mut saw_digit = false;
324 for ch in value.chars() {
325 if ch.is_ascii_digit() {
326 saw_digit = true;
327 } else if !matches!(ch, '-' | ',' | '&' | ' ') {
328 return false;
329 }
330 }
331 saw_digit
332}
333
334#[cfg(test)]
335mod is_numeric_tests {
336 use super::is_numeric;
337
338 #[test]
339 fn given_bare_digits_when_checked_then_numeric() {
340 assert!(is_numeric("2"));
341 assert!(is_numeric("510"));
342 }
343
344 #[test]
345 fn given_digit_range_or_list_when_checked_then_numeric() {
346 assert!(is_numeric("1-3"));
347 assert!(is_numeric("12, 14"));
348 }
349
350 #[test]
351 fn given_digit_embedded_in_free_text_when_checked_then_not_numeric() {
352 assert!(!is_numeric("新1版"));
353 }
354
355 #[test]
356 fn given_free_text_without_digits_when_checked_then_not_numeric() {
357 assert!(!is_numeric("修订版"));
358 assert!(!is_numeric("美国卷"));
359 }
360
361 #[test]
362 fn given_cjk_numeral_when_checked_then_not_numeric() {
363 assert!(!is_numeric("第二卷"));
366 }
367
368 #[test]
369 fn given_empty_value_when_checked_then_not_numeric() {
370 assert!(!is_numeric(""));
371 assert!(!is_numeric(" "));
372 }
373}
374
375#[cfg(test)]
376mod split_numeric_term_tests {
377 use super::split_numeric_term;
378
379 #[test]
380 fn given_circumfix_term_when_split_then_wraps_around_marker() {
381 assert_eq!(
382 split_numeric_term("第%s卷"),
383 (Some("第".to_string()), Some("卷".to_string()))
384 );
385 }
386
387 #[test]
388 fn given_suffix_only_term_when_split_then_prefix_only_marker() {
389 assert_eq!(split_numeric_term("%s卷"), (None, Some("卷".to_string())));
391 }
392
393 #[test]
394 fn given_plain_term_when_split_then_space_separated_suffix() {
395 assert_eq!(split_numeric_term("版"), (None, Some(" 版".to_string())));
396 }
397}
398
399#[must_use]
405pub fn number_var_to_locator_type(
406 var: &NumberVariable,
407) -> Option<citum_schema::citation::LocatorType> {
408 use citum_schema::citation::LocatorType;
409 match var {
410 NumberVariable::Volume => Some(LocatorType::Volume),
411 NumberVariable::Pages => Some(LocatorType::Page),
412 NumberVariable::ChapterNumber => Some(LocatorType::Chapter),
413 NumberVariable::NumberOfPages => Some(LocatorType::Page),
414 NumberVariable::NumberOfVolumes => Some(LocatorType::Volume),
415 NumberVariable::Number
416 | NumberVariable::DocketNumber
417 | NumberVariable::PatentNumber
418 | NumberVariable::StandardNumber
419 | NumberVariable::ReportNumber
420 | NumberVariable::PrintingNumber => Some(LocatorType::Number),
421 NumberVariable::PartNumber => Some(LocatorType::Part),
422 NumberVariable::SupplementNumber => Some(LocatorType::Supplement),
423 NumberVariable::Issue => Some(LocatorType::Issue),
424 NumberVariable::Custom(kind) => Some(LocatorType::Custom(kind.clone())),
425 _ => None,
426 }
427}
428
429#[must_use]
435pub fn check_plural(value: &str, _locator_type: &citum_schema::citation::LocatorType) -> bool {
436 value.contains('–') || value.contains('-') || value.contains(',') || value.contains('&')
439}
440
441#[must_use]
447pub fn format_page_range(
448 pages: &str,
449 format: Option<&citum_schema::options::PageRangeFormat>,
450 delimiter: &str,
451) -> String {
452 use citum_schema::options::PageRangeFormat;
453
454 let normalized = pages.replace('\u{2013}', "-");
457 let with_delimiter = || normalized.replace('-', delimiter);
458
459 let Some(format) = format else {
461 return with_delimiter();
462 };
463
464 let parts: Vec<&str> = normalized.split('-').collect();
465 let [start, end] = parts.as_slice() else {
466 return with_delimiter(); };
468 let start = start.trim();
469 let end = end.trim();
470
471 let start_num: Option<u32> = start.parse().ok();
473 let end_num: Option<u32> = end.parse().ok();
474
475 match (start_num, end_num) {
476 (Some(s), Some(e)) if e > s => {
477 let formatted_end = match format {
478 PageRangeFormat::Expanded => end.to_string(),
479 PageRangeFormat::Minimal => format_minimal(start, end, 1),
480 PageRangeFormat::MinimalTwo => format_minimal(start, end, 2),
481 PageRangeFormat::Chicago | PageRangeFormat::Chicago16 => {
482 format_chicago_page_range_end(s, e)
483 }
484 _ => end.to_string(), };
486 format!("{start}{delimiter}{formatted_end}")
487 }
488 _ => with_delimiter(), }
490}
491
492#[must_use]
494pub fn format_minimal(start: &str, end: &str, min_digits: usize) -> String {
495 let start_chars: Vec<char> = start.chars().collect();
496 let end_chars: Vec<char> = end.chars().collect();
497
498 if start_chars.len() != end_chars.len() {
499 return end.to_string();
500 }
501
502 let mut first_diff = 0;
504 for (i, (s, e)) in start_chars.iter().zip(end_chars.iter()).enumerate() {
505 if s != e {
506 first_diff = i;
507 break;
508 }
509 }
510
511 let keep_from = first_diff.min(end_chars.len().saturating_sub(min_digits));
513 end_chars
514 .get(keep_from..)
515 .unwrap_or_default()
516 .iter()
517 .collect()
518}
519
520#[must_use]
522fn format_chicago_page_range_end(start: u32, end: u32) -> String {
523 if start < 100 || start.is_multiple_of(100) {
532 return end.to_string();
533 }
534
535 let start_str = start.to_string();
536 let end_str = end.to_string();
537 let changed_end_part = if start % 100 <= 9 {
538 format_minimal(&start_str, &end_str, 1)
539 } else {
540 format_minimal(&start_str, &end_str, 2)
541 };
542
543 if changed_end_part.len() > 1 && changed_end_part.starts_with('0') {
544 let trimmed = changed_end_part.trim_start_matches('0');
545 if trimmed.is_empty() {
546 "0".to_string()
547 } else {
548 trimmed.to_string()
549 }
550 } else {
551 changed_end_part
552 }
553}
554
555#[must_use]
557pub fn format_chicago(start: u32, end: u32) -> String {
558 format_chicago_page_range_end(start, end)
559}
560
561#[cfg(test)]
562#[allow(
563 clippy::unwrap_used,
564 clippy::expect_used,
565 clippy::panic,
566 clippy::indexing_slicing,
567 clippy::todo,
568 clippy::unimplemented,
569 clippy::unreachable,
570 clippy::get_unwrap,
571 reason = "Panicking is acceptable and often desired in tests."
572)]
573mod tests {
574 use super::*;
575 use citum_schema::options::PageRangeFormat;
576
577 #[test]
578 fn test_format_chicago_page_range_end() {
579 for (start, end, expected) in [
580 (3, 10, "10"),
581 (71, 72, "72"),
582 (92, 113, "113"),
583 (100, 104, "104"),
584 (600, 613, "613"),
585 (107, 108, "8"),
586 (505, 517, "17"),
587 (1002, 1006, "6"),
588 (321, 325, "25"),
589 (415, 532, "532"),
590 (1087, 1089, "89"),
591 (1496, 1500, "500"),
592 (13792, 13803, "803"),
593 (12991, 13001, "3001"),
594 ] {
595 assert_eq!(format_chicago_page_range_end(start, end), expected);
596 }
597 }
598
599 #[test]
600 fn test_format_minimal() {
601 for (start, end, min_digits, expected) in [
602 ("100", "105", 1, "5"),
603 ("100", "105", 2, "05"),
604 ("1536", "1538", 1, "8"),
605 ("1536", "1538", 2, "38"),
606 ("1536", "1538", 4, "1538"),
607 ("12", "15", 1, "5"),
608 ("12", "15", 2, "15"),
609 ("10", "150", 1, "150"),
610 ] {
611 assert_eq!(format_minimal(start, end, min_digits), expected);
612 }
613 }
614
615 #[test]
616 fn test_format_page_range() {
617 let en = "\u{2013}";
619 for (input, format, expected) in [
620 ("10-15", None, "10–15"),
621 ("10–15", None, "10–15"),
622 ("321-328", None, "321–328"),
623 ("10-15", Some(PageRangeFormat::Expanded), "10–15"),
624 ("42-45", Some(PageRangeFormat::Expanded), "42–45"),
625 ("3-10", Some(PageRangeFormat::Chicago), "3–10"),
626 ("71-72", Some(PageRangeFormat::Chicago), "71–72"),
627 ("92-113", Some(PageRangeFormat::Chicago), "92–113"),
628 ("100-104", Some(PageRangeFormat::Chicago), "100–104"),
629 ("600-613", Some(PageRangeFormat::Chicago), "600–613"),
630 ("107-108", Some(PageRangeFormat::Chicago), "107–8"),
631 ("505-517", Some(PageRangeFormat::Chicago), "505–17"),
632 ("1002-1006", Some(PageRangeFormat::Chicago), "1002–6"),
633 ("321-325", Some(PageRangeFormat::Chicago), "321–25"),
634 ("415-532", Some(PageRangeFormat::Chicago), "415–532"),
635 ("1087-1089", Some(PageRangeFormat::Chicago), "1087–89"),
636 ("1496-1500", Some(PageRangeFormat::Chicago), "1496–500"),
637 ("13792-13803", Some(PageRangeFormat::Chicago), "13792–803"),
638 ("12991-13001", Some(PageRangeFormat::Chicago), "12991–3001"),
639 ("3-10", Some(PageRangeFormat::Chicago16), "3–10"),
640 ("71-72", Some(PageRangeFormat::Chicago16), "71–72"),
641 ("92-113", Some(PageRangeFormat::Chicago16), "92–113"),
642 ("100-104", Some(PageRangeFormat::Chicago16), "100–104"),
643 ("600-613", Some(PageRangeFormat::Chicago16), "600–613"),
644 ("107-108", Some(PageRangeFormat::Chicago16), "107–8"),
645 ("505-517", Some(PageRangeFormat::Chicago16), "505–17"),
646 ("1002-1006", Some(PageRangeFormat::Chicago16), "1002–6"),
647 ("321-325", Some(PageRangeFormat::Chicago16), "321–25"),
648 ("415-532", Some(PageRangeFormat::Chicago16), "415–532"),
649 ("1087-1089", Some(PageRangeFormat::Chicago16), "1087–89"),
650 ("1496-1500", Some(PageRangeFormat::Chicago16), "1496–500"),
651 ("13792-13803", Some(PageRangeFormat::Chicago16), "13792–803"),
652 (
653 "12991-13001",
654 Some(PageRangeFormat::Chicago16),
655 "12991–3001",
656 ),
657 ("100-105", Some(PageRangeFormat::Minimal), "100–5"),
658 ("321-328", Some(PageRangeFormat::Minimal), "321–8"),
659 ("42-45", Some(PageRangeFormat::Minimal), "42–5"),
660 ("12-17", Some(PageRangeFormat::Minimal), "12–7"),
661 ("100-105", Some(PageRangeFormat::MinimalTwo), "100–05"),
662 ("42-45", Some(PageRangeFormat::MinimalTwo), "42–45"),
663 ("10", Some(PageRangeFormat::Chicago), "10"),
664 ("10-5", Some(PageRangeFormat::Chicago), "10–5"),
665 ("X-Y", Some(PageRangeFormat::Chicago), "X–Y"),
666 ("10-15-20", Some(PageRangeFormat::Chicago), "10–15–20"),
667 ] {
668 assert_eq!(format_page_range(input, format.as_ref(), en), expected);
669 }
670 }
671
672 #[test]
673 fn test_format_page_range_hyphen_delimiter() {
674 for (input, format, expected) in [
677 ("436-444", None, "436-444"),
678 ("436–444", None, "436-444"),
679 ("321-328", Some(PageRangeFormat::Expanded), "321-328"),
680 ("321-328", Some(PageRangeFormat::Chicago), "321-28"),
681 ] {
682 assert_eq!(format_page_range(input, format.as_ref(), "-"), expected);
683 }
684 }
685
686 #[test]
687 fn test_check_plural() {
688 for (value, expected) in [
689 ("1-10", true),
690 ("1–10", true),
691 ("1, 3", true),
692 ("1 & 3", true),
693 ("1", false),
694 ("IV", false),
695 ] {
696 assert_eq!(
697 check_plural(value, &citum_schema::citation::LocatorType::Page),
698 expected
699 );
700 }
701 }
702
703 #[test]
704 fn number_var_to_locator_type_maps_printing_number() {
705 assert_eq!(
706 number_var_to_locator_type(&NumberVariable::PrintingNumber),
707 Some(citum_schema::citation::LocatorType::Number)
708 );
709 }
710
711 #[test]
712 fn number_var_to_locator_type_maps_part_number() {
713 assert_eq!(
714 number_var_to_locator_type(&NumberVariable::PartNumber),
715 Some(citum_schema::citation::LocatorType::Part)
716 );
717 }
718
719 #[test]
720 fn number_var_to_locator_type_maps_supplement_number() {
721 assert_eq!(
722 number_var_to_locator_type(&NumberVariable::SupplementNumber),
723 Some(citum_schema::citation::LocatorType::Supplement)
724 );
725 }
726}