Skip to content

Conversation

sffc
Copy link
Member

@sffc sffc commented Oct 1, 2025

#1174

Fixes #3147

This implementation aligns line-for-line with the spec. I intend to go back and add fast paths (like adding multiple years or months at once), but let's check in something that we know to be spec-compliant first.

@sffc sffc changed the title Implement CalendarDateAdd and CalendarDateUntil Implement date arithmetic according to Temporal specification Oct 3, 2025
@sffc sffc marked this pull request as ready for review October 3, 2025 04:47
@sffc sffc requested a review from Manishearth as a code owner October 3, 2025 04:47
@sffc sffc requested a review from nekevss October 3, 2025 04:47
@sffc
Copy link
Member Author

sffc commented Oct 3, 2025

I found some cases in the Hebrew calendar that I need to fix by fixing tc39/proposal-intl-era-monthcode#69. (This is why we have spec: so we can decide on the right thing upstream)

Copy link
Member

@Manishearth Manishearth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally a fan of the approach here. Will do full review on Monday when I can actually compare with the spec.

/// Options for taking the difference between two dates.
#[derive(Copy, Clone, PartialEq, Debug, Default)]
#[non_exhaustive]
pub struct DateUntilOptions {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bikeshed: Until or Difference, for this and related names?

I don't mind Until but it'll be confusing if we ever add since()

Not a blocker, worth discussing separately

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add since on Date, doesn't need to be on the trait

Copy link
Member

@Manishearth Manishearth Oct 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Absolutely, but if we do, does it make sense for the error and Options and other public API bits to be called Until?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fine calling the struct DateDifferenceOptions

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slight preference for that (and on the trait associated type, and elsewhere). Followup ok, and if people prefer Until that's fine too.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I renamed it to DateDifferenceOptions, and also UntilError to DifferenceError

robertbastian
robertbastian previously approved these changes Oct 3, 2025
#[derive(Copy, Clone, PartialEq, Debug, Default)]
#[non_exhaustive]
pub struct DateUntilOptions {
pub largest_unit: Option<DateDurationUnit>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be optional?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, the default value is contextually set by Temporal but I don't think we have all that context here and I think this is an ok thing to require from users.

But for convenience perhaps Unit or UntilOptions should have a default value? It's not fun to work with non exhaustives without one.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the question is does None have a behaviour that is different from all values?

Copy link
Member

@Manishearth Manishearth Oct 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've previously used None as a way of distinguishing user intent, so while None may have the same behavior as an option, we might change it later or add other magical defaults.

In some APIs we have None and Auto and that's intentional.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Options bags in ICU4X take Options by convention in ICU4X. In this particular case, I don't think there's a difference between None and Some(default), but I'd rather stick with what we do elsewhere

/// Options for taking the difference between two dates.
#[derive(Copy, Clone, PartialEq, Debug, Default)]
#[non_exhaustive]
pub struct DateUntilOptions {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add since on Date, doesn't need to be on the trait

@Manishearth
Copy link
Member

With Robert's review and my partial review I'm comfortable landing this so you can start optimizing, I'll let you know if there are post merge review issues.

And if there are test failures in V8.

Manishearth
Manishearth previously approved these changes Oct 6, 2025
Copy link
Member

@Manishearth Manishearth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. Slight preference for landing this and iterating so that upstream temporal_rs work can start sooner.

Main issue is that I would like to see more comments that explain the code without relying on the spec (we should still keep the spec comments)

}

impl<C: CalendarArithmetic> ArithmeticDate<C> {
/// Implements BalanceNonISODate
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: spec link, and an in-words explanation. Balance is Temporal-internal jargon, we should explain what this does in non-Temporal words too

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't link to the spec because tc39/proposal-intl-era-monthcode#69 still isn't merged, but I can explain what it does

// 1. Set _resolvedMonth_ to _resolvedMonth_ - 1.
// 1. If _resolvedMonth_ is 0, then
resolved_month -= 1;
if resolved_month == 0 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: it may be possible to write this code more efficiently as DateBalancer {y, m, d, m_in_y, d_in_m} with an add_years() add_months() add_days() set of methods.

But then we don't retain the spec correspondence

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I want to follow up with a PR after the line-for-line code is landed to implement optimizations like this

Self::new_unchecked(resolved_year, resolved_month, resolved_day)
}

pub(crate) fn surpasses(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: link to temporal spec if possible, and doc comment explaining this in non-temporal terms

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

// 1. Let _parts_ be CalendarISOToDate(_calendar_, _fromIsoDate_).
// 1. Let _y0_ be _parts_.[[Year]] + _years_.
let y0 = cal.year_info_from_extended(duration.add_years_to(self.year.to_extended_year()));
// 1. Let _m0_ be MonthCodeToOrdinal(_calendar_, _y0_, ! ConstrainMonthCode(_calendar_, _y0_, _parts_.[[MonthCode]], ~constrain~)).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: include an explanatory comment somewhere saying what this does ("transfer the month code to the new year" or something)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think for both this function and the one above, prose explanations of the various sections would be good. Matching the spec is nice for ensuring correctness, but if some behavior needs to be debugged it's good to have explanatory comments.

Prefer followup.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do in a follow-up

#[derive(Debug, Copy, Clone, Eq, PartialEq, Default)]
#[allow(clippy::exhaustive_structs)] // spec-defined in Temporal
#[doc(hidden)] // unstable
pub struct DateDuration {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

n.b. this is still hidden, as are add() and until().

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is to implement arithmetic according to Temporal. I want to decouple it from the graduation PR.

/// Error returned when comparing two [`Date`]s with [`AnyCalendar`].
#[derive(Debug, Copy, Clone)]
#[non_exhaustive]
pub enum AnyCalendarUntilError {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: maybe this should go in mod error

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll consider this as part of #7010

@Manishearth Manishearth dismissed stale reviews from robertbastian and themself via f1e4dbd October 6, 2025 16:02
impl Calendar for AnyCalendar {
type DateInner = AnyDateInner;
type Year = YearInfo;
type UntilError = AnyCalendarUntilError;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: I think this should go on a new trait, not calendar. AnyCalendar is the only calendar where the date difference is fallible, but this makes all calendars fallible. we can impl Date::since infallibly for a new trait CalendarDiff, and implement it fallibly as Date::try_since for Date<AnyCalendar>

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't make all calendars infallible, since the other calendars return an UntilError = Infallible, right?

I think being able to abstract over this is good.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but you still get a Result that you have to unwrap, unwrap_infallible doesn't exist, and even when it does, is not an ergonomic API.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually the docs currently say "In case the specific calendar objects differ on data, the data for the first calendar is used, and date2 may be converted if necessary.". Why don't we do that and make the AnyCalendar method infallible?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alternatively something like this to at least not make the Date API use infallible:

impl<A: AsCalendar> Date<A>
where
    A::Calendar: Calendar<UntilError = core::convert::Infallible>,
{
    pub fn until<B: AsCalendar<Calendar = A::Calendar>>(
        &self,
        other: &Date<B>,
        options: DateUntilOptions,
    ) -> DateDuration {
        self.calendar
            .as_calendar()
            .until(self.inner(), other.inner(), options)
            .unwrap_or_else(|e| match e {})
    }
}

impl<A: AsCalendar<Calendar = AnyCalendar>> Date<A> {
    pub fn try_until<B: AsCalendar<Calendar = A::Calendar>>(
        &self,
        other: &Date<B>,
        options: DateUntilOptions,
    ) -> Result<DateDuration, AnyCalendarUntilError> {
        self.calendar
            .as_calendar()
            .until(self.inner(), other.inner(), options)
    }
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm open to tweaking the APIs on Date as @robertbastian suggested.

If we do that, then should we try for consistency with the add functions?

Also, I want to note that I'm not intending to go for ergonomics in this PR. There is a lot of room we have to improve ergonomics, and I consider that very explicitly out of scope. I want to focus on landing the minimal usable API here. I'm open to prefixing functions by try_* if it gives us more room to improve in the future.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer having one single Date API, even if the Result is a little unergonomic.

I'm also okay with having this never error because AnyCalendar converts.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not auto-converting in AnyCalendar any more. I think it's a big footgun.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In that case I think I prefer having Result everywhere. let Ok(..) is fine. Rust has Infallible errors in a lot of places.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I renamed the Date functions to be try_added_with_options, try_add_with_options, and try_until_with_options. I think these names are very specific and give us room to add nicer ones in a follow-up.

@sffc
Copy link
Member Author

sffc commented Oct 7, 2025

I fixed the Hebrew bug; fortunately it was just a typo where I was using the wrong year variable.

I think this is ready to land. The APIs are still doc(hidden). I want follow-ups to:

  • Graduate the APIs and bikeshed them a bit more. Perhaps make the fields of DateDuration be private.
  • Add fast paths for adding multiple days, months, or years at once
  • Add more tests on algorithm behavior, such as Hebrew tests in the backwards direction
  • Add more tests on constrain/reject behavior and edge cases (constrain should never fail)

Manishearth
Manishearth previously approved these changes Oct 7, 2025
@sffc
Copy link
Member Author

sffc commented Oct 7, 2025


thread 'main' panicked at components/calendar/benches/date.rs:38:6:
called `Result::unwrap()` on an `Err` value: UnknownMonthCode(MonthCode("M04L"))
stack backtrace:
Testing date/calendar/julian
Success
Testing date/calendar/chinese_cached
   0:     0x55fdc93170b2 - std::backtrace_rs::backtrace::libunwind::trace::h2d45396358f41939
...
  17:     0x55fdc8d194f3 - core::result::Result<T,E>::unwrap::hcbc1ca801f2b226b
                               at /rustc/1159e78c4747b02ef996e55082b704c09b970588/library/core/src/result.rs:1167:23
  18:     0x55fdc8d194f3 - date::bench_date::hd4df229a13bf42b9
                               at /home/runner/work/icu4x/icu4x/components/calendar/benches/date.rs:38:6
  19:     0x55fdc8d1ba27 - date::bench_calendar::{{closure}}::{{closure}}::h4e96f5fed167e80f
                               at /home/runner/work/icu4x/icu4x/components/calendar/benches/date.rs:68:17
  20:     0x55fdc8d4590c - criterion::bencher::Bencher<M>::iter::h0dd6b3415aadf0c9
                               at /home/runner/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/criterion-0.5.1/src/bencher.rs:88:23
  21:     0x55fdc8d1a42f - date::bench_calendar::{{closure}}::h15d5e9f347cbe884
                               at /home/runner/work/icu4x/icu4x/components/calendar/benches/date.rs:57:11

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Calendar arithmetic support
3 participants