From 41a8746842e410bf76cf5dff655aab67983c8500 Mon Sep 17 00:00:00 2001 From: pjh456 <147148383@qq.com> Date: Wed, 14 Oct 2026 10:43:20 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Temporal=20Now=20=E4=B8=83=E6=96=B9?= =?UTF-8?q?=E6=B3=95=E4=B8=8E=E6=97=8F=20toStringTag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/oxide_builtins/src/temporal/common.rs | 61 +++++++ crates/oxide_builtins/src/temporal/instant.rs | 84 ++++++++- crates/oxide_vm/src/bindings/bind_temporal.rs | 54 ++++++ .../oxide_vm/tests/builtins_temporal_tests.rs | 167 ++++++++++++++++++ 4 files changed, 360 insertions(+), 6 deletions(-) diff --git a/crates/oxide_builtins/src/temporal/common.rs b/crates/oxide_builtins/src/temporal/common.rs index 453ea66f..2da6aad1 100644 --- a/crates/oxide_builtins/src/temporal/common.rs +++ b/crates/oxide_builtins/src/temporal/common.rs @@ -914,6 +914,67 @@ pub(crate) fn canonical_time_zone(value: &str) -> Option { parse_offset_seconds(offset_id).map(|_| offset_id.to_string()) } +/// 整数分钟偏移串解析为秒(±HH:MM / ±HH / ±HHMM 三形式)。 +/// +/// # 边界与前提 +/// - 亚分钟形式(±HH:MM:SS 及带小数秒的串)一律拒绝,返回 None。 +/// - 小时 ≤ 23、分钟 ≤ 59,越界返回 None。 +pub(crate) fn whole_minute_offset(value: &str) -> Option { + if value.len() == 9 { + return None; + } + fixed_offset_seconds(value) +} + +/// 时区字符串解析(ParseTemporalTimeZoneString):返回规范时区 ID。 +/// +/// # 步骤 +/// 1. "-000000" 前缀(负零年出 ISO 界)先于一切分支拒绝。 +/// 2. UTC/Z(大小写折叠)→ "UTC"。 +/// 3. 独立整数分钟偏移串原样保留为时区 ID。 +/// 4. IANA 区名(含 legacy 别名)→ 规范名。 +/// 5. 完整日期时间串:带方括号注解 → 注解内容递归走同一分支序; +/// 无注解、带尾偏移 → 偏移子串原样保留;无注解、无偏移 → None。 +/// +/// # 边界与前提 +/// - 亚分钟偏移(±HH:MM:SS、小数秒)在步骤 3 与步骤 5 的偏移分支均被拒绝。 +/// - 裸日期时间串(无偏移、无注解)不是合法时区,返回 None。 +/// - 与 canonical_time_zone 的区别:尾偏移接受 ±HHMM / ±HH 整数分钟形式, +/// 不修改 canonical_time_zone 的既有接受面。 +pub(crate) fn parse_time_zone_string(value: &str) -> Option { + let input = value.trim(); + if input.starts_with("-000000") { + return None; + } + if input.eq_ignore_ascii_case("UTC") || input.eq_ignore_ascii_case("Z") { + return Some("UTC".to_string()); + } + if whole_minute_offset(input).is_some() { + return Some(input.to_string()); + } + if let Some(name) = tz_canonical_name(input) { + return Some(name.to_string()); + } + if let Some(open) = input.rfind('[') { + if !input.ends_with(']') || open + 2 > input.len() { + return None; + } + let annotation = &input[open + 1..input.len() - 1]; + return parse_time_zone_string(annotation); + } + let time_start = input.find(['T', 't', ' '])?; + let time = &input[time_start + 1..]; + if time.ends_with(['Z', 'z']) { + return Some("UTC".to_string()); + } + let offset_start = time + .char_indices() + .rev() + .find_map(|(index, ch)| matches!(ch, '+' | '-').then_some(index))?; + let offset_id = &time[offset_start..]; + whole_minute_offset(offset_id).map(|_| offset_id.to_string()) +} + pub(crate) fn civil_from_days(days: i128) -> (i128, i128, i128) { let shifted = days + 719_468; let era = shifted.div_euclid(146_097); diff --git a/crates/oxide_builtins/src/temporal/instant.rs b/crates/oxide_builtins/src/temporal/instant.rs index 043de55f..3ac0395b 100644 --- a/crates/oxide_builtins/src/temporal/instant.rs +++ b/crates/oxide_builtins/src/temporal/instant.rs @@ -11,12 +11,15 @@ use super::time_zone::zone_offset_seconds; use super::{ balance_instant_difference, canonical_time_zone, civil_from_days, days_from_civil, days_in_month, duration_like_values, duration_time_nanoseconds, ensure_instant, format_iso_year, format_offset_seconds_text, - get_instant_epoch_ns, initialize_temporal_receiver, is_ctor_call, make_duration, make_instant, - make_zoned_date_time, native_engine_error, parse_digits, parse_fractional_second_digits, parse_round_options, + get_instant_epoch_ns, initialize_temporal_receiver, is_ctor_call, make_duration, make_instant, make_plain_date, + make_plain_date_time, make_plain_month_day, make_plain_time, make_plain_year_month, make_zoned_date_time, + native_engine_error, parse_digits, parse_fractional_second_digits, parse_round_options, parse_time_zone_string, receiver_obj, round_instant_difference, temporal_option_number, temporal_option_string, temporal_option_value, FractionalSecondDigitsInput, MAX_INSTANT_NS, }; +const DAY_NS: i128 = 86_400_000_000_000; + pub(crate) fn instant_string_without_annotations(input: &str) -> Option<&str> { let Some(first_annotation) = input.find('[') else { return Some(input); @@ -288,9 +291,7 @@ fn instant_like_epoch_ns(vm: &mut H, value: JsValue) -> Result(vm: &mut H, _args: &[u8]) -> NativeResult { - let now = Utc::now(); - let epoch_ns = now.timestamp() as i128 * 1_000_000_000 + now.timestamp_subsec_nanos() as i128; - make_instant(vm, epoch_ns) + make_instant(vm, now_epoch_ns()) } /// `Temporal.Now.timeZoneId()`:引擎固定使用 UTC。 @@ -298,6 +299,78 @@ pub fn now_time_zone_id(vm: &mut H, _args: &[u8]) -> NativeResult { NativeResult::Ok(vm.new_string("UTC")) } +fn now_epoch_ns() -> i128 { + let now = Utc::now(); + now.timestamp() as i128 * 1_000_000_000 + now.timestamp_subsec_nanos() as i128 +} + +/// Now.*ISO 方法的可选 timeZone 参数解析:undefined → "UTC";非字符串 → +/// TypeError;字符串经 parse_time_zone_string 校验(空串与非法形式 → RangeError)。 +/// +/// # 边界与前提 +/// - 本批口径下 zone 参仅做语法校验,返回分量恒取 UTC,zone 值不影响分量。 +fn now_time_zone_param(vm: &mut H, args: &[u8]) -> Result { + let raw = if args.len() < 2 { JsValue::undefined() } else { vm.reg(args[1]) }; + if raw.is_undefined() { + return Ok("UTC".to_string()); + } + if !raw.is_string() { + return Err(crate::error::create_type_error(vm, "invalid time zone")); + } + let input = to_string(raw); + parse_time_zone_string(&input).ok_or_else(|| crate::error::create_range_error(vm, "invalid time zone")) +} + +/// `Temporal.Now.plainDateISO([timeZone])`:返回当前 UTC 日期的 PlainDate。 +pub fn now_plain_date_iso(vm: &mut H, args: &[u8]) -> NativeResult { + let epoch_ns = now_epoch_ns(); + native_try!(now_time_zone_param(vm, args)); + let (year, month, day) = civil_from_days(epoch_ns.div_euclid(DAY_NS)); + make_plain_date(vm, year as i32, month as u32, day as u32, "iso8601") +} + +/// `Temporal.Now.plainTimeISO([timeZone])`:返回当前 UTC 时刻的 PlainTime。 +/// 直接按纳秒分量构造,不经 toPlainTime 路径。 +pub fn now_plain_time_iso(vm: &mut H, args: &[u8]) -> NativeResult { + let epoch_ns = now_epoch_ns(); + native_try!(now_time_zone_param(vm, args)); + make_plain_time(vm, epoch_ns.rem_euclid(DAY_NS) as f64) +} + +/// `Temporal.Now.plainDateTimeISO([timeZone])`:返回当前 UTC 日期时间的 PlainDateTime。 +pub fn now_plain_date_time_iso(vm: &mut H, args: &[u8]) -> NativeResult { + let epoch_ns = now_epoch_ns(); + native_try!(now_time_zone_param(vm, args)); + let (year, month, day) = civil_from_days(epoch_ns.div_euclid(DAY_NS)); + make_plain_date_time(vm, year as i32, month as u32, day as u32, epoch_ns.rem_euclid(DAY_NS) as f64, "iso8601") +} + +/// `Temporal.Now.plainMonthDayISO([timeZone])`:返回当前 UTC 月日的 PlainMonthDay +/// (参考年取当前年)。 +pub fn now_plain_month_day_iso(vm: &mut H, args: &[u8]) -> NativeResult { + let epoch_ns = now_epoch_ns(); + native_try!(now_time_zone_param(vm, args)); + let (year, month, day) = civil_from_days(epoch_ns.div_euclid(DAY_NS)); + make_plain_month_day(vm, month as u32, day as u32, year as i32, "iso8601") +} + +/// `Temporal.Now.plainYearMonthISO([timeZone])`:返回当前 UTC 年月的 PlainYearMonth +/// (参考日取当前日)。 +pub fn now_plain_year_month_iso(vm: &mut H, args: &[u8]) -> NativeResult { + let epoch_ns = now_epoch_ns(); + native_try!(now_time_zone_param(vm, args)); + let (year, month, day) = civil_from_days(epoch_ns.div_euclid(DAY_NS)); + make_plain_year_month(vm, year as i32, month as u32, day as u32, "iso8601") +} + +/// `Temporal.Now.zonedDateTimeISO([timeZone])`:返回同一纪元纳秒、指定时区的 +/// ZonedDateTime(calendar 恒 iso8601)。 +pub fn now_zoned_date_time_iso(vm: &mut H, args: &[u8]) -> NativeResult { + let epoch_ns = now_epoch_ns(); + let zone = native_try!(now_time_zone_param(vm, args)); + make_zoned_date_time(vm, epoch_ns, &zone, "iso8601") +} + /// `Temporal.Instant` 构造器:把参数按 ToBigInt 转换为纪元纳秒。 pub fn instant_constructor(vm: &mut H, args: &[u8]) -> NativeResult { let ctor_proto = vm.builtin_proto(ProtoKind::InstantProto); @@ -691,7 +764,6 @@ pub fn instant_since(vm: &mut H, args: &[u8]) -> NativeResult { pub(crate) fn format_instant_iso( epoch_ns: i128, offset_seconds: Option, include_seconds: bool, fractional_digits: Option, ) -> Option { - const DAY_NS: i128 = 86_400_000_000_000; let offset_ns = i128::from(offset_seconds.unwrap_or(0)).checked_mul(1_000_000_000)?; let local_ns = epoch_ns.checked_add(offset_ns)?; let days = local_ns.div_euclid(DAY_NS); diff --git a/crates/oxide_vm/src/bindings/bind_temporal.rs b/crates/oxide_vm/src/bindings/bind_temporal.rs index 6a89e1ce..0c74a6f0 100644 --- a/crates/oxide_vm/src/bindings/bind_temporal.rs +++ b/crates/oxide_vm/src/bindings/bind_temporal.rs @@ -23,8 +23,46 @@ pub fn bind_temporal(env: BindEnv<'_>, global: &mut JsObject) { &[ ("instant", oxide_builtins::temporal::now_instant:: as *const (), 0), ("timeZoneId", oxide_builtins::temporal::now_time_zone_id:: as *const (), 0), + ( + "plainDateISO", + oxide_builtins::temporal::now_plain_date_iso:: as *const (), + 0, + ), + ( + "plainDateTimeISO", + oxide_builtins::temporal::now_plain_date_time_iso:: as *const (), + 0, + ), + ( + "plainTimeISO", + oxide_builtins::temporal::now_plain_time_iso:: as *const (), + 0, + ), + ( + "plainMonthDayISO", + oxide_builtins::temporal::now_plain_month_day_iso:: as *const (), + 0, + ), + ( + "plainYearMonthISO", + oxide_builtins::temporal::now_plain_year_month_iso:: as *const (), + 0, + ), + ( + "zonedDateTimeISO", + oxide_builtins::temporal::now_zoned_date_time_iso:: as *const (), + 0, + ), ], ); + bind_well_known_data_property( + env.core, + now, + 9, + JsValue::perm_string(env.string_forge().string_ptr(env.string_forge().intern("Temporal.Now").0)), + PropAttributes::new(false, false, true), + env.realm_id, + ); // Temporal.Instant:构造器 + 静态方法 + 原型 getter 与 toString/valueOf。 let instant_ctor_ptr = world.instant_constructor.as_ptr() as *mut JsObject; @@ -591,6 +629,14 @@ pub fn bind_temporal(env: BindEnv<'_>, global: &mut JsObject) { ("round", oxide_builtins::temporal::plain_date_round:: as *const (), 1), ], ); + bind_well_known_data_property( + env.core, + plain_date_proto, + 9, + JsValue::perm_string(env.string_forge().string_ptr(env.string_forge().intern("Temporal.PlainDate").0)), + PropAttributes::new(false, false, true), + env.realm_id, + ); // Temporal.PlainTime:构造器 + 6 个分量 getter 与 toString。 let plain_time_ctor_ptr = world.plain_time_constructor.as_ptr() as *mut JsObject; @@ -1157,6 +1203,14 @@ pub fn bind_temporal(env: BindEnv<'_>, global: &mut JsObject) { ); // 把子对象挂到 Temporal 命名空间对象上,再把 Temporal 挂到 global。 + bind_well_known_data_property( + env.core, + temporal, + 9, + JsValue::perm_string(env.string_forge().string_ptr(env.string_forge().intern("Temporal").0)), + PropAttributes::new(false, false, true), + env.realm_id, + ); bind_global_value(env.core, temporal, "Now", JsValue::from_js_object(now_ptr)); bind_global_value(env.core, temporal, "Instant", JsValue::from_js_object(instant_ctor_ptr)); bind_global_value(env.core, temporal, "PlainDate", JsValue::from_js_object(plain_date_ctor_ptr)); diff --git a/crates/oxide_vm/tests/builtins_temporal_tests.rs b/crates/oxide_vm/tests/builtins_temporal_tests.rs index ab1cb2d7..4c0e6ffb 100644 --- a/crates/oxide_vm/tests/builtins_temporal_tests.rs +++ b/crates/oxide_vm/tests/builtins_temporal_tests.rs @@ -4616,3 +4616,170 @@ fn round_options_bag_shorthand_and_modes() { "1976-11-18T13:00:00|1976-11-18T13:00:00|1976-11-18T12:00:00|1976-11-18T13:00:00|1976-11-18T12:00:00|1976-11-18T13:00:00|1976-11-18T12:00:00|1976-11-18T12:00:00|1976-11-18T12:00:00" ); } + +// -- Temporal.Now.*ISO -- + +#[test] +fn now_iso_methods_return_utc_components() { + let mut vm = Vm::new(); + let r = eval( + &mut vm, + "(() => { + let before = Temporal.Now.instant(); + let d = Temporal.Now.plainDateISO(); + let t = Temporal.Now.plainTimeISO(); + let dt = Temporal.Now.plainDateTimeISO(); + let m = Temporal.Now.plainMonthDayISO(); + let y = Temporal.Now.plainYearMonthISO(); + let z = Temporal.Now.zonedDateTimeISO(); + let after = Temporal.Now.instant(); + let bd = before.toZonedDateTimeISO('UTC').toPlainDate(); + let ad = after.toZonedDateTimeISO('UTC').toPlainDate(); + let bt = before.toZonedDateTimeISO('UTC').toPlainTime(); + let at = after.toZonedDateTimeISO('UTC').toPlainTime(); + let lo = bd.subtract({ days: 1 }); + let hi = ad.add({ days: 1 }); + let dateInRange = (x) => Temporal.PlainDate.compare(x, lo) >= 0 + && Temporal.PlainDate.compare(x, hi) <= 0; + let ymInRange = (yr, mo) => { + let pm = new Temporal.PlainYearMonth(yr, mo); + return Temporal.PlainYearMonth.compare(pm, new Temporal.PlainYearMonth(lo.year, lo.month)) >= 0 + && Temporal.PlainYearMonth.compare(pm, new Temporal.PlainYearMonth(hi.year, hi.month)) <= 0; + }; + let timeInRange = (x) => bd === ad + ? (Temporal.PlainTime.compare(x, bt) >= 0 && Temporal.PlainTime.compare(x, at) <= 0) + : (Temporal.PlainTime.compare(x, bt) >= 0 || Temporal.PlainTime.compare(x, at) <= 0); + return (d instanceof Temporal.PlainDate) + '|' + + (t instanceof Temporal.PlainTime) + '|' + + (dt instanceof Temporal.PlainDateTime) + '|' + + (m instanceof Temporal.PlainMonthDay) + '|' + + (y instanceof Temporal.PlainYearMonth) + '|' + + (z instanceof Temporal.ZonedDateTime) + '|' + + d.calendarId + '|' + dt.calendarId + '|' + z.calendarId + '|' + + dateInRange(d) + '|' + timeInRange(t) + '|' + + dateInRange(dt.toPlainDate()) + '|' + timeInRange(dt.toPlainTime()) + '|' + + dateInRange(new Temporal.PlainDate(y.year, Number(m.monthCode.slice(1)), m.day)) + '|' + + ymInRange(y.year, y.month) + '|' + + (z.timeZoneId === Temporal.Now.timeZoneId()); + })()", + ) + .unwrap(); + assert_eq!( + str_val(&vm, r), + "true|true|true|true|true|true|iso8601|iso8601|iso8601|true|true|true|true|true|true|true" + ); +} + +#[test] +fn now_iso_methods_accept_time_zone_strings() { + let mut vm = Vm::new(); + let r = eval( + &mut vm, + "['UTC', 'uTc', '+01:30', '-07:00', '+01', '-0530', 'Asia/Shanghai', + '2021-08-19T17:30Z', '2021-08-19T1730Z', '2021-08-19T17:30-07:00', + '2021-08-19T17:30-0700', '2021-08-19T17:30[UTC]', '2021-08-19T17:30Z[UTC]', + '2021-08-19T17:30-07:00[UTC]', '2016-12-31T23:59:60+00:00[UTC]'] + .every(z => Temporal.Now.plainDateISO(z) instanceof Temporal.PlainDate + && Temporal.Now.plainTimeISO(z) instanceof Temporal.PlainTime + && Temporal.Now.plainDateTimeISO(z) instanceof Temporal.PlainDateTime + && Temporal.Now.zonedDateTimeISO(z) instanceof Temporal.ZonedDateTime)", + ) + .unwrap(); + assert!(r.as_bool()); +} + +#[test] +fn now_zoned_date_time_iso_time_zone_id() { + let mut vm = Vm::new(); + let r = eval( + &mut vm, + "Temporal.Now.zonedDateTimeISO('2021-08-19T17:30Z').timeZoneId + '|' + + Temporal.Now.zonedDateTimeISO('2021-08-19T17:30-07:00').timeZoneId + '|' + + Temporal.Now.zonedDateTimeISO('2021-08-19T17:30[UTC]').timeZoneId + '|' + + Temporal.Now.zonedDateTimeISO('2021-08-19T17:30:45.123456789-12:12[+01:46]').timeZoneId + '|' + + Temporal.Now.zonedDateTimeISO('UtC').timeZoneId + '|' + + Temporal.Now.zonedDateTimeISO('+01:30').timeZoneId + '|' + + Temporal.Now.zonedDateTimeISO().timeZoneId", + ) + .unwrap(); + assert_eq!(str_val(&vm, r), "UTC|-07:00|UTC|+01:46|UTC|+01:30|UTC"); +} + +#[test] +fn now_iso_methods_reject_invalid_time_zones() { + let mut vm = Vm::new(); + let r = eval( + &mut vm, + "['2021-08-19T17:30', '2021-08-19T17:30-07:00:00', '2021-08-19T17:30-07:00:00.1', + '-12:12:59.9', '2021-08-19T17:30:45.123456789-12:12:59.9[-12:12:59.9]', + '2021-08-19T17:30:45.123456789+23:59[+23:59:60]', + '-000000-10-31T17:45Z', '-000000-10-31T17:45+00:00[UTC]', ''] + .every(z => { try { Temporal.Now.plainDateISO(z); return false } + catch (e) { return e instanceof RangeError } }) + '|' + + [null, true, 1, 19761118, 1n, Symbol(), {}, new Temporal.Duration()] + .every(z => { try { Temporal.Now.zonedDateTimeISO(z); return false } + catch (e) { return e instanceof TypeError } })", + ) + .unwrap(); + assert_eq!(str_val(&vm, r), "true|true"); +} + +#[test] +fn now_plain_time_iso_does_not_call_to_plain_time() { + let mut vm = Vm::new(); + let r = eval( + &mut vm, + "let calls = []; + Object.defineProperty(Temporal.PlainDateTime.prototype, 'toPlainTime', { + get() { calls.push('get'); return () => calls.push('call'); } + }); + let result = Temporal.Now.plainTimeISO('UTC'); + calls.length + '|' + (result instanceof Temporal.PlainTime)", + ) + .unwrap(); + assert_eq!(str_val(&vm, r), "0|true"); +} + +#[test] +fn now_and_temporal_and_plain_date_to_string_tags() { + let mut vm = Vm::new(); + let r = eval( + &mut vm, + "let d1 = Object.getOwnPropertyDescriptor(Temporal, Symbol.toStringTag); + let d2 = Object.getOwnPropertyDescriptor(Temporal.Now, Symbol.toStringTag); + let d3 = Object.getOwnPropertyDescriptor(Temporal.PlainDate.prototype, Symbol.toStringTag); + (d1.value === 'Temporal' && d1.writable === false && d1.enumerable === false && d1.configurable === true) + '|' + + (d2.value === 'Temporal.Now' && d2.writable === false && d2.enumerable === false && d2.configurable === true) + '|' + + (d3.value === 'Temporal.PlainDate' && d3.writable === false && d3.enumerable === false && d3.configurable === true) + '|' + + Object.prototype.toString.call(Temporal) + '|' + + Object.prototype.toString.call(Temporal.Now) + '|' + + Object.prototype.toString.call(new Temporal.PlainDate(2024, 1, 1))", + ) + .unwrap(); + assert_eq!( + str_val(&vm, r), + "true|true|true|[object Temporal]|[object Temporal.Now]|[object Temporal.PlainDate]" + ); +} + +#[test] +fn now_iso_method_bindings_are_plain_functions() { + let mut vm = Vm::new(); + let r = eval( + &mut vm, + "['plainDateISO', 'plainDateTimeISO', 'plainTimeISO', 'plainMonthDayISO', 'plainYearMonthISO', 'zonedDateTimeISO'] + .every(name => { + let f = Temporal.Now[name]; + if (typeof f !== 'function' || f.length !== 0 || f.name !== name) return false; + let d = Object.getOwnPropertyDescriptor(Temporal.Now, name); + return d.writable === true && d.enumerable === false && d.configurable === true; + }) + '|' + + Object.isExtensible(Temporal.Now) + '|' + + (Object.getPrototypeOf(Temporal.Now) === Object.prototype) + '|' + + (Temporal.Now.prototype === undefined) + '|' + + ( () => { try { new Temporal.Now.zonedDateTimeISO(); return false } + catch (e) { return e instanceof TypeError } })()", + ) + .unwrap(); + assert_eq!(str_val(&vm, r), "true|true|true|true|true"); +}