icu4x 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,13 @@
1
1
  use crate::data_provider::DataProvider;
2
2
  use crate::helpers;
3
+ use crate::parts_collector::{PartsCollector, parts_to_ruby_array};
4
+ use icu::list::parts as list_parts;
3
5
  use icu::list::ListFormatter;
4
6
  use icu::list::options::{ListFormatterOptions, ListLength};
5
7
  use icu_provider::buf::AsDeserializingBufferProvider;
6
8
  use icu4x_macros::RubySymbol;
7
- use magnus::{
8
- Error, RArray, RHash, RModule, Ruby, TryConvert, Value, function, method, prelude::*,
9
- };
9
+ use magnus::{Error, RArray, RHash, RModule, Ruby, TryConvert, Value, function, method, prelude::*};
10
+ use writeable::{Part, Writeable};
10
11
 
11
12
  /// The type of list formatting
12
13
  #[derive(Clone, Copy, PartialEq, Eq, RubySymbol)]
@@ -34,6 +35,17 @@ impl ListStyle {
34
35
  }
35
36
  }
36
37
 
38
+ /// Convert ICU4X list Part to Ruby symbol name
39
+ fn part_to_symbol_name(part: &Part) -> &'static str {
40
+ if *part == list_parts::ELEMENT {
41
+ "element"
42
+ } else if *part == list_parts::LITERAL {
43
+ "literal"
44
+ } else {
45
+ "literal"
46
+ }
47
+ }
48
+
37
49
  /// Ruby wrapper for ICU4X ListFormatter
38
50
  #[magnus::wrap(class = "ICU4X::ListFormat", free_immediately, size)]
