Repository: unicode-org/icu4x
Pull request #6517: Infer the zone datetime in FFI when possible
Author: login: sffc
Created: 2025-05-01T23:42:31Z | Merged: 2025-05-02T21:55:33Z

Mostly fixes #6378

It doesn't make FFI work for ZonedDateFormatter and ZonedTimeFormatter since this trick only works for ZonedDateTimeFormatter, but at least this is progress.

Linked issue #6378: ZonedDateTime FFI: How to set/infer the local time?
Currently https://unicode-org.github.io/icu4x/wasm-demo/template.html?func=ZonedDateTimeFormatter.formatIso doesn't work, I think because the `local_time` field is empty.

What should we do:

1. Add the local time to FFI `TimeZoneInfo::from_parts`. This will mean that you need to enter the date and time twice on the demo page (and when using the FFI).
2. Automatically set the time zone's local time if it isn't set yet from the time being formatted in the `format_iso` functions
3. Do something else to make the demo work but don't touch the FFI

@robertbastian 
Linked issue comments: user: login: robertbastian
created_at: 2025-03-28T09:14:02Z
body: I'm pretty sure the old datetime API did 2, which we should continue doing.
author_association: MEMBER
user: login: Manishearth
created_at: 2025-04-09T17:08:10Z
body: 2 seems good
author_association: MEMBER
user: login: sffc
created_at: 2025-04-10T07:08:28Z
body: ok, this is a small issue, then. It should be a <10-line change to the Askama template, plus tests.
author_association: MEMBER

