1use 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
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(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, ¶ms);
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
117fn 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 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 let effective_rendering = &self.rendering;
174
175 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 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#[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#[must_use]
251pub fn check_plural(value: &str, _locator_type: &citum_schema::citation::LocatorType) -> bool {
252 value.contains('–') || value.contains('-') || value.contains(',') || value.contains('&')
255}
256
257#[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 let normalized = pages.replace('\u{2013}', "-");
273 let with_delimiter = || normalized.replace('-', delimiter);
274
275 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(); };
284 let start = start.trim();
285 let end = end.trim();
286
287 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(), };
302 format!("{start}{delimiter}{formatted_end}")
303 }
304 _ => with_delimiter(), }
306}
307
308#[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 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 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#[must_use]
338fn format_chicago_page_range_end(start: u32, end: u32) -> String {
339 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#[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 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 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}