39
51
  pub struct ListFormat {
@@ -134,18 +146,42 @@ impl ListFormat {
134
146
  /// A formatted string
135
147
  fn format(&self, list: Value) -> Result<String, Error> {
136
148
  let ruby = Ruby::get().expect("Ruby runtime should be available");
149
+ let items = self.prepare_list(&ruby, list)?;
150
+ let formatted = self.inner.format(items.iter().map(|s| s.as_str()));
151
+ Ok(formatted.to_string())
152
+ }
137
153
 
138
- // Convert Ruby Array to Vec<String>
154
+ /// Format a list of strings and return an array of FormattedPart
155
+ ///
156
+ /// # Arguments
157
+ /// * `list` - An array of strings
158
+ ///
159
+ /// # Returns
160
+ /// An array of FormattedPart objects with :type and :value
161
+ fn format_to_parts(&self, list: Value) -> Result<RArray, Error> {
162
+ let ruby = Ruby::get().expect("Ruby runtime should be available");
163
+ let items = self.prepare_list(&ruby, list)?;
164
+
165
+ let formatted = self.inner.format(items.iter().map(|s| s.as_str()));
166
+ let mut collector = PartsCollector::new();
167
+ formatted
168
+ .write_to_parts(&mut collector)
169
+ .map_err(|e| Error::new(ruby.exception_runtime_error(), format!("{}", e)))?;
170
+
171
+ parts_to_ruby_array(&ruby, collector, part_to_symbol_name)
172
+ }
173
+
174
+ /// Prepare a Ruby list for formatting.
175
+ ///
176
+ /// Converts Ruby Array to Vec<String>.
177
+ fn prepare_list(&self, ruby: &Ruby, list: Value) -> Result<Vec<String>, Error> {
139
178
  let array: RArray = TryConvert::try_convert(list)
140
179
  .map_err(|_| Error::new(ruby.exception_type_error(), "list must be an Array"))?;
141
180
 
142
- let items: Vec<String> = array
181
+ array
143
182
  .into_iter()
144
183
  .map(TryConvert::try_convert)
145
- .collect::<Result<Vec<_>, _>>()?;
146
-
147
- let formatted = self.inner.format(items.iter().map(|s| s.as_str()));
148
- Ok(formatted.to_string())
184
+ .collect::<Result<Vec<_>, _>>()
149
185
  }
150
186
 
151
187
  /// Get the resolved options
@@ -172,6 +208,7 @@ pub fn init(ruby: &Ruby, module: &RModule) -> Result<(), Error> {
172
208
  let class = module.define_class("ListFormat", ruby.class_object())?;
173
209
  class.define_singleton_method("new", function!(ListFormat::new, -1))?;
174
210
  class.define_method("format", method!(ListFormat::format, 1))?;
211
+ class.define_method("format_to_parts", method!(ListFormat::format_to_parts, 1))?;
175
212
  class.define_method("resolved_options", method!(ListFormat::resolved_options, 0))?;
176
213
  Ok(())
177
214
  }
@@ -1,5 +1,6 @@
1
1
  use crate::helpers;
2
2
  use icu_locale::{Locale as IcuLocale, LocaleExpander, TransformResult};
3
+ use icu_locale::subtags::Variant;
3
4
  use magnus::{Error, RHash, RModule, Ruby, function, method, prelude::*, typed_data::Obj};
4
5
  use std::cell::RefCell;
5
6
 
@@ -11,7 +12,7 @@ pub struct Locale {
11
12
 
12
13
  impl Locale {
13
14
  /// Parse a BCP 47 locale string
14
- fn parse(ruby: &Ruby, s: String) -> Result<Self, Error> {
15
+ fn parse_bcp47(ruby: &Ruby, s: String) -> Result<Self, Error> {
15
16
  let locale: IcuLocale = s.parse().map_err(|e| {
16
17
  Error::new(
17
18
  helpers::get_exception_class(ruby, "ICU4X::LocaleError"),
@@ -32,7 +33,7 @@ impl Locale {
32
33
  fn parse_posix(ruby: &Ruby, posix_str: String) -> Result<Self, Error> {
33
34
  // Handle special cases
34
35
  if posix_str == "C" || posix_str == "POSIX" {
35
- return Self::parse(ruby, "und".to_string());
36
+ return Self::parse_bcp47(ruby, "und".to_string());
36
37
  }
37
38
 
38
39
  // Handle empty string
@@ -90,7 +91,7 @@ impl Locale {
90
91
  bcp47.push_str(&t.to_uppercase());
91
92
  }
92
93
 
93
- Self::parse(ruby, bcp47)
94
+ Self::parse_bcp47(ruby, bcp47)
94
95
  }
95
96
 
96
97
  /// Get the language component
@@ -204,11 +205,63 @@ impl Locale {
204
205
  inner: RefCell::new(IcuLocale::from(new_id)),
205
206
  }
206
207
  }
208
+
209
+ /// Get the list of variants
210
+ fn variants(&self) -> Vec<String> {
211
+ self.inner
212
+ .borrow()
213
+ .id
214
+ .variants
215
+ .iter()
216
+ .map(|v| v.to_string())
217
+ .collect()
218
+ }
219
+
220
+ fn parse_variant(s: &str) -> Result<Variant, Error> {
221
+ let ruby = Ruby::get().expect("Ruby runtime should be available");
222
+ s.parse::<Variant>().map_err(|e| {
223
+ Error::new(
224
+ helpers::get_exception_class(&ruby, "ICU4X::LocaleError"),
225
+ format!("Invalid variant: {e}"),
226
+ )
227
+ })
228
+ }
229
+
230
+ /// Add a variant in place; returns self if added, nil if already present
231
+ fn add_variant_bang(rb_self: Obj<Self>, variant_str: String) -> Result<Option<Obj<Self>>, Error> {
232
+ let variant = Self::parse_variant(&variant_str)?;
233
+ let added = rb_self.inner.borrow_mut().id.variants.push(variant);
234
+ Ok(if added { Some(rb_self) } else { None })
235
+ }
236
+
237
+ /// Return a new Locale with the variant added
238
+ fn add_variant(&self, variant_str: String) -> Result<Self, Error> {
239
+ let variant = Self::parse_variant(&variant_str)?;
240
+ let mut new_locale = self.inner.borrow().clone();
241
+ new_locale.id.variants.push(variant);
242
+ Ok(Self { inner: RefCell::new(new_locale) })
243
+ }
244
+
245
+ /// Remove a variant in place; returns self if removed, nil if not present
246
+ fn remove_variant_bang(rb_self: Obj<Self>, variant_str: String) -> Result<Option<Obj<Self>>, Error> {
247
+ let variant = Self::parse_variant(&variant_str)?;
248
+ let removed = rb_self.inner.borrow_mut().id.variants.remove(&variant);
249
+ Ok(if removed { Some(rb_self) } else { None })
250
+ }
251
+
252
+ /// Return a new Locale with the variant removed
253
+ fn remove_variant(&self, variant_str: String) -> Result<Self, Error> {
254
+ let variant = Self::parse_variant(&variant_str)?;
255
+ let mut new_locale = self.inner.borrow().clone();
256
+ new_locale.id.variants.remove(&variant);
257
+ Ok(Self { inner: RefCell::new(new_locale) })
258
+ }
207
259
  }
208
260
 
209
261
  pub fn init(ruby: &Ruby, module: &RModule) -> Result<(), Error> {
210
262
  let class = module.define_class("Locale", ruby.class_object())?;
211
- class.define_singleton_method("parse", function!(Locale::parse, 1))?;
263
+ class.define_singleton_method("parse_bcp47", function!(Locale::parse_bcp47, 1))?;
264
+ class.singleton_class()?.define_alias("parse", "parse_bcp47")?;
212
265
  class.define_singleton_method("parse_posix", function!(Locale::parse_posix, 1))?;
213
266
  class.define_method("language", method!(Locale::language, 0))?;
214
267
  class.define_method("script", method!(Locale::script, 0))?;
@@ -220,5 +273,10 @@ pub fn init(ruby: &Ruby, module: &RModule) -> Result<(), Error> {
220
273
  class.define_method("maximize", method!(Locale::maximize, 0))?;
221
274
  class.define_method("minimize!", method!(Locale::minimize_bang, 0))?;
222
275
  class.define_method("minimize", method!(Locale::minimize, 0))?;
276
+ class.define_method("variants", method!(Locale::variants, 0))?;
277
+ class.define_method("add_variant!", method!(Locale::add_variant_bang, 1))?;
278
+ class.define_method("add_variant", method!(Locale::add_variant, 1))?;
279
+ class.define_method("remove_variant!", method!(Locale::remove_variant_bang, 1))?;
280
+ class.define_method("remove_variant", method!(Locale::remove_variant, 1))?;
223
281
  Ok(())
224
282
  }
@@ -1,7 +1,9 @@
1
1
  use crate::data_provider::DataProvider;
2
2
  use crate::helpers;
3
+ use crate::parts_collector::{PartsCollector, parts_to_ruby_array};
3
4
  use fixed_decimal::{Decimal, SignedRoundingMode, UnsignedRoundingMode};
4
5
  use icu::decimal::options::{DecimalFormatterOptions, GroupingStrategy};
6
+ use icu::decimal::parts as decimal_parts;
5
7
  use icu::decimal::{DecimalFormatter, DecimalFormatterPreferences};
6
8
  use icu::experimental::dimension::currency::CurrencyCode;
7
9
  use icu::experimental::dimension::currency::formatter::{
@@ -14,10 +16,9 @@ use icu::experimental::dimension::percent::formatter::{
14
16
  use icu::experimental::dimension::percent::options::PercentFormatterOptions;
15
17
  use icu_provider::buf::AsDeserializingBufferProvider;
16
18
  use icu4x_macros::RubySymbol;
17
- use magnus::{
18
- Error, RHash, RModule, Ruby, TryConvert, Value, function, method, prelude::*,
19
- };
19
+ use magnus::{Error, RArray, RHash, RModule, Ruby, TryConvert, Value, function, method, prelude::*};
20
20
  use tinystr::TinyAsciiStr;
21
+ use writeable::{Part, Writeable};
21
22
 
22
23
  /// The style of number formatting
23
24
  #[derive(Clone, Copy, PartialEq, Eq, RubySymbol)]
@@ -69,6 +70,29 @@ enum FormatterKind {
69
70
  Currency(CurrencyFormatter, CurrencyCode),
70
71
  }
71
72
 
73
+ /// Convert ICU4X decimal Part to Ruby symbol name
74
+ fn part_to_symbol_name(part: &Part) -> &'static str {
75
+ if *part == decimal_parts::INTEGER {
76
+ "integer"
77
+ } else if *part == decimal_parts::FRACTION {
78
+ "fraction"
79
+ } else if *part == decimal_parts::DECIMAL {
80
+ "decimal"
81
+ } else if *part == decimal_parts::GROUP {
82
+ "group"
83
+ } else if *part == decimal_parts::MINUS_SIGN {
84
+ "minus_sign"
85
+ } else if *part == decimal_parts::PLUS_SIGN {
86
+ "plus_sign"
87
+ } else if part.category == "currency" {
88
+ "currency"
89
+ } else if part.category == "percent" {
90
+ "percent_sign"
91
+ } else {
92
+ "literal"
93
+ }
94
+ }
95
+
72
96
  /// Ruby wrapper for ICU4X number formatters
73
97
  #[magnus::wrap(class = "ICU4X::NumberFormat", free_immediately, size)]
74
98
  pub struct NumberFormat {
@@ -268,8 +292,59 @@ impl NumberFormat {
268
292
  /// A formatted string
269
293
  fn format(&self, number: Value) -> Result<String, Error> {
270
294
  let ruby = Ruby::get().expect("Ruby runtime should be available");
295
+ let decimal = self.prepare_decimal(&ruby, number)?;
296
+
297
+ let formatted = match &self.inner {
298
+ FormatterKind::Decimal(formatter) => formatter.format(&decimal).to_string(),
299
+ FormatterKind::Percent(formatter) => formatter.format(&decimal).to_string(),
300
+ FormatterKind::Currency(formatter, currency_code) => formatter
301
+ .format_fixed_decimal(&decimal, currency_code)
302
+ .to_string(),
303
+ };
304
+ Ok(formatted)
305
+ }
306
+
307
+ /// Format a number and return an array of FormattedPart
308
+ ///
309
+ /// # Arguments
310
+ /// * `number` - An integer, float, or BigDecimal
311
+ ///
312
+ /// # Returns
313
+ /// An array of FormattedPart objects with :type and :value
314
+ fn format_to_parts(&self, number: Value) -> Result<RArray, Error> {
315
+ let ruby = Ruby::get().expect("Ruby runtime should be available");
316
+ let decimal = self.prepare_decimal(&ruby, number)?;
317
+
318
+ let mut collector = PartsCollector::new();
319
+ match &self.inner {
320
+ FormatterKind::Decimal(formatter) => {
321
+ formatter
322
+ .format(&decimal)
323
+ .write_to_parts(&mut collector)
324
+ .map_err(|e| Error::new(ruby.exception_runtime_error(), format!("{}", e)))?;
325
+ }
326
+ FormatterKind::Percent(formatter) => {
327
+ formatter
328
+ .format(&decimal)
329
+ .write_to_parts(&mut collector)
330
+ .map_err(|e| Error::new(ruby.exception_runtime_error(), format!("{}", e)))?;
331
+ }
332
+ FormatterKind::Currency(formatter, currency_code) => {
333
+ formatter
334
+ .format_fixed_decimal(&decimal, currency_code)
335
+ .write_to_parts(&mut collector)
336
+ .map_err(|e| Error::new(ruby.exception_runtime_error(), format!("{}", e)))?;
337
+ }
338
+ }
271
339
 
272
- let mut decimal = Self::convert_to_decimal(&ruby, number)?;
340
+ parts_to_ruby_array(&ruby, collector, part_to_symbol_name)
341
+ }
342
+
343
+ /// Prepare a Ruby number for formatting.
344
+ ///
345
+ /// Converts to Decimal, adjusts for percent style, and applies digit options.
346
+ fn prepare_decimal(&self, ruby: &Ruby, number: Value) -> Result<Decimal, Error> {
347
+ let mut decimal = Self::convert_to_decimal(ruby, number)?;
273
348
 
274
349
  // For percent style, multiply by 100 (same as Intl.NumberFormat)
275
350
  if self.style == Style::Percent {
@@ -288,14 +363,7 @@ impl NumberFormat {
288
363
  decimal.pad_start(min);
289
364
  }
290
365
 
291
- let formatted = match &self.inner {
292
- FormatterKind::Decimal(formatter) => formatter.format(&decimal).to_string(),
293
- FormatterKind::Percent(formatter) => formatter.format(&decimal).to_string(),
294
- FormatterKind::Currency(formatter, currency_code) => formatter
295
- .format_fixed_decimal(&decimal, *currency_code)
296
- .to_string(),
297
- };
298
- Ok(formatted)
366
+ Ok(decimal)
299
367
  }
300
368
 
301
369
  /// Convert Ruby number to Decimal
@@ -331,11 +399,7 @@ impl NumberFormat {
331
399
 
332
400
  /// Check if value is a BigDecimal
333
401
  fn is_big_decimal(ruby: &Ruby, value: Value) -> bool {
334
- // Try to get BigDecimal class; if bigdecimal is not loaded, return false
335
- if let Ok(bigdecimal_class) = ruby.eval::<Value>("defined?(BigDecimal) && BigDecimal") {
336
- if bigdecimal_class.is_nil() {
337
- return false;
338
- }
402
+ if let Ok(bigdecimal_class) = ruby.eval::<Value>("BigDecimal") {
339
403
  if let Ok(class) = magnus::RClass::try_convert(bigdecimal_class) {
340
404
  return value.is_kind_of(class);
341
405
  }
@@ -382,6 +446,10 @@ pub fn init(ruby: &Ruby, module: &RModule) -> Result<(), Error> {
382
446
  let class = module.define_class("NumberFormat", ruby.class_object())?;
383
447
  class.define_singleton_method("new", function!(NumberFormat::new, -1))?;
384
448
  class.define_method("format", method!(NumberFormat::format, 1))?;
449
+ class.define_method(
450
+ "format_to_parts",
451
+ method!(NumberFormat::format_to_parts, 1),
452
+ )?;
385
453
  class.define_method(
386
454
  "resolved_options",
387
455
  method!(NumberFormat::resolved_options, 0),
@@ -0,0 +1,117 @@
1
+ use magnus::{Error, RArray, Ruby, Value, prelude::*};
2
+ use std::fmt;
3
+ use writeable::{Part, PartsWrite};
4
+
5
+ /// A collector for formatted parts that handles nested part annotations.
6
+ ///
7
+ /// ICU4X uses nested parts - e.g., datetime/day wraps decimal/integer.
8
+ /// We track a stack of parts and prefer the outermost (top-level) annotations.
9
+ pub struct PartsCollector {
10
+ parts: Vec<(String, Part)>,
11
+ current_buffer: String,
12
+ /// Stack of part contexts for handling nested with_part calls
13
+ part_stack: Vec<Part>,
14
+ }
15
+
16
+ impl PartsCollector {
17
+ pub fn new() -> Self {
18
+ Self {
19
+ parts: Vec::new(),
20
+ current_buffer: String::new(),
21
+ part_stack: Vec::new(),
22
+ }
23
+ }
24
+
25
+ fn flush(&mut self) {
26
+ // Store any remaining content as "literal"
27
+ if !self.current_buffer.is_empty() {
28
+ self.parts.push((
29
+ std::mem::take(&mut self.current_buffer),
30
+ Part {
31
+ category: "literal",
32
+ value: "literal",
33
+ },
34
+ ));
35
+ }
36
+ }
37
+
38
+ pub fn into_parts(mut self) -> Vec<(String, Part)> {
39
+ self.flush();
40
+ self.parts
41
+ }
42
+ }
43
+
44
+ impl fmt::Write for PartsCollector {
45
+ fn write_str(&mut self, s: &str) -> fmt::Result {
46
+ self.current_buffer.push_str(s);
47
+ Ok(())
48
+ }
49
+ }
50
+
51
+ impl PartsWrite for PartsCollector {
52
+ type SubPartsWrite = Self;
53
+
54
+ fn with_part(
55
+ &mut self,
56
+ part: Part,
57
+ mut f: impl FnMut(&mut Self::SubPartsWrite) -> fmt::Result,
58
+ ) -> fmt::Result {
59
+ // If at top level, store any buffered content as literal before entering new part
60
+ if self.part_stack.is_empty() && !self.current_buffer.is_empty() {
61
+ self.parts.push((
62
+ std::mem::take(&mut self.current_buffer),
63
+ Part {
64
+ category: "literal",
65
+ value: "literal",
66
+ },
67
+ ));
68
+ }
69
+
70
+ // Push this part onto the stack
71
+ self.part_stack.push(part);
72
+
73
+ // Execute the writing function
74
+ f(self)?;
75
+
76
+ // Pop this part from the stack
77
+ self.part_stack.pop();
78
+
79
+ // If back at top level, store collected content with effective part
80
+ if self.part_stack.is_empty() && !self.current_buffer.is_empty() {
81
+ self.parts
82
+ .push((std::mem::take(&mut self.current_buffer), part));
83
+ }
84
+
85
+ Ok(())
86
+ }
87
+ }
88
+
89
+ /// Converts collected parts to a Ruby array of FormattedPart objects.
90
+ ///
91
+ /// # Arguments
92
+ /// * `ruby` - The Ruby runtime reference
93
+ /// * `collector` - The PartsCollector with collected parts
94
+ /// * `part_mapper` - Function to convert a Part to a symbol name string
95
+ ///
96
+ /// # Returns
97
+ /// A Ruby array containing FormattedPart objects.
98
+ pub fn parts_to_ruby_array<F>(
99
+ ruby: &Ruby,
100
+ collector: PartsCollector,
101
+ part_mapper: F,
102
+ ) -> Result<RArray, Error>
103
+ where
104
+ F: Fn(&Part) -> &'static str,
105
+ {
106
+ let formatted_part_class: Value = ruby.eval("ICU4X::FormattedPart")?;
107
+ let result = ruby.ary_new();
108
+
109
+ for (value, part) in collector.into_parts() {
110
+ let symbol_name = part_mapper(&part);
111
+ let part_obj: Value =
112
+ formatted_part_class.funcall("[]", (ruby.to_symbol(symbol_name), value.as_str()))?;
113
+ result.push(part_obj)?;
114
+ }
115
+
116
+ Ok(result)
117
+ }
@@ -2,7 +2,7 @@ use crate::data_provider::DataProvider;
2
2
  use crate::helpers;
3
3
  use fixed_decimal::Decimal;
4
4
  use icu::plurals::{
5
- PluralCategory, PluralRuleType, PluralRules as IcuPluralRules, PluralRulesPreferences,
5
+ PluralCategory, PluralRuleType, PluralRulesPreferences, PluralRulesWithRanges,
6
6
  };
7
7
  use icu_provider::buf::AsDeserializingBufferProvider;
8
8
  use magnus::{
@@ -12,7 +12,7 @@ use magnus::{
12
12
  /// Ruby wrapper for ICU4X PluralRules
13
13
  #[magnus::wrap(class = "ICU4X::PluralRules", free_immediately, size)]
14
14
  pub struct PluralRules {
15
- inner: IcuPluralRules,
15
+ inner: PluralRulesWithRanges<icu::plurals::PluralRules>,
16
16
  locale_str: String,
17
17
  rule_type: PluralRuleType,
18
18
  }
@@ -84,15 +84,20 @@ impl PluralRules {
84
84
  )
85
85
  })?;
86
86
 
87
- // Create PluralRules from DataProvider
87
+ // Create PluralRulesWithRanges from DataProvider
88
88
  let rules = match rule_type {
89
- PluralRuleType::Cardinal => {
90
- IcuPluralRules::try_new_cardinal_unstable(&dp.inner.as_deserializing(), prefs)
91
- }
92
- PluralRuleType::Ordinal => {
93
- IcuPluralRules::try_new_ordinal_unstable(&dp.inner.as_deserializing(), prefs)
94
- }
95
- _ => IcuPluralRules::try_new_cardinal_unstable(&dp.inner.as_deserializing(), prefs),
89
+ PluralRuleType::Cardinal => PluralRulesWithRanges::try_new_cardinal_unstable(
90
+ &dp.inner.as_deserializing(),
91
+ prefs,
92
+ ),
93
+ PluralRuleType::Ordinal => PluralRulesWithRanges::try_new_ordinal_unstable(
94
+ &dp.inner.as_deserializing(),
95
+ prefs,
96
+ ),
97
+ _ => PluralRulesWithRanges::try_new_cardinal_unstable(
98
+ &dp.inner.as_deserializing(),
99
+ prefs,
100
+ ),
96
101
  }
97
102
  .map_err(|e| Error::new(error_class, format!("Failed to create PluralRules: {}", e)))?;
98
103
 
@@ -120,7 +125,7 @@ impl PluralRules {
120
125
  // For floats, convert to Decimal to preserve fractional digits
121
126
  let s = format!("{}", f);
122
127
  if let Ok(fd) = s.parse::<Decimal>() {
123
- self.inner.category_for(&fd)
128
+ self.inner.rules().category_for(&fd)
124
129
  } else {
125
130
  return Err(Error::new(
126
131
  ruby.exception_arg_error(),
@@ -129,7 +134,7 @@ impl PluralRules {
129
134
  }
130
135
  } else if number.is_kind_of(ruby.class_integer()) {
131
136
  let n: i64 = TryConvert::try_convert(number)?;
132
- self.inner.category_for(n as usize)
137
+ self.inner.rules().category_for(n as usize)
133
138
  } else {
134
139
  return Err(Error::new(
135
140
  ruby.exception_type_error(),
@@ -140,6 +145,49 @@ impl PluralRules {
140
145
  Ok(Self::category_to_symbol(&ruby, category))
141
146
  }
142
147
 
148
+ /// Determine the plural category for a range of numbers
149
+ ///
150
+ /// # Arguments
151
+ /// * `start` - The start of the range (integer or float)
152
+ /// * `end` - The end of the range (integer or float)
153
+ ///
154
+ /// # Returns
155
+ /// A symbol: :zero, :one, :two, :few, :many, or :other
156
+ fn select_range(&self, start: Value, end: Value) -> Result<Symbol, Error> {
157
+ let ruby = Ruby::get().expect("Ruby runtime should be available");
158
+
159
+ let start_decimal = Self::value_to_decimal(&ruby, start, "start")?;
160
+ let end_decimal = Self::value_to_decimal(&ruby, end, "end")?;
161
+
162
+ let category = self
163
+ .inner
164
+ .category_for_range(&start_decimal, &end_decimal);
165
+
166
+ Ok(Self::category_to_symbol(&ruby, category))
167
+ }
168
+
169
+ /// Convert a Ruby Value to a fixed_decimal::Decimal
170
+ fn value_to_decimal(ruby: &Ruby, value: Value, name: &str) -> Result<Decimal, Error> {
171
+ if value.is_kind_of(ruby.class_float()) {
172
+ let f: f64 = TryConvert::try_convert(value)?;
173
+ let s = format!("{}", f);
174
+ s.parse::<Decimal>().map_err(|_| {
175
+ Error::new(
176
+ ruby.exception_arg_error(),
177
+ format!("Failed to convert {} ({}) to Decimal", name, f),
178
+ )
179
+ })
180
+ } else if value.is_kind_of(ruby.class_integer()) {
181
+ let n: i64 = TryConvert::try_convert(value)?;
182
+ Ok(Decimal::from(n))
183
+ } else {
184
+ Err(Error::new(
185
+ ruby.exception_type_error(),
186
+ format!("{} must be an Integer or Float", name),
187
+ ))
188
+ }
189
+ }
190
+
143
191
  /// Get the list of plural categories for this locale
144
192
  ///
145
193
  /// # Returns
@@ -147,7 +195,7 @@ impl PluralRules {
147
195
  fn categories(&self) -> RArray {
148
196
  let ruby = Ruby::get().expect("Ruby runtime should be available");
149
197
  let array = ruby.ary_new();
150
- for category in self.inner.categories() {
198
+ for category in self.inner.rules().categories() {
151
199
  let _ = array.push(Self::category_to_symbol(&ruby, category));
152
200
  }
153
201
  array
@@ -187,6 +235,7 @@ pub fn init(ruby: &Ruby, module: &RModule) -> Result<(), Error> {
187
235
  let class = module.define_class("PluralRules", ruby.class_object())?;
188
236
  class.define_singleton_method("new", function!(PluralRules::new, -1))?;
189
237
  class.define_method("select", method!(PluralRules::select, 1))?;
238
+ class.define_method("select_range", method!(PluralRules::select_range, 2))?;
190
239
  class.define_method("categories", method!(PluralRules::categories, 0))?;
191
240
  class.define_method(
192
241
  "resolved_options",