diff --git a/Cargo.toml b/Cargo.toml index 4e024e5c..4a149697 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ members = [ ] [workspace.package] -version = "0.19.9" +version = "0.19.10" edition = "2021" license = "MIT" rust-version = "1.80" diff --git a/crates/oxide_builtins/src/temporal/plain_date.rs b/crates/oxide_builtins/src/temporal/plain_date.rs index bcd4fa91..72d99b49 100644 --- a/crates/oxide_builtins/src/temporal/plain_date.rs +++ b/crates/oxide_builtins/src/temporal/plain_date.rs @@ -8,8 +8,9 @@ use oxide_types::value::JsValue; use super::common::native_try; use super::common::{ days_from_civil, days_in_month, ensure_plain_date, get_calendar_id, get_double_prop, initialize_temporal_receiver, - is_ctor_call, parse_plain_date_string, plain_date_time_object_parts, receiver_obj, temporal_calendar_id_strict, - temporal_number_component, temporal_overflow, zoned_date_time_plain_parts, + is_ctor_call, parse_plain_date_string, plain_date_time_object_parts, receiver_obj, reject_partial_object_with_calendar_or_time_zone, + resolve_date_time_parts, temporal_calendar_id_strict, temporal_number_component, temporal_option_string, + temporal_option_value, temporal_overflow, zoned_date_time_plain_parts, DateTimeParts, }; use super::difference::{difference_core, parse_difference_settings, IsoDateTime}; use super::duration::duration_like_values; @@ -498,6 +499,105 @@ pub fn plain_date_equals(vm: &mut H, args: &[u8]) -> NativeResult { NativeResult::Ok(JsValue::bool(equal)) } +/// `Temporal.PlainDate.prototype.with(temporalDateLike[, options])`:覆盖 year/month/day/monthCode; +/// undefined 字段不覆盖;calendar/timeZone 键与 Temporal 实例、字符串参数均抛 TypeError; +/// 四键全 undefined 抛 TypeError;overflow 读自 options(缺省 constrain)。 +/// 结果保留 receiver 日历。 +/// +/// # 步骤 +/// 1. receiver 品牌校验;partial 参数经 reject_partial_object_with_calendar_or_time_zone +/// 排除日历/时区载体(其内部按序读 calendar、timeZone 两键)。 +/// 2. 按规范读序逐字段读并就地转换:day → month → monthCode → year(数值字段 +/// ToNumber,monthCode ToString),转换错误先于 options 校验抛出。 +/// 3. month/day 小于 1 抛 RangeError(先于 options 形态错误)。 +/// 4. 读 overflow;monthCode 语义校验(L 后缀、超界、与 month 冲突)在 options 之后。 +/// 5. 与 receiver 字段合并后经 resolve_date_time_parts 钳制/校验;显式 day count +/// 范围检查(ignore_time 路径不查 ISODateWithinLimits)。 +pub fn plain_date_with(vm: &mut H, args: &[u8]) -> NativeResult { + let ptr = native_try!(receiver_obj(vm, args)); + let obj = unsafe { &*ptr }; + native_try!(ensure_plain_date(vm, obj)); + let value = if args.len() > 1 { vm.reg(args[1]) } else { JsValue::undefined() }; + native_try!(reject_partial_object_with_calendar_or_time_zone(vm, value)); + let vptr = value.as_js_object_ptr(); + if vptr.is_null() { + return NativeResult::Err(crate::error::create_type_error(vm, "invalid argument")); + } + let vobj = unsafe { &*vptr }; + // 读序由规范固定:day → month → monthCode → year,每字段读后立即转换。 + let day_raw = native_try!(temporal_option_value(vm, vobj, value, "day")); + let day = if day_raw.is_undefined() { + None + } else { + Some(native_try!(temporal_number_component(vm, day_raw))) + }; + let month_raw = native_try!(temporal_option_value(vm, vobj, value, "month")); + let month = if month_raw.is_undefined() { + None + } else { + Some(native_try!(temporal_number_component(vm, month_raw))) + }; + let month_code_raw = native_try!(temporal_option_value(vm, vobj, value, "monthCode")); + let month_code = if month_code_raw.is_undefined() { + None + } else { + Some(native_try!(temporal_option_string(vm, month_code_raw))) + }; + let year_raw = native_try!(temporal_option_value(vm, vobj, value, "year")); + let year = if year_raw.is_undefined() { + None + } else { + Some(native_try!(temporal_number_component(vm, year_raw))) + }; + if day.is_none() && month.is_none() && month_code.is_none() && year.is_none() { + return NativeResult::Err(crate::error::create_type_error(vm, "no properties present")); + } + // 月/日小于 1 的 RangeError 先于 options 校验抛出。 + if let Some(month) = month { + if month < 1.0 { + return NativeResult::Err(crate::error::create_range_error(vm, "invalid month")); + } + } + if let Some(day) = day { + if day < 1.0 { + return NativeResult::Err(crate::error::create_range_error(vm, "invalid day")); + } + } + let constrain = native_try!(temporal_overflow(vm, args)); + // 与 receiver 字段合并:bag 字段优先,undefined 字段回落 receiver 槽值。 + // receiver 月只在 bag 未提供 month 与 monthCode 时参与合并,避免与 + // bag 的 monthCode 触发误冲突(冲突检查仅作用于 bag 字段)。 + let month = if month.is_none() && month_code.is_none() { + Some(get_double_prop(obj, 1)) + } else { + month + }; + let (year, month, day, _, _) = native_try!(resolve_date_time_parts( + vm, + None, + DateTimeParts { + year: year.or(Some(get_double_prop(obj, 0))), + month, + month_code, + day: day.or(Some(get_double_prop(obj, 2))), + hour: 0.0, + minute: 0.0, + second: 0.0, + millisecond: 0.0, + microsecond: 0.0, + nanosecond: 0.0, + }, + constrain, + true, + )); + // ignore_time 路径不查 ISODateWithinLimits,day count 表示范围须显式检查。 + let day_count = days_from_civil(i128::from(year), i128::from(month), i128::from(day)); + if !(-100_000_001..=100_000_000).contains(&day_count) { + return NativeResult::Err(crate::error::create_range_error(vm, "ISO date is out of range")); + } + make_plain_date(vm, year, month, day, &get_calendar_id(obj, 3)) +} + /// `Temporal.PlainDate.compare(a, b)`:静态比较,返回 -1/0/1。 pub fn plain_date_compare(vm: &mut H, args: &[u8]) -> NativeResult { let a_val = if args.len() > 1 { vm.reg(args[1]) } else { JsValue::undefined() }; diff --git a/crates/oxide_builtins/src/temporal/plain_date_time.rs b/crates/oxide_builtins/src/temporal/plain_date_time.rs index fa4270fe..50e68e92 100644 --- a/crates/oxide_builtins/src/temporal/plain_date_time.rs +++ b/crates/oxide_builtins/src/temporal/plain_date_time.rs @@ -11,10 +11,11 @@ use super::{ duration_like_values, ensure_plain_date_time, format_iso_year, get_calendar_id, get_double_prop, initialize_temporal_receiver, instant_rounding_mode, is_ctor_call, is_leap_year_iso, make_plain_date, make_plain_date_time, make_plain_time, parse_difference_settings, parse_fractional_second_digits, - parse_temporal_string_impl, plain_date_time_object_parts, plain_time_components, receiver_obj, round_instant_ns, - temporal_calendar_id_strict, temporal_option_number, temporal_option_string, temporal_option_value, - temporal_overflow, valid_iso_date, valid_plain_date_time_range, valid_plain_time, zoned_date_time_plain_parts, - FractionalSecondDigitsInput, IsoDateTime, + parse_temporal_string_impl, plain_date_time_object_parts, plain_time_components, receiver_obj, + reject_partial_object_with_calendar_or_time_zone, resolve_date_time_parts, round_instant_ns, + temporal_calendar_id_strict, temporal_number_component, temporal_option_number, temporal_option_string, + temporal_option_value, temporal_overflow, valid_iso_date, valid_plain_date_time_range, valid_plain_time, + zoned_date_time_plain_parts, DateTimeParts, FractionalSecondDigitsInput, IsoDateTime, }; // ───────────────────── PlainDateTime 基础方法 ───────────────────── @@ -219,6 +220,143 @@ pub fn plain_date_time_equals(vm: &mut H, args: &[u8]) -> NativeResul NativeResult::Ok(JsValue::bool(equal)) } +/// `Temporal.PlainDateTime.prototype.with(temporalDateTimeLike[, options])`:覆盖日期与时间全字段; +/// undefined 字段不覆盖;calendar/timeZone 键与 Temporal 实例、字符串参数均抛 TypeError; +/// 十键全 undefined 抛 TypeError;overflow 读自 options(缺省 constrain)。 +/// 结果保留 receiver 日历。 +/// +/// # 步骤 +/// 1. receiver 品牌校验;partial 参数经 reject_partial_object_with_calendar_or_time_zone +/// 排除日历/时区载体(其内部按序读 calendar、timeZone 两键)。 +/// 2. 按规范读序逐字段读并就地转换:day → hour → microsecond → millisecond → minute +/// → month → monthCode → nanosecond → second → year(数值字段 ToNumber, +/// monthCode ToString),转换错误先于 options 校验抛出。 +/// 3. month/day 小于 1 抛 RangeError(先于 options 形态错误)。 +/// 4. 读 overflow;monthCode 语义校验(L 后缀、超界、与 month 冲突)在 options 之后。 +/// 5. 与 receiver 字段合并(时间字段缺省取 receiver 的纳秒分解)后经 +/// resolve_date_time_parts 钳制/校验(含 PlainDateTime 范围严格不等式边界)。 +pub fn plain_date_time_with(vm: &mut H, args: &[u8]) -> NativeResult { + let ptr = native_try!(receiver_obj(vm, args)); + let obj = unsafe { &*ptr }; + native_try!(ensure_plain_date_time(vm, obj)); + let value = if args.len() > 1 { vm.reg(args[1]) } else { JsValue::undefined() }; + native_try!(reject_partial_object_with_calendar_or_time_zone(vm, value)); + let vptr = value.as_js_object_ptr(); + if vptr.is_null() { + return NativeResult::Err(crate::error::create_type_error(vm, "invalid argument")); + } + let vobj = unsafe { &*vptr }; + // 读序由规范固定:day → hour → microsecond → millisecond → minute → month + // → monthCode → nanosecond → second → year,每字段读后立即转换。 + let day_raw = native_try!(temporal_option_value(vm, vobj, value, "day")); + let day = if day_raw.is_undefined() { + None + } else { + Some(native_try!(temporal_number_component(vm, day_raw))) + }; + let hour_raw = native_try!(temporal_option_value(vm, vobj, value, "hour")); + let hour = if hour_raw.is_undefined() { + None + } else { + Some(native_try!(temporal_number_component(vm, hour_raw))) + }; + let microsecond_raw = native_try!(temporal_option_value(vm, vobj, value, "microsecond")); + let microsecond = if microsecond_raw.is_undefined() { + None + } else { + Some(native_try!(temporal_number_component(vm, microsecond_raw))) + }; + let millisecond_raw = native_try!(temporal_option_value(vm, vobj, value, "millisecond")); + let millisecond = if millisecond_raw.is_undefined() { + None + } else { + Some(native_try!(temporal_number_component(vm, millisecond_raw))) + }; + let minute_raw = native_try!(temporal_option_value(vm, vobj, value, "minute")); + let minute = if minute_raw.is_undefined() { + None + } else { + Some(native_try!(temporal_number_component(vm, minute_raw))) + }; + let month_raw = native_try!(temporal_option_value(vm, vobj, value, "month")); + let month = if month_raw.is_undefined() { + None + } else { + Some(native_try!(temporal_number_component(vm, month_raw))) + }; + let month_code_raw = native_try!(temporal_option_value(vm, vobj, value, "monthCode")); + let month_code = if month_code_raw.is_undefined() { + None + } else { + Some(native_try!(temporal_option_string(vm, month_code_raw))) + }; + let nanosecond_raw = native_try!(temporal_option_value(vm, vobj, value, "nanosecond")); + let nanosecond = if nanosecond_raw.is_undefined() { + None + } else { + Some(native_try!(temporal_number_component(vm, nanosecond_raw))) + }; + let second_raw = native_try!(temporal_option_value(vm, vobj, value, "second")); + let second = if second_raw.is_undefined() { + None + } else { + Some(native_try!(temporal_number_component(vm, second_raw))) + }; + let year_raw = native_try!(temporal_option_value(vm, vobj, value, "year")); + let year = if year_raw.is_undefined() { + None + } else { + Some(native_try!(temporal_number_component(vm, year_raw))) + }; + if [day_raw, hour_raw, microsecond_raw, millisecond_raw, minute_raw, month_raw, month_code_raw, nanosecond_raw, second_raw, year_raw] + .iter() + .all(|raw| raw.is_undefined()) + { + return NativeResult::Err(crate::error::create_type_error(vm, "no properties present")); + } + // 月/日小于 1 的 RangeError 先于 options 校验抛出。 + if let Some(month) = month { + if month < 1.0 { + return NativeResult::Err(crate::error::create_range_error(vm, "invalid month")); + } + } + if let Some(day) = day { + if day < 1.0 { + return NativeResult::Err(crate::error::create_range_error(vm, "invalid day")); + } + } + let constrain = native_try!(temporal_overflow(vm, args)); + // 与 receiver 字段合并:bag 字段优先,undefined 字段回落 receiver 槽值 + // (时间字段取 receiver 午夜后纳秒的分解)。receiver 月只在 bag 未提供 + // month 与 monthCode 时参与合并,避免与 bag 的 monthCode 触发误冲突。 + let month = if month.is_none() && month_code.is_none() { + Some(get_double_prop(obj, 1)) + } else { + month + }; + let (hour_r, minute_r, second_r, millisecond_r, microsecond_r, nanosecond_r) = + plain_time_components(get_double_prop(obj, 3)); + let (year, month, day, total_ns, _) = native_try!(resolve_date_time_parts( + vm, + None, + DateTimeParts { + year: year.or(Some(get_double_prop(obj, 0))), + month, + month_code, + day: day.or(Some(get_double_prop(obj, 2))), + hour: hour.unwrap_or(hour_r as f64), + minute: minute.unwrap_or(minute_r as f64), + second: second.unwrap_or(second_r as f64), + millisecond: millisecond.unwrap_or(millisecond_r as f64), + microsecond: microsecond.unwrap_or(microsecond_r as f64), + nanosecond: nanosecond.unwrap_or(nanosecond_r as f64), + }, + constrain, + false, + )); + make_plain_date_time(vm, year, month, day, total_ns, &get_calendar_id(obj, 4)) +} + fn plain_date_time_parts(vm: &mut H, args: &[u8]) -> Result<(i32, u32, u32, f64), JsValue> { let ptr = receiver_obj(vm, args)?; let obj = unsafe { &*ptr }; diff --git a/crates/oxide_vm/src/bindings/bind_temporal.rs b/crates/oxide_vm/src/bindings/bind_temporal.rs index dec58e36..67aca8bd 100644 --- a/crates/oxide_vm/src/bindings/bind_temporal.rs +++ b/crates/oxide_vm/src/bindings/bind_temporal.rs @@ -567,6 +567,7 @@ pub fn bind_temporal(env: BindEnv<'_>, global: &mut JsObject) { ("subtract", oxide_builtins::temporal::plain_date_subtract:: as *const (), 1), ("until", oxide_builtins::temporal::plain_date_until:: as *const (), 1), ("since", oxide_builtins::temporal::plain_date_since:: as *const (), 1), + ("with", oxide_builtins::temporal::plain_date_with:: as *const (), 1), ], ); @@ -812,6 +813,11 @@ pub fn bind_temporal(env: BindEnv<'_>, global: &mut JsObject) { oxide_builtins::temporal::plain_date_time_equals:: as *const (), 1, ), + ( + "with", + oxide_builtins::temporal::plain_date_time_with:: as *const (), + 1, + ), ], ); bind_well_known_data_property( diff --git a/crates/oxide_vm/tests/builtins_temporal_tests.rs b/crates/oxide_vm/tests/builtins_temporal_tests.rs index 3c9d749e..cf2e329c 100644 --- a/crates/oxide_vm/tests/builtins_temporal_tests.rs +++ b/crates/oxide_vm/tests/builtins_temporal_tests.rs @@ -3897,6 +3897,76 @@ fn plain_time_with_reject_invalid_components_throw_range_error() { ); } +#[test] +fn plain_date_with_field_update_overflow_and_forms() { + let mut vm = Vm::new(); + // 字段更新:year/month/day/monthCode 覆盖、undefined 不覆盖、unrecognized 键忽略; + // overflow:constrain 钳日、reject 抛 RangeError;day/year 越界 RangeError; + // 三形态:空袋/字符串/Temporal 实例/calendar 键均 TypeError,month 与 monthCode 冲突 + // RangeError;year 超 day count 表示范围 RangeError。 + let r = eval( + &mut vm, + "(() => { + const d = new Temporal.PlainDate(1976, 11, 18); + const kind = (fn) => { try { fn(); return 'no'; } catch (e) { return e.constructor.name; } }; + const val = (fn) => { try { return String(fn()); } catch (e) { return e.constructor.name; } }; + return [ + val(() => d.with({ year: 2019, nonsense: true }).toString()), + val(() => d.with({ month: 4, day: 31 }, { overflow: 'constrain' }).toString()), + kind(() => d.with({ month: 4, day: 31 }, { overflow: 'reject' })), + kind(() => d.with({ day: -1 }, { overflow: 'reject' })), + kind(() => d.with({ year: 300000 })), + kind(() => d.with({})), + kind(() => d.with('2019-05-17')), + kind(() => d.with(Temporal.PlainDate.from('2019-05-17'))), + kind(() => d.with({ year: 2021, calendar: 'iso8601' })), + val(() => d.with({ monthCode: 'M03' }).toString()), + kind(() => d.with({ month: 5, monthCode: 'M06' })), + ].join('|'); + })()", + ) + .unwrap(); + assert_eq!( + str_val(&vm, r), + "2019-11-18|1976-04-30|RangeError|RangeError|RangeError|TypeError|TypeError|TypeError|TypeError|1976-03-18|RangeError" + ); +} + +#[test] +fn plain_date_time_with_field_update_overflow_and_forms() { + let mut vm = Vm::new(); + // 字段更新:日期与时间全字段覆盖、undefined 不覆盖; + // overflow:constrain 钳时/日、reject 抛 RangeError; + // 三形态:空袋/字符串/timeZone 键 TypeError,month 与 monthCode 冲突 RangeError; + // 边界:-271821-04-19T00:00:00.000000001 减纳秒到范围外边界抛 RangeError。 + let r = eval( + &mut vm, + "(() => { + const dt = new Temporal.PlainDateTime(1976, 11, 18, 15, 23, 30, 123, 456, 789); + const kind = (fn) => { try { fn(); return 'no'; } catch (e) { return e.constructor.name; } }; + const val = (fn) => { try { return String(fn()); } catch (e) { return e.constructor.name; } }; + const min = new Temporal.PlainDateTime(-271821, 4, 19, 0, 0, 0, 0, 0, 1); + return [ + val(() => dt.with({ year: 2019, hour: 5 }).toString()), + val(() => dt.with({ month: 2, day: 31 }, { overflow: 'constrain' }).toString()), + kind(() => dt.with({ month: 2, day: 31 }, { overflow: 'reject' })), + kind(() => dt.with({ hour: -1 }, { overflow: 'reject' })), + val(() => dt.with({ hour: 25, minute: 90 }, { overflow: 'constrain' }).toString()), + kind(() => dt.with({})), + kind(() => dt.with('1995-04-07T12:34')), + kind(() => dt.with({ year: 2021, timeZone: 'UTC' })), + kind(() => dt.with({ month: 5, monthCode: 'M06' })), + kind(() => min.with({ nanosecond: 0 })), + ].join('|'); + })()", + ) + .unwrap(); + assert_eq!( + str_val(&vm, r), + "2019-11-18T05:23:30.123456789|1976-02-29T15:23:30.123456789|RangeError|RangeError|1976-11-18T23:59:30.123456789|TypeError|TypeError|TypeError|RangeError|RangeError" + ); +} + #[test] fn zoned_date_time_with_reject_negative_time_component_throws_range_error() { let mut vm = Vm::new();