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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3a97e647de7657f35756c1c2fb3f03132773ad7fcc0f275a53c939995307d31d
4
- data.tar.gz: 6484dfe98f82c00e2036d884ae31a5bc91c5122e96b6eff5db2ca5d8b6551080
3
+ metadata.gz: 0c0cb2adcc5d5af24a00272def5a66e3b03a928b0cb522a0d7c5ee6b50eacdb5
4
+ data.tar.gz: 2f44d6cddb74e64fc922c6269d32b060ed8b03e751969d7bd0e1c87d584f0a59
5
5
  SHA512:
6
- metadata.gz: 83845dc2f7a88a6719ce992842fd9e30276f324a2a370a496fe076608a51f6695d0fb7f327195aad8bdbef01d8a768f3edcc67bf8f4da69b5b11b1b329beac42
7
- data.tar.gz: 9e9fdaea6e4004c4671b3a4491cd0a251408be272ad233c7d3bbc59871d98bab23ef7e95e50c50be319bcadb3440713cb714d591bdaa589970fa9fee5d295979
6
+ metadata.gz: 15b7967a5eecc044bbd3d3c1f3dfc36c08fd655cf9e83164c418239aa24a6cad7b622ac25d923cbc1d8f453cb8996be8ce5a1e38e3114f511a5f4ef1ebae2950
7
+ data.tar.gz: 68e9db100a5255557d6bf1c298f990dae44f8743ce0829ecc050a56491dd9ba27ac9d42aff3df583f1111b919c9af8395cca75f1c3eccc98ce6789502e826f56
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.9.0] - 2026-02-01
4
+
5
+ ### Added
6
+
7
+ - `hour12` option for `ICU4X::DateTimeFormat` as a simpler alternative to `hour_cycle` (#132)
8
+ - Default behavior for `ICU4X::DateTimeFormat` when no options specified, matching JavaScript Intl.DateTimeFormat (#130)
9
+ - Component options for `ICU4X::DateTimeFormat` (`year`, `month`, `day`, `weekday`, `hour`, `minute`, `second`) as an alternative to style options (#129)
10
+ - Document numbering system support via BCP 47 locale extensions (`-u-nu-xxx`) for `NumberFormat`, `DateTimeFormat`, and `RelativeTimeFormat` (#127)
11
+ - `hour_cycle` option for `ICU4X::DateTimeFormat` to control 12/24-hour time display (#112)
12
+ - `ICU4X::RelativeTimeFormat#format_to_parts` method for breaking down formatted output into typed parts (#117)
13
+ - `ICU4X::ListFormat#format_to_parts` method for breaking down formatted output into typed parts (#116)
14
+ - `ICU4X::NumberFormat#format_to_parts` method for breaking down formatted output into typed parts (#115)
15
+ - `ICU4X::DateTimeFormat#format_to_parts` method for breaking down formatted output into typed parts (#114)
16
+ - `ICU4X::FormattedPart` data class for representing formatted parts (#113)
17
+ - `ICU4X::PluralRules#select_range` method for plural category selection on number ranges (#106)
18
+ - Allow data gems to be required by gem name (#104)
19
+ - `ICU4X::Locale.parse_bcp47` method for explicit BCP 47 parsing; `parse` is now an alias (#108)
20
+
3
21
  ## [0.8.1] - 2026-01-12
4
22
 
5
23
  ### Added
data/Cargo.lock CHANGED
@@ -417,7 +417,7 @@ dependencies = [
417
417
 
418
418
  [[package]]
419
419
  name = "icu4x"
420
- version = "0.1.0"
420
+ version = "0.9.0"
421
421
  dependencies = [
422
422
  "fixed_decimal",
423
423
  "icu",
@@ -432,11 +432,12 @@ dependencies = [
432
432
  "jiff",
433
433
  "magnus",
434
434
  "tinystr",
435
+ "writeable",
435
436
  ]
436
437
 
437
438
  [[package]]
438
439
  name = "icu4x_macros"
439
- version = "0.1.0"
440
+ version = "0.9.0"
440
441
  dependencies = [
441
442
  "proc-macro2",
442
443
  "quote",
data/README.md CHANGED
@@ -41,16 +41,11 @@ Prebuilt binary gems are available for x86_64-linux, aarch64-linux, x86_64-darwi
41
41
 
42
42
  #### Option 1: Use Pre-built Data Gem (Quick Start)
43
43
 
44
- Add a companion data gem for instant setup:
44
+ Add a companion data gem to your Gemfile:
45
45
 
46
46
  ```ruby
47
47
  gem "icu4x"
48
- gem "icu4x-data-recommended" # 164 locales, ~24MB
49
- ```
50
-
51
- ```ruby
52
- require "icu4x"
53
- require "icu4x/data/recommended" # Auto-configures default provider
48
+ gem "icu4x-data-recommended" # 164 locales, ~24MB, auto-configures default provider
54
49
  ```
55
50
 
56
51
  Available data gems:
data/ext/icu4x/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "icu4x"
3
- version = "0.1.0"
3
+ version = "0.9.0"
4
4
  edition = "2024"
5
5
  publish = false
6
6
 
@@ -9,6 +9,7 @@ crate-type = ["cdylib"]
9
9
 
10
10
  [dependencies]
11
11
  magnus = "0.8"
12
+ writeable = "0.6"
12
13
  icu_locale = "2.1"
13
14
  icu_provider = "2.1"
14
15
  icu_provider_blob = { version = "2.1", features = ["alloc", "export"] }
@@ -1,22 +1,26 @@
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 icu::calendar::preferences::CalendarAlgorithm;
4
5
  use icu::calendar::{AnyCalendarKind, Date, Gregorian};
5
6
  use icu::datetime::fieldsets::enums::{
6
- CompositeDateTimeFieldSet, DateAndTimeFieldSet, DateFieldSet, TimeFieldSet,
7
+ CalendarPeriodFieldSet, CompositeDateTimeFieldSet, DateAndTimeFieldSet, DateFieldSet,
8
+ TimeFieldSet,
7
9
  };
8
10
  use icu::datetime::fieldsets::{self};
11
+ use icu::datetime::options::Length;
9
12
  use icu::datetime::input::DateTime;
13
+ use icu::datetime::parts as dt_parts;
10
14
  use icu::datetime::{DateTimeFormatter, DateTimeFormatterPreferences};
15
+ use icu::locale::preferences::extensions::unicode::keywords::HourCycle as IcuHourCycle;
11
16
  use icu::time::Time;
12
17
  use icu::time::zone::IanaParser;
13
18
  use icu_provider::buf::AsDeserializingBufferProvider;
14
19
  use icu4x_macros::RubySymbol;
15
20
  use jiff::Timestamp;
16
21
  use jiff::tz::TimeZone;
17
- use magnus::{
18
- Error, RHash, RModule, Ruby, TryConvert, Value, function, method, prelude::*,
19
- };
22
+ use magnus::{Error, RArray, RHash, RModule, Ruby, TryConvert, Value, function, method, prelude::*};
23
+ use writeable::{Part, Writeable};
20
24
 
21
25
  /// Date style option
22
26
  #[derive(Clone, Copy, PartialEq, Eq, RubySymbol)]
@@ -36,6 +40,134 @@ enum TimeStyle {
36
40
  Short,
37
41
  }
38
42
 
43
+ /// Hour cycle option
44
+ #[derive(Clone, Copy, PartialEq, Eq, RubySymbol)]
45
+ enum HourCycle {
46
+ H11,
47
+ H12,
48
+ H23,
49
+ }
50
+
51
+ impl HourCycle {
52
+ fn to_icu_hour_cycle(self) -> IcuHourCycle {
53
+ match self {
54
+ HourCycle::H11 => IcuHourCycle::H11,
55
+ HourCycle::H12 => IcuHourCycle::H12,
56
+ HourCycle::H23 => IcuHourCycle::H23,
57
+ }
58
+ }
59
+ }
60
+
61
+ /// Year component option
62
+ #[derive(Clone, Copy, PartialEq, Eq, RubySymbol)]
63
+ enum YearStyle {
64
+ Numeric,
65
+ TwoDigit,
66
+ }
67
+
68
+ /// Month component option
69
+ #[derive(Clone, Copy, PartialEq, Eq, RubySymbol)]
70
+ enum MonthStyle {
71
+ Numeric,
72
+ TwoDigit,
73
+ Long,
74
+ Short,
75
+ Narrow,
76
+ }
77
+
78
+ /// Day component option
79
+ #[derive(Clone, Copy, PartialEq, Eq, RubySymbol)]
80
+ enum DayStyle {
81
+ Numeric,
82
+ TwoDigit,
83
+ }
84
+
85
+ /// Weekday component option
86
+ #[derive(Clone, Copy, PartialEq, Eq, RubySymbol)]
87
+ enum WeekdayStyle {
88
+ Long,
89
+ Short,
90
+ Narrow,
91
+ }
92
+
93
+ /// Hour component option
94
+ #[derive(Clone, Copy, PartialEq, Eq, RubySymbol)]
95
+ enum HourStyle {
96
+ Numeric,
97
+ TwoDigit,
98
+ }
99
+
100
+ /// Minute component option
101
+ #[derive(Clone, Copy, PartialEq, Eq, RubySymbol)]
102
+ enum MinuteStyle {
103
+ Numeric,
104
+ TwoDigit,
105
+ }
106
+
107
+ /// Second component option
108
+ #[derive(Clone, Copy, PartialEq, Eq, RubySymbol)]
109
+ enum SecondStyle {
110
+ Numeric,
111
+ TwoDigit,
112
+ }
113
+
114
+ /// Component options for date/time formatting
115
+ #[derive(Clone, Copy, Default)]
116
+ struct ComponentOptions {
117
+ year: Option<YearStyle>,
118
+ month: Option<MonthStyle>,
119
+ day: Option<DayStyle>,
120
+ weekday: Option<WeekdayStyle>,
121
+ hour: Option<HourStyle>,
122
+ minute: Option<MinuteStyle>,
123
+ second: Option<SecondStyle>,
124
+ }
125
+
126
+ impl ComponentOptions {
127
+ fn has_date_components(&self) -> bool {
128
+ self.year.is_some() || self.month.is_some() || self.day.is_some() || self.weekday.is_some()
129
+ }
130
+
131
+ fn has_time_components(&self) -> bool {
132
+ self.hour.is_some() || self.minute.is_some() || self.second.is_some()
133
+ }
134
+
135
+ fn is_empty(&self) -> bool {
136
+ !self.has_date_components() && !self.has_time_components()
137
+ }
138
+
139
+ /// Determine the appropriate ICU4X Length based on component option values.
140
+ ///
141
+ /// When text-based month or weekday styles (:long, :short, :narrow) are specified,
142
+ /// we use Length::Long to ensure the format uses localized text patterns
143
+ /// (e.g., "2026年2月" in Japanese instead of "2026/02").
144
+ ///
145
+ /// This matches JavaScript Intl.DateTimeFormat behavior where specifying
146
+ /// month: "short" produces text-based formats with abbreviated month names,
147
+ /// not numeric formats.
148
+ ///
149
+ /// - If any text-based component (:long, :short, :narrow) → Length::Long
150
+ /// - Default (all numeric) → Length::Short
151
+ fn determine_length(&self) -> Length {
152
+ // Check for any text-based month or weekday option
153
+ let has_text_month = matches!(
154
+ self.month,
155
+ Some(MonthStyle::Long) | Some(MonthStyle::Short) | Some(MonthStyle::Narrow)
156
+ );
157
+ let has_text_weekday = matches!(
158
+ self.weekday,
159
+ Some(WeekdayStyle::Long) | Some(WeekdayStyle::Short) | Some(WeekdayStyle::Narrow)
160
+ );
161
+
162
+ if has_text_month || has_text_weekday {
163
+ return Length::Long;
164
+ }
165
+
166
+ // Default for numeric-only options
167
+ Length::Short
168
+ }
169
+ }
170
+
39
171
  /// Calendar option
40
172
  #[derive(Clone, Copy, PartialEq, Eq, RubySymbol)]
41
173
  enum Calendar {
@@ -94,6 +226,33 @@ impl Calendar {
94
226
  }
95
227
  }
96
228
 
229
+ /// Convert ICU4X datetime Part to Ruby symbol name
230
+ fn part_to_symbol_name(part: &Part) -> &'static str {
231
+ if *part == dt_parts::YEAR {
232
+ "year"
233
+ } else if *part == dt_parts::MONTH {
234
+ "month"
235
+ } else if *part == dt_parts::DAY {
236
+ "day"
237
+ } else if *part == dt_parts::WEEKDAY {
238
+ "weekday"
239
+ } else if *part == dt_parts::HOUR {
240
+ "hour"
241
+ } else if *part == dt_parts::MINUTE {
242
+ "minute"
243
+ } else if *part == dt_parts::SECOND {
244
+ "second"
245
+ } else if *part == dt_parts::DAY_PERIOD {
246
+ "day_period"
247
+ } else if *part == dt_parts::ERA {
248
+ "era"
249
+ } else if *part == dt_parts::TIME_ZONE_NAME {
250
+ "time_zone_name"
251
+ } else {
252
+ "literal"
253
+ }
254
+ }
255
+
97
256
  /// Ruby wrapper for ICU4X datetime formatters
98
257
  #[magnus::wrap(class = "ICU4X::DateTimeFormat", free_immediately, size)]
99
258
  pub struct DateTimeFormat {
@@ -104,6 +263,8 @@ pub struct DateTimeFormat {
104
263
  time_zone: Option<String>,
105
264
  jiff_timezone: Option<TimeZone>,
106
265
  calendar: Calendar,
266
+ hour_cycle: Option<HourCycle>,
267
+ component_options: Option<ComponentOptions>,
107
268
  }
108
269
 
109
270
  // SAFETY: This type is marked as Send to allow Ruby to move it between threads.
@@ -131,6 +292,7 @@ impl DateTimeFormat {
131
292
  /// * `time_zone:` - IANA timezone name (e.g., "Asia/Tokyo")
132
293
  /// * `calendar:` - :gregory, :japanese, :buddhist, :chinese, :hebrew, :islamic,
133
294
  /// :persian, :indian, :ethiopian, :coptic, :roc, :dangi
295
+ /// * `hour_cycle:` - :h11, :h12, or :h23
134
296
  fn new(ruby: &Ruby, args: &[Value]) -> Result<Self, Error> {
135
297
  // Parse arguments: (locale, **kwargs)
136
298
  let (icu_locale, locale_str) = helpers::extract_locale(ruby, args)?;
@@ -153,14 +315,35 @@ impl DateTimeFormat {
153
315
  let time_style =
154
316
  helpers::extract_symbol(ruby, &kwargs, "time_style", TimeStyle::from_ruby_symbol)?;
155
317
 
156
- // At least one of date_style or time_style must be specified
157
- if date_style.is_none() && time_style.is_none() {
318
+ // Extract component options
319
+ let component_options = Self::extract_component_options(ruby, &kwargs)?;
320
+
321
+ // Validate: style options and component options are mutually exclusive
322
+ let has_style_options = date_style.is_some() || time_style.is_some();
323
+ let has_component_options = !component_options.is_empty();
324
+
325
+ if has_style_options && has_component_options {
158
326
  return Err(Error::new(
159
327
  ruby.exception_arg_error(),
160
- "at least one of date_style or time_style must be specified",
328
+ "cannot use date_style/time_style together with component options (year, month, day, etc.)",
161
329
  ));
162
330
  }
163
331
 
332
+ // Apply default component options if no options specified
333
+ // Default: year: :numeric, month: :numeric, day: :numeric
334
+ // This matches JavaScript Intl.DateTimeFormat default behavior
335
+ let component_options = if !has_style_options && !has_component_options {
336
+ ComponentOptions {
337
+ year: Some(YearStyle::Numeric),
338
+ month: Some(MonthStyle::Numeric),
339
+ day: Some(DayStyle::Numeric),
340
+ ..Default::default()
341
+ }
342
+ } else {
343
+ component_options
344
+ };
345
+ let has_component_options = !component_options.is_empty();
346
+
164
347
  // Extract time_zone option and parse it
165
348
  let time_zone: Option<String> =
166
349
  kwargs.lookup::<_, Option<String>>(ruby.to_symbol("time_zone"))?;
@@ -192,6 +375,20 @@ impl DateTimeFormat {
192
375
  let calendar =
193
376
  helpers::extract_symbol(ruby, &kwargs, "calendar", Calendar::from_ruby_symbol)?;
194
377
 
378
+ // Extract hour_cycle option
379
+ let hour_cycle =
380
+ helpers::extract_symbol(ruby, &kwargs, "hour_cycle", HourCycle::from_ruby_symbol)?;
381
+
382
+ // Extract hour12 option and convert to hour_cycle if hour_cycle is not specified
383
+ // hour12: true → :h12, hour12: false → :h23
384
+ let hour12: Option<bool> = kwargs.lookup::<_, Option<bool>>(ruby.to_symbol("hour12"))?;
385
+ let hour_cycle = match (hour_cycle, hour12) {
386
+ (Some(hc), _) => Some(hc), // hour_cycle takes precedence
387
+ (None, Some(true)) => Some(HourCycle::H12),
388
+ (None, Some(false)) => Some(HourCycle::H23),
389
+ (None, None) => None,
390
+ };
391
+
195
392
  // Get the error exception class
196
393
  let error_class = helpers::get_exception_class(ruby, "ICU4X::Error");
197
394
 
@@ -203,14 +400,21 @@ impl DateTimeFormat {
203
400
  )
204
401
  })?;
205
402
 
206
- // Create field set based on date_style and time_style
207
- let field_set = Self::create_field_set(date_style, time_style);
403
+ // Create field set based on options
404
+ let field_set = if has_component_options {
405
+ Self::create_field_set_from_components(ruby, &component_options)?
406
+ } else {
407
+ Self::create_field_set_from_style(date_style, time_style)
408
+ };
208
409
 
209
- // Create formatter with calendar preference
410
+ // Create formatter with calendar and hour_cycle preferences
210
411
  let mut prefs: DateTimeFormatterPreferences = (&icu_locale).into();
211
412
  if let Some(cal) = calendar {
212
413
  prefs.calendar_algorithm = Some(cal.to_calendar_algorithm());
213
414
  }
415
+ if let Some(hc) = hour_cycle {
416
+ prefs.hour_cycle = Some(hc.to_icu_hour_cycle());
417
+ }
214
418
 
215
419
  let formatter =
216
420
  DateTimeFormatter::try_new_unstable(&dp.inner.as_deserializing(), prefs, field_set)
@@ -232,11 +436,130 @@ impl DateTimeFormat {
232
436
  time_zone,
233
437
  jiff_timezone,
234
438
  calendar: resolved_calendar,
439
+ hour_cycle,
440
+ component_options: if has_component_options {
441
+ Some(component_options)
442
+ } else {
443
+ None
444
+ },
235
445
  })
236
446
  }
237
447
 
448
+ /// Extract component options from kwargs
449
+ fn extract_component_options(ruby: &Ruby, kwargs: &RHash) -> Result<ComponentOptions, Error> {
450
+ let year = helpers::extract_symbol(ruby, kwargs, "year", YearStyle::from_ruby_symbol)?;
451
+ let month = helpers::extract_symbol(ruby, kwargs, "month", MonthStyle::from_ruby_symbol)?;
452
+ let day = helpers::extract_symbol(ruby, kwargs, "day", DayStyle::from_ruby_symbol)?;
453
+ let weekday =
454
+ helpers::extract_symbol(ruby, kwargs, "weekday", WeekdayStyle::from_ruby_symbol)?;
455
+ let hour = helpers::extract_symbol(ruby, kwargs, "hour", HourStyle::from_ruby_symbol)?;
456
+ let minute =
457
+ helpers::extract_symbol(ruby, kwargs, "minute", MinuteStyle::from_ruby_symbol)?;
458
+ let second =
459
+ helpers::extract_symbol(ruby, kwargs, "second", SecondStyle::from_ruby_symbol)?;
460
+
461
+ Ok(ComponentOptions {
462
+ year,
463
+ month,
464
+ day,
465
+ weekday,
466
+ hour,
467
+ minute,
468
+ second,
469
+ })
470
+ }
471
+
472
+ /// Create field set from component options
473
+ ///
474
+ /// Maps component combinations to appropriate ICU4X Field Sets.
475
+ /// Field Sets determine which components appear; the locale determines their order.
476
+ /// The length is determined by the component option values (e.g., :long → Long).
477
+ fn create_field_set_from_components(
478
+ ruby: &Ruby,
479
+ opts: &ComponentOptions,
480
+ ) -> Result<CompositeDateTimeFieldSet, Error> {
481
+ let has_date = opts.has_date_components();
482
+ let has_time = opts.has_time_components();
483
+ let length = opts.determine_length();
484
+
485
+ match (has_date, has_time) {
486
+ (true, true) => {
487
+ // Date and time components
488
+ Ok(CompositeDateTimeFieldSet::DateTime(
489
+ DateAndTimeFieldSet::YMDT(fieldsets::YMDT::for_length(length)),
490
+ ))
491
+ }
492
+ (true, false) => {
493
+ // Date only - choose field set based on which components are specified
494
+ let has_year = opts.year.is_some();
495
+ let has_month = opts.month.is_some();
496
+ let has_day = opts.day.is_some();
497
+ let has_weekday = opts.weekday.is_some();
498
+
499
+ match (has_year, has_month, has_day, has_weekday) {
500
+ // Year + Month + Day + Weekday
501
+ (true, true, true, true) => Ok(CompositeDateTimeFieldSet::Date(
502
+ DateFieldSet::YMDE(fieldsets::YMDE::for_length(length)),
503
+ )),
504
+ // Year + Month + Day
505
+ (true, true, true, false) => Ok(CompositeDateTimeFieldSet::Date(
506
+ DateFieldSet::YMD(fieldsets::YMD::for_length(length)),
507
+ )),
508
+ // Month + Day + Weekday
509
+ (false, true, true, true) => Ok(CompositeDateTimeFieldSet::Date(
510
+ DateFieldSet::MDE(fieldsets::MDE::for_length(length)),
511
+ )),
512
+ // Month + Day
513
+ (false, true, true, false) => Ok(CompositeDateTimeFieldSet::Date(
514
+ DateFieldSet::MD(fieldsets::MD::for_length(length)),
515
+ )),
516
+ // Year + Month (calendar period)
517
+ (true, true, false, _) => Ok(CompositeDateTimeFieldSet::CalendarPeriod(
518
+ CalendarPeriodFieldSet::YM(fieldsets::YM::for_length(length)),
519
+ )),
520
+ // Month only (calendar period)
521
+ (false, true, false, _) => Ok(CompositeDateTimeFieldSet::CalendarPeriod(
522
+ CalendarPeriodFieldSet::M(fieldsets::M::for_length(length)),
523
+ )),
524
+ // Day + Weekday
525
+ (false, false, true, true) => Ok(CompositeDateTimeFieldSet::Date(
526
+ DateFieldSet::DE(fieldsets::DE::for_length(length)),
527
+ )),
528
+ // Day only
529
+ (false, false, true, false) => Ok(CompositeDateTimeFieldSet::Date(
530
+ DateFieldSet::D(fieldsets::D::for_length(length)),
531
+ )),
532
+ // Weekday only
533
+ (false, false, false, true) => Ok(CompositeDateTimeFieldSet::Date(
534
+ DateFieldSet::E(fieldsets::E::for_length(length)),
535
+ )),
536
+ // Year only (calendar period)
537
+ (true, false, false, _) => Ok(CompositeDateTimeFieldSet::CalendarPeriod(
538
+ CalendarPeriodFieldSet::Y(fieldsets::Y::for_length(length)),
539
+ )),
540
+ // Year + Day (not a standard combination, use YMD as fallback)
541
+ (true, false, true, _) => Ok(CompositeDateTimeFieldSet::Date(
542
+ DateFieldSet::YMD(fieldsets::YMD::for_length(length)),
543
+ )),
544
+ // Should not happen - we checked has_date_components
545
+ (false, false, false, false) => unreachable!(),
546
+ }
547
+ }
548
+ (false, true) => {
549
+ // Time only - use medium as default since time components are always numeric
550
+ Ok(CompositeDateTimeFieldSet::Time(TimeFieldSet::T(
551
+ fieldsets::T::for_length(length),
552
+ )))
553
+ }
554
+ (false, false) => Err(Error::new(
555
+ ruby.exception_arg_error(),
556
+ "at least one component option must be specified",
557
+ )),
558
+ }
559
+ }
560
+
238
561
  /// Create field set based on date_style and time_style
239
- fn create_field_set(
562
+ fn create_field_set_from_style(
240
563
  date_style: Option<DateStyle>,
241
564
  time_style: Option<TimeStyle>,
242
565
  ) -> CompositeDateTimeFieldSet {
@@ -284,7 +607,36 @@ impl DateTimeFormat {
284
607
  /// A formatted string
285
608
  fn format(&self, time: Value) -> Result<String, Error> {
286
609
  let ruby = Ruby::get().expect("Ruby runtime should be available");
610
+ let datetime = self.prepare_datetime(&ruby, time)?;
611
+ let formatted = self.inner.format(&datetime);
612
+ Ok(formatted.to_string())
613
+ }
614
+
615
+ /// Format a Ruby Time object and return an array of FormattedPart
616
+ ///
617
+ /// # Arguments
618
+ /// * `time` - A Ruby Time object or an object responding to #to_time (e.g., Date, DateTime)
619
+ ///
620
+ /// # Returns
621
+ /// An array of FormattedPart objects with :type and :value
622
+ fn format_to_parts(&self, time: Value) -> Result<RArray, Error> {
623
+ let ruby = Ruby::get().expect("Ruby runtime should be available");
624
+ let datetime = self.prepare_datetime(&ruby, time)?;
625
+
626
+ let formatted = self.inner.format(&datetime);
627
+ let mut collector = PartsCollector::new();
628
+ formatted
629
+ .write_to_parts(&mut collector)
630
+ .map_err(|e| Error::new(ruby.exception_runtime_error(), format!("{}", e)))?;
631
+
632
+ parts_to_ruby_array(&ruby, collector, part_to_symbol_name)
633
+ }
287
634
 
635
+ /// Prepare a Ruby Time value for formatting.
636
+ ///
637
+ /// Converts objects responding to #to_time, validates the result,
638
+ /// and converts to ICU4X DateTime.
639
+ fn prepare_datetime(&self, ruby: &Ruby, time: Value) -> Result<DateTime<Gregorian>, Error> {
288
640
  // Convert to Time if the object responds to #to_time
289
641
  let time_value = if time.respond_to("to_time", false)? {
290
642
  time.funcall::<_, _, Value>("to_time", ())?
@@ -301,12 +653,7 @@ impl DateTimeFormat {
301
653
  ));
302
654
  }
303
655
 
304
- // Convert Ruby Time to ICU4X DateTime, applying timezone if specified
305
- let datetime = self.convert_time_to_datetime(&ruby, time_value)?;
306
-
307
- // Format the datetime
308
- let formatted = self.inner.format(&datetime);
309
- Ok(formatted.to_string())
656
+ self.convert_time_to_datetime(ruby, time_value)
310
657
  }
311
658
 
312
659
  /// Convert Ruby Time to ICU4X DateTime<Gregorian>
@@ -374,7 +721,7 @@ impl DateTimeFormat {
374
721
  /// Get the resolved options
375
722
  ///
376
723
  /// # Returns
377
- /// A hash with :locale, :calendar, :date_style, :time_style, and optionally :time_zone
724
+ /// A hash with :locale, :calendar, :date_style, :time_style, and optionally :time_zone, :hour_cycle
378
725
  fn resolved_options(&self) -> Result<RHash, Error> {
379
726
  let ruby = Ruby::get().expect("Ruby runtime should be available");
380
727
  let hash = ruby.hash_new();
@@ -403,6 +750,59 @@ impl DateTimeFormat {
403
750
  hash.aset(ruby.to_symbol("time_zone"), tz.as_str())?;
404
751
  }
405
752
 
753
+ if let Some(hc) = self.hour_cycle {
754
+ hash.aset(
755
+ ruby.to_symbol("hour_cycle"),
756
+ ruby.to_symbol(hc.to_symbol_name()),
757
+ )?;
758
+ }
759
+
760
+ // Add component options if they were used
761
+ if let Some(ref opts) = self.component_options {
762
+ if let Some(year) = opts.year {
763
+ hash.aset(
764
+ ruby.to_symbol("year"),
765
+ ruby.to_symbol(year.to_symbol_name()),
766
+ )?;
767
+ }
768
+ if let Some(month) = opts.month {
769
+ hash.aset(
770
+ ruby.to_symbol("month"),
771
+ ruby.to_symbol(month.to_symbol_name()),
772
+ )?;
773
+ }
774
+ if let Some(day) = opts.day {
775
+ hash.aset(
776
+ ruby.to_symbol("day"),
777
+ ruby.to_symbol(day.to_symbol_name()),
778
+ )?;
779
+ }
780
+ if let Some(weekday) = opts.weekday {
781
+ hash.aset(
782
+ ruby.to_symbol("weekday"),
783
+ ruby.to_symbol(weekday.to_symbol_name()),
784
+ )?;
785
+ }
786
+ if let Some(hour) = opts.hour {
787
+ hash.aset(
788
+ ruby.to_symbol("hour"),
789
+ ruby.to_symbol(hour.to_symbol_name()),
790
+ )?;
791
+ }
792
+ if let Some(minute) = opts.minute {
793
+ hash.aset(
794
+ ruby.to_symbol("minute"),
795
+ ruby.to_symbol(minute.to_symbol_name()),
796
+ )?;
797
+ }
798
+ if let Some(second) = opts.second {
799
+ hash.aset(
800
+ ruby.to_symbol("second"),
801
+ ruby.to_symbol(second.to_symbol_name()),
802
+ )?;
803
+ }
804
+ }
805
+
406
806
  Ok(hash)
407
807
  }
408
808
  }
@@ -411,6 +811,10 @@ pub fn init(ruby: &Ruby, module: &RModule) -> Result<(), Error> {
411
811
  let class = module.define_class("DateTimeFormat", ruby.class_object())?;
412
812
  class.define_singleton_method("new", function!(DateTimeFormat::new, -1))?;
413
813
  class.define_method("format", method!(DateTimeFormat::format, 1))?;
814
+ class.define_method(
815
+ "format_to_parts",
816
+ method!(DateTimeFormat::format_to_parts, 1),
817
+ )?;
414
818
  class.define_method(
415
819
  "resolved_options",
416
820
  method!(DateTimeFormat::resolved_options, 0),
data/ext/icu4x/src/lib.rs CHANGED
@@ -7,6 +7,7 @@ mod helpers;
7
7
  mod list_format;
8
8
  mod locale;
9
9
  mod number_format;
10
+ mod parts_collector;
10
11
  mod plural_rules;
11
12
  mod relative_time_format;
12
13
  mod segmenter;