icu4x 0.8.1 → 0.9.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
  }
@@ -11,7 +11,7 @@ pub struct Locale {
11
11
 
12
12
  impl Locale {
13
13
  /// Parse a BCP 47 locale string
14
- fn parse(ruby: &Ruby, s: String) -> Result<Self, Error> {
14
+ fn parse_bcp47(ruby: &Ruby, s: String) -> Result<Self, Error> {
15
15
  let locale: IcuLocale = s.parse().map_err(|e| {
16
16
  Error::new(
17
17
  helpers::get_exception_class(ruby, "ICU4X::LocaleError"),
@@ -32,7 +32,7 @@ impl Locale {
32
32
  fn parse_posix(ruby: &Ruby, posix_str: String) -> Result<Self, Error> {
33
33
  // Handle special cases
34
34
  if posix_str == "C" || posix_str == "POSIX" {
35
- return Self::parse(ruby, "und".to_string());
35
+ return Self::parse_bcp47(ruby, "und".to_string());
36
36
  }
37
37
 
38
38
  // Handle empty string
@@ -90,7 +90,7 @@ impl Locale {
90
90
  bcp47.push_str(&t.to_uppercase());
91
91
  }
92
92
 
93
- Self::parse(ruby, bcp47)
93
+ Self::parse_bcp47(ruby, bcp47)
94
94
  }
95
95
 
96
96
  /// Get the language component
@@ -208,7 +208,8 @@ impl Locale {
208
208
 
209
209
  pub fn init(ruby: &Ruby, module: &RModule) -> Result<(), Error> {
210
210
  let class = module.define_class("Locale", ruby.class_object())?;
211
- class.define_singleton_method("parse", function!(Locale::parse, 1))?;
211
+ class.define_singleton_method("parse_bcp47", function!(Locale::parse_bcp47, 1))?;
212
+ class.singleton_class()?.define_alias("parse", "parse_bcp47")?;
212
213
  class.define_singleton_method("parse_posix", function!(Locale::parse_posix, 1))?;
213
214
  class.define_method("language", method!(Locale::language, 0))?;
214
215
  class.define_method("script", method!(Locale::script, 0))?;
@@ -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
+ }
271
306
 
272
- let mut decimal = Self::convert_to_decimal(&ruby, number)?;
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
+ }
339
+
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
@@ -382,6 +450,10 @@ pub fn init(ruby: &Ruby, module: &RModule) -> Result<(), Error> {
382
450
  let class = module.define_class("NumberFormat", ruby.class_object())?;
383
451
  class.define_singleton_method("new", function!(NumberFormat::new, -1))?;
384
452
  class.define_method("format", method!(NumberFormat::format, 1))?;
453
+ class.define_method(
454
+ "format_to_parts",
455
+ method!(NumberFormat::format_to_parts, 1),
456
+ )?;
385
457
  class.define_method(
386
458
  "resolved_options",
387
459
  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",
@@ -1,6 +1,8 @@
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;
5
+ use icu::decimal::parts as decimal_parts;
4
6
  use icu::experimental::relativetime::options::Numeric;
5
7
  use icu::experimental::relativetime::{
6
8
  RelativeTimeFormatter, RelativeTimeFormatterOptions, RelativeTimeFormatterPreferences,
@@ -8,9 +10,10 @@ use icu::experimental::relativetime::{
8
10
  use icu_provider::buf::AsDeserializingBufferProvider;
9
11
  use icu4x_macros::RubySymbol;
10
12
  use magnus::{
11
- Error, ExceptionClass, RHash, RModule, Ruby, Symbol, TryConvert, Value, function, method,
12
- prelude::*,
13
+ Error, ExceptionClass, RArray, RHash, RModule, Ruby, Symbol, TryConvert, Value, function,
14
+ method, prelude::*,
13
15
  };
16
+ use writeable::{Part, Writeable};
14
17
 
15
18
  /// The style of relative time formatting
16
19
  #[derive(Clone, Copy, PartialEq, Eq, RubySymbol)]
@@ -64,6 +67,17 @@ impl Unit {
64
67
  }
65
68
  }
66
69
 
70
+ /// Convert ICU4X relative time Part to Ruby symbol name
71
+ fn part_to_symbol_name(part: &Part) -> &'static str {
72
+ if *part == decimal_parts::INTEGER {
73
+ "integer"
74
+ } else if part.category == "relativetime" && part.value == "literal" {
75
+ "literal"
76
+ } else {
77
+ "literal"
78
+ }
79
+ }
80
+
67
81
  /// Ruby wrapper for ICU4X RelativeTimeFormatter
68
82
  ///
69
83
  /// Stores formatters for all 8 time units for the selected style.
@@ -228,15 +242,45 @@ impl RelativeTimeFormat {
228
242
  /// A formatted string
229
243
  fn format(&self, value: i64, unit: Symbol) -> Result<String, Error> {
230
244
  let ruby = Ruby::get().expect("Ruby runtime should be available");
245
+ let (formatter, decimal) = self.prepare_value(&ruby, value, unit)?;
246
+ let formatted = formatter.format(decimal);
247
+ Ok(formatted.to_string())
248
+ }
231
249
 
232
- let unit = Unit::from_ruby_symbol(&ruby, unit, "unit")?;
233
- let formatter = &self.formatters[unit.index()];
234
-
235
- // Convert i64 to Decimal
236
- let decimal = Decimal::from(value);
250
+ /// Format a relative time value and return an array of FormattedPart
251
+ ///
252
+ /// # Arguments
253
+ /// * `value` - The relative time value (negative = past, positive = future)
254
+ /// * `unit` - The time unit (:second, :minute, :hour, :day, :week, :month, :quarter, :year)
255
+ ///
256
+ /// # Returns
257
+ /// An array of FormattedPart objects with :type and :value
258
+ fn format_to_parts(&self, value: i64, unit: Symbol) -> Result<RArray, Error> {
259
+ let ruby = Ruby::get().expect("Ruby runtime should be available");
260
+ let (formatter, decimal) = self.prepare_value(&ruby, value, unit)?;
237
261
 
238
262
  let formatted = formatter.format(decimal);
239
- Ok(formatted.to_string())
263
+ let mut collector = PartsCollector::new();
264
+ formatted
265
+ .write_to_parts(&mut collector)
266
+ .map_err(|e| Error::new(ruby.exception_runtime_error(), format!("{}", e)))?;
267
+
268
+ parts_to_ruby_array(&ruby, collector, part_to_symbol_name)
269
+ }
270
+
271
+ /// Prepare value for formatting.
272
+ ///
273
+ /// Validates unit and converts value to Decimal.
274
+ fn prepare_value<'a>(
275
+ &'a self,
276
+ ruby: &Ruby,
277
+ value: i64,
278
+ unit: Symbol,
279
+ ) -> Result<(&'a RelativeTimeFormatter, Decimal), Error> {
280
+ let unit = Unit::from_ruby_symbol(ruby, unit, "unit")?;
281
+ let formatter = &self.formatters[unit.index()];
282
+ let decimal = Decimal::from(value);
283
+ Ok((formatter, decimal))
240
284
  }
241
285
 
242
286
  /// Get the resolved options
@@ -263,6 +307,10 @@ pub fn init(ruby: &Ruby, module: &RModule) -> Result<(), Error> {
263
307
  let class = module.define_class("RelativeTimeFormat", ruby.class_object())?;
264
308
  class.define_singleton_method("new", function!(RelativeTimeFormat::new, -1))?;
265
309
  class.define_method("format", method!(RelativeTimeFormat::format, 2))?;
310
+ class.define_method(
311
+ "format_to_parts",
312
+ method!(RelativeTimeFormat::format_to_parts, 2),
313
+ )?;
266
314
  class.define_method(
267
315
  "resolved_options",
268
316
  method!(RelativeTimeFormat::resolved_options, 0),
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "icu4x_macros"
3
- version = "0.1.0"
3
+ version = "0.9.0"
4
4
  edition = "2024"
5
5
 
6
6
  [lib]
@@ -84,13 +84,19 @@ module ICU4X
84
84
  config:
85
85
  )
86
86
 
87
- lib_data_dir = gem_dir / "lib" / "icu4x" / "data"
87
+ lib_dir = gem_dir / "lib"
88
+ lib_data_dir = lib_dir / "icu4x" / "data"
88
89
  lib_data_dir.mkpath
89
90
  render_template(
90
91
  "lib/icu4x/data/variant.rb.erb",
91
92
  lib_data_dir / "#{variant}.rb",
92
93
  variant:
93
94
  )
95
+ render_template(
96
+ "lib/icu4x-data-variant.rb.erb",
97
+ lib_dir / "icu4x-data-#{variant}.rb",
98
+ variant:
99
+ )
94
100
 
95
101
  # Copy LICENSE
96
102
  FileUtils.cp("LICENSE.txt", gem_dir / "LICENSE.txt")
data/lib/icu4x/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ICU4X
4
- VERSION = "0.8.1"
4
+ VERSION = "0.9.0"
5
5
  public_constant :VERSION
6
6
  end