Diff:
diff --git a/components/datetime/src/format/input.rs b/components/datetime/src/format/input.rs
index 58422c78a28..21b13c467bb 100644
--- a/components/datetime/src/format/input.rs
+++ b/components/datetime/src/format/input.rs
@@ -67,26 +67,26 @@ impl DateTimeInputUnchecked {
     /// responsible for making sure the calendar matches the formatter.
     pub fn set_date_fields_unchecked<C: Calendar, A: AsCalendar<Calendar = C>>(
         &mut self,
-        input: Date<A>,
+        date_in_calendar: Date<A>,
     ) {
-        self.year = Some(input.year());
-        self.month = Some(input.month());
-        self.day_of_month = Some(input.day_of_month());
-        self.iso_weekday = Some(input.day_of_week());
-        self.day_of_year = Some(input.day_of_year());
+        self.year = Some(date_in_calendar.year());
+        self.month = Some(date_in_calendar.month());
+        self.day_of_month = Some(date_in_calendar.day_of_month());
+        self.iso_weekday = Some(date_in_calendar.day_of_week());
+        self.day_of_year = Some(date_in_calendar.day_of_year());
     }
 
     /// Sets all fields from a [`Time`] input.
-    pub fn set_time_fields(&mut self, input: Time) {
-        self.hour = Some(input.hour);
-        self.minute = Some(input.minute);
-        self.second = Some(input.second);
-        self.subsecond = Some(input.subsecond);
+    pub fn set_time_fields(&mut self, time: Time) {
+        self.hour = Some(time.hour);
+        self.minute = Some(time.minute);
+        self.second = Some(time.second);
+        self.subsecond = Some(time.subsecond);
     }
 
     /// Sets the time zone UTC offset.
-    pub fn set_time_zone_utc_offset(&mut self, offset: UtcOffset) {
-        self.zone_offset = Some(offset);
+    pub fn set_time_zone_utc_offset(&mut self, utc_offset: UtcOffset) {
+        self.zone_offset = Some(utc_offset);
     }
 
     /// Sets the time zone ID.
diff --git a/ffi/capi/src/zoned_date_formatter.rs b/ffi/capi/src/zoned_date_formatter.rs
index 262591e2c67..96d8f874959 100644
--- a/ffi/capi/src/zoned_date_formatter.rs
+++ b/ffi/capi/src/zoned_date_formatter.rs
@@ -517,8 +517,8 @@ pub mod ffi {
             write: &mut diplomat_runtime::DiplomatWrite,
         ) -> Result<(), DateTimeWriteError> {
             let mut input = icu_datetime::DateTimeInputUnchecked::default();
-            let date = date.0.to_calendar(self.0.calendar());
-            input.set_date_fields_unchecked(date); // calendar conversion on previous line
+            let date_in_calendar = date.0.to_calendar(self.0.calendar());
+            input.set_date_fields_unchecked(date_in_calendar); // calendar conversion on previous line
             input.set_time_zone_id(zone.id);
             if let Some(offset) = zone.offset {
                 input.set_time_zone_utc_offset(offset);
@@ -1009,8 +1009,8 @@ pub mod ffi {
             write: &mut diplomat_runtime::DiplomatWrite,
         ) -> Result<(), DateTimeWriteError> {
             let mut input = icu_datetime::DateTimeInputUnchecked::default();
-            let date = date.0.to_calendar(Gregorian);
-            input.set_date_fields_unchecked(date); // calendar conversion on previous line
+            let date_in_calendar = date.0.to_calendar(Gregorian);
+            input.set_date_fields_unchecked(date_in_calendar); // calendar conversion on previous line
             input.set_time_zone_id(zone.id);
             if let Some(offset) = zone.offset {
                 input.set_time_zone_utc_offset(offset);
diff --git a/ffi/capi/src/zoned_date_time_formatter.rs b/ffi/capi/src/zoned_date_time_formatter.rs
index d2f892d5088..d8e170e74d1 100644
--- a/ffi/capi/src/zoned_date_time_formatter.rs
+++ b/ffi/capi/src/zoned_date_time_formatter.rs
@@ -518,8 +518,8 @@ pub mod ffi {
             write: &mut diplomat_runtime::DiplomatWrite,
         ) -> Result<(), DateTimeWriteError> {
             let mut input = icu_datetime::DateTimeInputUnchecked::default();
-            let date = date.0.to_calendar(self.0.calendar());
-            input.set_date_fields_unchecked(date); // calendar conversion on previous line
+            let date_in_calendar = date.0.to_calendar(self.0.calendar());
+            input.set_date_fields_unchecked(date_in_calendar); // calendar conversion on previous line
             input.set_time_fields(time.0);
             input.set_time_zone_id(zone.id);
             if let Some(offset) = zone.offset {
@@ -528,6 +528,12 @@ pub mod ffi {
             if let Some(zone_name_timestamp) = zone.zone_name_timestamp {
                 input.set_time_zone_name_timestamp(zone_name_timestamp);
             }
+            else {
+                input.set_time_zone_name_timestamp(icu_time::zone::ZoneNameTimestamp::from_date_time_iso(&icu_time::DateTime {
+                    date: date.0,
+                    time: time.0
+                }))
+            }
             if let Some(variant) = zone.variant {
                 input.set_time_zone_variant(variant);
             }
@@ -1012,8 +1018,8 @@ pub mod ffi {
             write: &mut diplomat_runtime::DiplomatWrite,
         ) -> Result<(), DateTimeWriteError> {
             let mut input = icu_datetime::DateTimeInputUnchecked::default();
-            let date = date.0.to_calendar(Gregorian);
-            input.set_date_fields_unchecked(date); // calendar conversion on previous line
+            let date_in_calendar = date.0.to_calendar(Gregorian);
+            input.set_date_fields_unchecked(date_in_calendar); // calendar conversion on previous line
             input.set_time_fields(time.0);
             input.set_time_zone_id(zone.id);
             if let Some(offset) = zone.offset {
@@ -1022,6 +1028,12 @@ pub mod ffi {
             if let Some(zone_name_timestamp) = zone.zone_name_timestamp {
                 input.set_time_zone_name_timestamp(zone_name_timestamp);
             }
+            else {
+                input.set_time_zone_name_timestamp(icu_time::zone::ZoneNameTimestamp::from_date_time_iso(&icu_time::DateTime {
+                    date: date.0,
+                    time: time.0
+                }))
+            }
             if let Some(variant) = zone.variant {
                 input.set_time_zone_variant(variant);
             }
diff --git a/ffi/dart/test/icu_test.dart b/ffi/dart/test/icu_test.dart
index 77d3811ba1e..110d14d7e3f 100644
--- a/ffi/dart/test/icu_test.dart
+++ b/ffi/dart/test/icu_test.dart
@@ -95,6 +95,16 @@ void main() {
       VariantOffsetsCalculator(),
     );
 
+    final utcOffset = UtcOffset.fromSeconds(-420);
+    final customZDT = ZonedIsoDateTime.fromEpochMillisecondsAndUtcOffset(
+      1746140981731,
+      utcOffset,
+    );
+    final customZone = TimeZoneInfo(
+      TimeZone.fromBcp47('uslax'),
+      offset: utcOffset,
+    );
+
     var locale = Locale.fromString('de-u-ca-islamic-umalqura');
 
     ///// DateFormatter /////
@@ -233,7 +243,7 @@ void main() {
     );
 
     expect(
-      () => ZonedDateTimeFormatter.genericLong(
+      () => ZonedDateTimeFormatter.specificLong(
         locale,
         DateTimeFormatter.ymdet(locale),
       ).formatIso(
@@ -268,6 +278,14 @@ void main() {
       '15.07.46 AH, 14:32:12 MEZ',
     );
 
+    expect(
+      ZonedDateTimeFormatter.genericLong(
+        locale,
+        DateTimeFormatter.mdt(locale),
+      ).formatIso(customZDT.date, customZDT.time, customZone),
+      '03.11., 23:02:41 Nordamerikanische Westküstenzeit',
+    );
+
     ///// ZonedDateTimeFormatterGregorian /////
 
     expect(
diff --git a/tools/make/codegen/templates/zoned_formatter.rs.jinja b/tools/make/codegen/templates/zoned_formatter.rs.jinja
index ab3d8478c2f..c93df51768a 100644
--- a/tools/make/codegen/templates/zoned_formatter.rs.jinja
+++ b/tools/make/codegen/templates/zoned_formatter.rs.jinja
@@ -190,11 +190,11 @@ pub mod ffi {
             let mut input = icu_datetime::DateTimeInputUnchecked::default();
             {%- if flavor.has_date() %}
             {%- if formatter_kind.is_fixed_calendar %}
-            let date = date.0.to_calendar(Gregorian);
+            let date_in_calendar = date.0.to_calendar(Gregorian);
             {%- else %}
-            let date = date.0.to_calendar(self.0.calendar());
+            let date_in_calendar = date.0.to_calendar(self.0.calendar());
             {%- endif %}
-            input.set_date_fields_unchecked(date); // calendar conversion on previous line
+            input.set_date_fields_unchecked(date_in_calendar); // calendar conversion on previous line
             {%- endif %}
             {%- if flavor.has_time() %}
             input.set_time_fields(time.0);
@@ -206,6 +206,14 @@ pub mod ffi {
             if let Some(zone_name_timestamp) = zone.zone_name_timestamp {
                 input.set_time_zone_name_timestamp(zone_name_timestamp);
             }
+            {%- if flavor.has_date() && flavor.has_time() %}
+            else {
+                input.set_time_zone_name_timestamp(icu_time::zone::ZoneNameTimestamp::from_date_time_iso(&icu_time::DateTime {
+                    date: date.0,
+                    time: time.0
+                }))
+            }
+            {%- endif %}
             if let Some(variant) = zone.variant {
                 input.set_time_zone_variant(variant);
             }


Reviews:
user: login: gemini-code-assist[bot]
body: Hello @sffc, I'm Gemini Code Assist[^1]! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

## Summary of Changes
This pull request addresses issue #6378 by inferring the zone datetime in FFI when possible. The changes primarily involve modifying the `DateTimeInputUnchecked` struct and updating the FFI bindings for `ZonedDateTimeFormatter` to automatically set the time zone name timestamp when it's not explicitly provided. This enhancement improves FFI support, particularly for `ZonedDateTimeFormatter`, although it doesn't yet extend to `ZonedDateFormatter` and `ZonedTimeFormatter`.

### Highlights
* **FFI Enhancement**: The pull request enhances FFI support by enabling the inference of zone datetime when possible, specifically targeting `ZonedDateTimeFormatter`.
* **Automatic Time Zone Timestamp**: The changes introduce automatic setting of the time zone name timestamp in `ZonedDateTimeFormatter` when it's not explicitly provided, using `ZoneNameTimestamp::from_date_time_iso`.
* **Code Modification**: The code has been modified to use `date_in_calendar` instead of `date` in `set_date_fields_unchecked` for clarity and consistency.

### Changelog
<details>
<summary>Click here to see the changelog</summary>

* **components/datetime/src/format/input.rs**
  * Renamed `input` to `date_in_calendar` in `set_date_fields_unchecked` for better readability.
* **ffi/capi/src/zoned_date_formatter.rs**
  * Renamed `date` to `date_in_calendar` in `write_to_diplomat` for better readability.
* **ffi/capi/src/zoned_date_time_formatter.rs**
  * Renamed `date` to `date_in_calendar` in `write_to_diplomat` for better readability.
  * Added logic to automatically set `time_zone_name_timestamp` using `ZoneNameTimestamp::from_date_time_iso` when not explicitly provided.
* **ffi/dart/test/icu_test.dart**
  * Added test case with `customZDT` and `customZone` to test the new functionality.
  * Changed `ZonedDateTimeFormatter.genericLong` to `ZonedDateTimeFormatter.specificLong` in one of the tests.
  * Added a new expectation to test `ZonedDateTimeFormatter.genericLong` with `customZDT` and `customZone`.
* **tools/make/codegen/templates/zoned_formatter.rs.jinja**
  * Renamed `date` to `date_in_calendar` in the generated code for better readability.
  * Added logic to automatically set `time_zone_name_timestamp` using `ZoneNameTimestamp::from_date_time_iso` when not explicitly provided in the generated code.

</details>


<details>

<summary><b>Using Gemini Code Assist</b></summary>
<br>

The full guide for Gemini Code Assist can be found on our [documentation page](https://developers.google.com/gemini-code-assist/docs/review-github-code), here are some quick tips.

<b>Invoking Gemini</b>

You can request assistance from Gemini at any point in your pull request via creating an issue comment (i.e. comment on the pull request page) using either `/gemini <command>` or `@gemini-code-assist <command>`. Below is a summary of the supported commands.

Feature | Command | Description
--- | --- | ---
Code Review | `/gemini review` | Performs a code review for the current pull request in its current state.
Pull Request Summary | `/gemini summary` | Provides a summary of the current pull request in its current state.
Comment | @gemini-code-assist | Responds in comments when explicitly tagged, both in issue comments and review comments.
Help | `/gemini help` | Displays a list of available commands.

<b>Customization</b>

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a `.gemini/` folder in the base of the repository. Detailed instructions can be found [here](https://developers.google.com/gemini-code-assist/docs/customize-gemini-behavior-github).

<b>Limitations & Feedback</b>

Gemini Code Assist is currently in preview and may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with :thumbsup: and :thumbsdown: on @gemini-code-assist comments to provide feedback.

</details>

<b>You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the [Gemini Code Assist IDE Extension](https://cloud.google.com/products/gemini/code-assist).</b>


[^1]: Review the [Privacy Notices](https://policies.google.com/privacy), [Generative AI Prohibited Use Policy](https://policies.google.com/terms/generative-ai/use-policy), [Terms of Service](https://policies.google.com/terms), and learn how to configure Gemini Code Assist in GitHub [here](https://developers.google.com/gemini-code-assist/docs/review-github-code). Gemini can make mistakes, so double check it and [use code with caution](https://support.google.com/legal/answer/13505487).

---

> In zones of time, a dance,
> FFI's grace, a hopeful chance.
> DateTime's form,
> Weathering the storm,
> In code's embrace, we advance.


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T23:42:42Z
user: login: gemini-code-assist[bot]
body: ## Code Review
This pull request introduces a change to infer the zone datetime in FFI when possible, which is a good step towards fixing #6378. The changes look reasonable, but there are a few areas that could be improved for clarity and correctness.

### Summary of Findings
* **Naming consistency**: The naming of variables could be more consistent across the codebase. For example, `date_in_calendar` is used in some places, while `date` is used in others. Standardizing this would improve readability.
* **Missing time zone name timestamp**: The logic for setting the time zone name timestamp could be simplified and made more robust. Currently, it only sets the timestamp if it's explicitly provided, but it should also handle the case where it's not provided and can be inferred from the date and time.

### Merge Readiness
The pull request makes progress towards resolving issue #6378, but it doesn't fully address the problem. There are also some areas for improvement in terms of naming consistency and logic simplification. I recommend addressing the high severity issue before merging. I am unable to approve this pull request, and users should have others review and approve this code before merging.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T23:43:06Z
user: login: Manishearth
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-02T21:47:53Z

Review comments:
diff_hunk: @@ -528,6 +528,12 @@ pub mod ffi {
             if let Some(zone_name_timestamp) = zone.zone_name_timestamp {
                 input.set_time_zone_name_timestamp(zone_name_timestamp);
             }
+            else {
+                input.set_time_zone_name_timestamp(icu_time::zone::ZoneNameTimestamp::from_date_time_iso(&icu_time::DateTime {
+                    date: date.0,
+                    time: time.0
+                }))
+            }
path: ffi/capi/src/zoned_date_time_formatter.rs
user: login: gemini-code-assist[bot]
body: ![high](https://img.shields.io/badge/priority-high-orange)

This `else` block is intended to infer the time zone name timestamp when it's not explicitly provided. However, it's unconditionally setting the timestamp based on the date and time. This might not always be the desired behavior. It would be better to check if the timestamp can be inferred from the zone ID before setting it here. Otherwise, the code might be overwriting a valid timestamp with an inferred one.

```suggestion
            if let Some(zone_name_timestamp) = zone.zone_name_timestamp {
                input.set_time_zone_name_timestamp(zone_name_timestamp);
            }
            else if zone.id.is_some() {
                input.set_time_zone_name_timestamp(icu_time::zone::ZoneNameTimestamp::from_date_time_iso(&icu_time::DateTime {
                    date: date.0,
                    time: time.0
                }))
            }

```
created_at: 2025-05-01T23:43:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1022,6 +1028,12 @@ pub mod ffi {
             if let Some(zone_name_timestamp) = zone.zone_name_timestamp {
                 input.set_time_zone_name_timestamp(zone_name_timestamp);
             }
+            else {
+                input.set_time_zone_name_timestamp(icu_time::zone::ZoneNameTimestamp::from_date_time_iso(&icu_time::DateTime {
+                    date: date.0,
+                    time: time.0
+                }))
+            }
path: ffi/capi/src/zoned_date_time_formatter.rs
user: login: gemini-code-assist[bot]
body: ![high](https://img.shields.io/badge/priority-high-orange)

This `else` block is intended to infer the time zone name timestamp when it's not explicitly provided. However, it's unconditionally setting the timestamp based on the date and time. This might not always be the desired behavior. It would be better to check if the timestamp can be inferred from the zone ID before setting it here. Otherwise, the code might be overwriting a valid timestamp with an inferred one.

```suggestion
            if let Some(zone_name_timestamp) = zone.zone_name_timestamp {
                input.set_time_zone_name_timestamp(zone_name_timestamp);
            }
            else if zone.id.is_some() {
                input.set_time_zone_name_timestamp(icu_time::zone::ZoneNameTimestamp::from_date_time_iso(&icu_time::DateTime {
                    date: date.0,
                    time: time.0
                }))
            }

```
created_at: 2025-05-01T23:43:06Z
author_association: CONTRIBUTOR

PR comments:
user: login: sffc
created_at: 2025-05-01T23:47:34Z
body: @gemini-code-assist What makes you think that the zone name timestamp can be inferred from the time zone ID
author_association: MEMBER
user: login: gemini-code-assist[bot]
created_at: 2025-05-01T23:47:57Z
body: Hi @sffc,

Thanks for the question! My reasoning was based on the assumption that if the time zone ID is known, and the `ZonedDateTimeFormatter` is already working with a specific `date` and `time`, then it's possible to create a `ZoneNameTimestamp` from that `date` and `time` using `ZoneNameTimestamp::from_date_time_iso`. This function essentially captures the moment when the zone name was valid for that specific date and time.

However, you're right to point out that this inference might not always be accurate or desired. There could be scenarios where the user wants to explicitly provide a `zone_name_timestamp` that is different from the one inferred from the `date` and `time`. Also, it's not a true 'inference' since it's derived from the date and time, not the ID itself.

I hope this clarifies my thought process. I'm open to discussing alternative approaches or refinements to this logic!
author_association: CONTRIBUTOR
user: login: sffc
created_at: 2025-05-01T23:49:28Z
body: But @gemini-code-assist, this PR is doing exactly what you asked for, I think. If the zone name timestamp was explicitly provided, we use it, and otherwise, we fall back to the formattable date and time.
author_association: MEMBER
user: login: gemini-code-assist[bot]
created_at: 2025-05-01T23:49:43Z
body: Hi @sffc,

You're absolutely right! Thanks for pointing that out. I was a bit too quick in my previous assessment. I appreciate you implementing the logic as intended, prioritizing the explicitly provided timestamp when available and falling back to the formattable date and time when it's not.

author_association: CONTRIBUTOR
user: login: sffc
created_at: 2025-05-02T21:27:51Z
body: Ping @unicode-org/icu4x-owners 
author_association: MEMBER
user: login: Manishearth
created_at: 2025-05-02T21:48:14Z
body: (I did have this in my queue, I was just deep in V8 stuff)
author_association: MEMBER