diff --git a/crates/oxide_builtins/src/temporal.rs b/crates/oxide_builtins/src/temporal.rs index 0bc00933..4ab6984a 100644 --- a/crates/oxide_builtins/src/temporal.rs +++ b/crates/oxide_builtins/src/temporal.rs @@ -1372,7 +1372,6 @@ pub fn zoned_date_time_calendar_id(vm: &mut H, args: &[u8]) -> Native /// - 无时区注解或注解形式非法返回 None;critical(`!` 前缀)注解同样返回(带标记)。 /// - 不修改 instant_string_without_annotations 的返回;剥离与提取各自独立扫描。 /// - IANA 命名区(如 America/New_York)通过本函数校验,由调用方 canonical_time_zone 裁决。 -#[allow(dead_code)] fn extract_time_zone_annotation(input: &str) -> Option<(String, bool)> { let first_annotation = input.find('[')?; let mut rest = &input[first_annotation..]; @@ -1433,6 +1432,293 @@ fn is_time_zone_annotation_value(annotation: &str) -> bool { false } +/// 读 ZonedDateTime.from 的 options(disambiguation → offset 顺序),先 Get 后统一白名单校验。 +/// +/// # 边界与前提 +/// - options 为 undefined 时返回默认(offset=reject,disambiguation=compatible)。 +/// - options 为非对象原始值抛 TypeError;选项值不在白名单抛 RangeError。 +fn zoned_date_time_options(vm: &mut H, args: &[u8]) -> Result<(String, String), JsValue> { + let options = if args.len() > 2 { vm.reg(args[2]) } else { JsValue::undefined() }; + if options.is_undefined() { + return Ok(("reject".to_string(), "compatible".to_string())); + } + if !options.is_object() { + return Err(crate::error::create_type_error(vm, "options must be an object")); + } + let ptr = options.as_js_object_ptr(); + if ptr.is_null() { + return Err(crate::error::create_type_error(vm, "options must be an object")); + } + let options_obj = unsafe { &*ptr }; + + // 先 Get 再校验:disambiguation 在前,offset 在后。 + let disambiguation_raw = temporal_option_value(vm, options_obj, options, "disambiguation")?; + let offset_raw = temporal_option_value(vm, options_obj, options, "offset")?; + let disambiguation = if disambiguation_raw.is_undefined() { + "compatible".to_string() + } else { + temporal_option_string(vm, disambiguation_raw)? + }; + let offset = if offset_raw.is_undefined() { + "reject".to_string() + } else { + temporal_option_string(vm, offset_raw)? + }; + if !matches!(offset.as_str(), "prefer" | "use" | "ignore" | "reject") { + return Err(crate::error::create_range_error(vm, "invalid offset")); + } + if !matches!(disambiguation.as_str(), "compatible" | "earlier" | "later" | "reject") { + return Err(crate::error::create_range_error(vm, "invalid disambiguation")); + } + Ok((offset, disambiguation)) +} + +/// 从剥注解后的 Instant 主体提取字符串内数值偏移分钟数(±HH / ±HHMM / ±HH:MM / 亚秒形式)。 +/// Z/z 结尾视为 0 分钟;无偏移或偏移不可提取返回 None。 +fn extract_string_offset_minutes(body: &str) -> Option { + let time_start = body.find(['T', 't', ' '])?; + let time = &body[time_start + 1..]; + let offset_start = time + .char_indices() + .rev() + .find_map(|(index, ch)| matches!(ch, '+' | '-').then_some(index))?; + parse_any_offset_minutes(&time[offset_start..]) +} + +/// 解析数值偏移串(±HH / ±HHMM / ±HH:MM / ±HHMMSS / ±HH:MM:SS,可带小数秒)为分钟数。 +fn parse_any_offset_minutes(value: &str) -> Option { + let bytes = value.as_bytes(); + let sign = match bytes.first() { + Some(b'+') => 1_i32, + Some(b'-') => -1_i32, + _ => return None, + }; + let mut cursor = 1usize; + let hour = parse_digits(bytes, &mut cursor, 2)?; + let colon_format = bytes.get(cursor) == Some(&b':'); + let minute = if colon_format { + cursor += 1; + parse_digits(bytes, &mut cursor, 2)? + } else if matches!(bytes.get(cursor), Some(byte) if byte.is_ascii_digit()) { + parse_digits(bytes, &mut cursor, 2)? + } else { + 0 + }; + let second = if colon_format && bytes.get(cursor) == Some(&b':') { + cursor += 1; + parse_digits(bytes, &mut cursor, 2)? + } else if !colon_format && matches!(bytes.get(cursor), Some(byte) if byte.is_ascii_digit()) { + parse_digits(bytes, &mut cursor, 2)? + } else { + 0 + }; + if matches!(bytes.get(cursor), Some(b'.' | b',')) { + cursor += 1; + while matches!(bytes.get(cursor), Some(byte) if byte.is_ascii_digit()) { + cursor += 1; + } + } + if cursor != bytes.len() || hour > 23 || minute > 59 || second > 59 { + return None; + } + Some(sign * (hour * 60 + minute) as i32) +} + +/// 从 ZDT 对象 / ISO 字符串 / property bag 解析 (epoch_ns, time_zone_id, calendar_id) 三元组。 +/// +/// # 步骤 +/// 1. ZDT 对象直拷三槽;字符串按 instant 解析 + 时区注解提取 + offset 选项决策。 +/// 2. 其余对象按字段 bag 解析:timeZone 必填、offset 可选,冲突按 offset 选项处理。 +/// 3. 非字符串原始值抛 TypeError。 +/// +/// # 边界与前提 +/// - 字符串解析失败抛 RangeError;epoch 越 Instant 界抛 RangeError。 +/// - offset_mode 为 prefer/use/ignore/reject;disambiguation 本批仅校验不参与算法。 +fn zoned_date_time_like_epoch_ns( + vm: &mut H, value: JsValue, offset_mode: &str, _disambiguation: &str, +) -> Result<(i128, String, String), JsValue> { + if value.is_object() { + let ptr = value.as_js_object_ptr(); + if !ptr.is_null() { + let obj = unsafe { &*ptr }; + if obj.is_zoned_date_time_obj() { + let epoch_ns = get_instant_epoch_ns(obj) + .ok_or_else(|| crate::error::create_range_error(vm, "invalid ZonedDateTime"))?; + let time_zone_id = to_string(obj.get_prop_at(1)); + let calendar_id = get_calendar_id(obj, 2); + return Ok((epoch_ns, time_zone_id, calendar_id)); + } + return zoned_date_time_bag_parts(vm, value, obj, offset_mode); + } + } + if value.is_string() { + return zoned_date_time_string_parts(vm, &to_string(value), offset_mode); + } + Err(crate::error::create_type_error(vm, "cannot convert to ZonedDateTime")) +} + +/// 字符串分支:instant 解析 + 时区注解提取,按 offset 选项决定最终 epoch。 +/// +/// # 步骤 +/// 1. extract_time_zone_annotation 取注解 → canonical_time_zone 得时区 ID 与偏移。 +/// 2. 主体剥注解后按 Z / 数值偏移 / 无偏移分路:Z 定 exact time,有偏移经 parse_instant_string +/// 反推墙钟,无偏移直接解析墙钟。 +/// 3. 按 offset 选项 use/ignore/prefer/reject 决策 epoch,并做 Instant 范围校验。 +/// +/// # 边界与前提 +/// - 字符串缺时区注解或注解无法规范化抛 RangeError;主体非法抛 RangeError。 +/// - Z 时区标识使 offsetBehaviour 为 exact:字符串偏移被忽略,epoch 恒为墙钟时刻。 +fn zoned_date_time_string_parts( + vm: &mut H, input: &str, offset_mode: &str, +) -> Result<(i128, String, String), JsValue> { + let Some((tz_annotation, _critical)) = extract_time_zone_annotation(input) else { + return Err(crate::error::create_range_error(vm, "invalid time zone")); + }; + let Some((time_zone_id, time_zone_offset)) = canonical_time_zone(&tz_annotation) else { + return Err(crate::error::create_range_error(vm, "invalid time zone")); + }; + let Some(body) = instant_string_without_annotations(input) else { + return Err(crate::error::create_range_error(vm, "invalid ISO 8601 date-time")); + }; + let has_utc_designator = body.ends_with(['Z', 'z']); + let string_offset_minutes = if has_utc_designator { Some(0) } else { extract_string_offset_minutes(body) }; + + // 有偏移/Z 时经 parse_instant_string 得 epoch 并反推墙钟;否则直接解析墙钟。 + const DAY_NS: i128 = 86_400_000_000_000; + let (epoch_from_string, wall_parts) = if string_offset_minutes.is_some() { + let epoch_ns = parse_instant_string(input) + .ok_or_else(|| crate::error::create_range_error(vm, "invalid ISO 8601 date-time"))?; + let offset_minutes = string_offset_minutes.unwrap_or(0); + let wall_ns = epoch_ns + i128::from(offset_minutes) * 60_000_000_000; + let days = wall_ns.div_euclid(DAY_NS); + let (year, month, day) = civil_from_days(days); + (Some(epoch_ns), (year as i32, month as u32, day as u32, wall_ns.rem_euclid(DAY_NS) as f64)) + } else { + let (year, month, day, total_ns) = parse_temporal_string_impl(body, true) + .map_err(|_| crate::error::create_range_error(vm, "invalid ISO 8601 date-time"))?; + (None, (year, month, day, total_ns)) + }; + let wall_epoch = + |offset_minutes: i32| local_to_epoch_ns(wall_parts.0, wall_parts.1, wall_parts.2, wall_parts.3, offset_minutes); + + // 按 offsetBehaviour 决策:Z → exact(墙钟 epoch);无偏移 → wall(墙钟 + 时区偏移); + // 有偏移 → 按 offset 选项在字符串偏移与时区偏移之间选择。 + let epoch_ns = if has_utc_designator { + epoch_from_string + } else { + match string_offset_minutes { + None => wall_epoch(time_zone_offset), + Some(offset) => match offset_mode { + "use" => epoch_from_string, + "ignore" => wall_epoch(time_zone_offset), + "prefer" => { + if offset == time_zone_offset { + epoch_from_string + } else { + wall_epoch(time_zone_offset) + } + } + "reject" => { + if offset == time_zone_offset { + epoch_from_string + } else { + return Err(crate::error::create_range_error(vm, "offset and time zone disagree")); + } + } + _ => unreachable!(), + }, + } + } + .ok_or_else(|| crate::error::create_range_error(vm, "invalid date-time"))?; + if epoch_ns.unsigned_abs() > MAX_INSTANT_NS as u128 { + return Err(crate::error::create_range_error(vm, "ZonedDateTime outside supported range")); + } + Ok((epoch_ns, time_zone_id, "iso8601".to_string())) +} + +/// property bag 分支:读 timeZone/offset 与年月日字段,按 timeZone 偏移换算 epoch。 +/// +/// # 步骤 +/// 1. timeZone 必填:缺失 TypeError,经 canonical_time_zone 规范化。 +/// 2. offset 可选:先做语法校验(RangeError),再按 offset 选项与 timeZone 偏移比对。 +/// 3. plain_date_time_object_parts 读年月日与日历,local_to_epoch_ns 换算并校验 Instant 范围。 +/// +/// # 边界与前提 +/// - timeZone/offset 在字段读取前取用(读序对齐 order-of-operations 的前置约定)。 +/// - offset 字段语法校验先于 year 等数值字段类型校验;匹配校验在其后。 +fn zoned_date_time_bag_parts( + vm: &mut H, value: JsValue, obj: &JsObject, offset_mode: &str, +) -> Result<(i128, String, String), JsValue> { + let time_zone_raw = temporal_option_value(vm, obj, value, "timeZone")?; + if time_zone_raw.is_undefined() { + return Err(crate::error::create_type_error(vm, "timeZone is required")); + } + let time_zone_input = temporal_option_string(vm, time_zone_raw)?; + let Some((time_zone_id, time_zone_offset)) = canonical_time_zone(&time_zone_input) else { + return Err(crate::error::create_range_error(vm, "invalid time zone")); + }; + + // offset 可选:语法校验(ToOffsetString 语义)先于数值字段转换。 + let offset_raw = temporal_option_value(vm, obj, value, "offset")?; + let bag_offset_minutes = if offset_raw.is_undefined() { + None + } else { + let offset_input = temporal_option_string(vm, offset_raw)?; + Some( + instant_time_zone_offset(&offset_input) + .ok_or_else(|| crate::error::create_range_error(vm, "invalid offset"))?, + ) + }; + + // 年月日字段与 calendar:内部先读 calendar 再按字母序读字段并做类型转换校验。 + let (year, month, day, total_ns, calendar) = plain_date_time_object_parts(vm, value, obj, false, false)?; + + // 按 offset 选项决定 epoch(InterpretISODateTimeOffset 固定偏移简化:候选恒唯一)。 + let epoch_ns = match (bag_offset_minutes, offset_mode) { + (None, _) | (Some(_), "ignore") => local_to_epoch_ns(year, month, day, total_ns, time_zone_offset), + (Some(offset), "use") => local_to_epoch_ns(year, month, day, total_ns, offset), + (Some(offset), "prefer") => { + if offset == time_zone_offset { + local_to_epoch_ns(year, month, day, total_ns, offset) + } else { + local_to_epoch_ns(year, month, day, total_ns, time_zone_offset) + } + } + (Some(offset), "reject") => { + if offset == time_zone_offset { + local_to_epoch_ns(year, month, day, total_ns, offset) + } else { + return Err(crate::error::create_range_error(vm, "offset and time zone disagree")); + } + } + _ => unreachable!(), + } + .ok_or_else(|| crate::error::create_range_error(vm, "invalid date-time"))?; + if epoch_ns.unsigned_abs() > MAX_INSTANT_NS as u128 { + return Err(crate::error::create_range_error(vm, "ZonedDateTime outside supported range")); + } + Ok((epoch_ns, time_zone_id, calendar.unwrap_or_else(|| "iso8601".to_string()))) +} + +/// `Temporal.ZonedDateTime.from(item, options)`:从 ZDT 对象、ISO 字符串或 property bag 创建副本。 +/// +/// # 步骤 +/// 1. 读 options(disambiguation → offset 顺序),先 Get 后统一白名单校验。 +/// 2. ZDT 对象直拷三槽;字符串走 instant 解析 + 时区注解;其他对象走字段 bag。 +/// 3. make_zoned_date_time 组装新对象。 +/// +/// # 边界与前提 +/// - offset 默认 reject:字符串内偏移与注解时区不一致抛 RangeError;bag 内 offset 冲突同理。 +/// - 字符串缺时区注解、bag 缺 timeZone 字段均抛错;number 等原始值抛 TypeError。 +/// - disambiguation 本批仅做选项值校验,固定偏移时区下四个取值算法等价。 +pub fn zoned_date_time_from(vm: &mut H, args: &[u8]) -> NativeResult { + let value = if args.len() > 1 { vm.reg(args[1]) } else { JsValue::undefined() }; + let (offset_mode, disambiguation) = native_try!(zoned_date_time_options(vm, args)); + let (epoch_ns, time_zone_id, calendar_id) = + native_try!(zoned_date_time_like_epoch_ns(vm, value, &offset_mode, &disambiguation)); + make_zoned_date_time(vm, epoch_ns, &time_zone_id, &calendar_id) +} + /// 时区注解文本:命名区原样返回,偏移区规范化为 `±HH:MM` 带冒号;critical 时 `!` 置于括号内。 fn format_time_zone_annotation(time_zone_id: &str, critical: bool) -> String { let inner = if matches!(time_zone_id.as_bytes().first(), Some(b'+') | Some(b'-')) { @@ -3817,7 +4103,6 @@ fn start_of_day_epoch_ns_by_days(days: i128, offset_minutes: i32) -> Option Option { let days = days_from_civil(i128::from(year), i128::from(month), i128::from(day)); days.checked_mul(86_400_000_000_000)? diff --git a/crates/oxide_vm/src/bindings/bind_temporal.rs b/crates/oxide_vm/src/bindings/bind_temporal.rs index 89b56311..8e26a9d5 100644 --- a/crates/oxide_vm/src/bindings/bind_temporal.rs +++ b/crates/oxide_vm/src/bindings/bind_temporal.rs @@ -148,6 +148,12 @@ pub fn bind_temporal(core: &Arc, session: &KernelSession, global: &m .map_or(0, |props| props.len() as u32) .saturating_sub(1); zoned_date_time_ctor.set_data_meta(length_pos, PropAttributes::new(false, false, true)); + apply_binding_table( + world, + zoned_date_time_ctor, + core, + &[("from", oxide_builtins::temporal::zoned_date_time_from:: as *const (), 1)], + ); let zoned_date_time_proto_ptr = world.zoned_date_time_proto.as_ptr() as *mut JsObject; let zoned_date_time_proto = unsafe { &mut *zoned_date_time_proto_ptr }; diff --git a/crates/oxide_vm/tests/builtins_temporal_tests.rs b/crates/oxide_vm/tests/builtins_temporal_tests.rs index 193d1c21..f805df9a 100644 --- a/crates/oxide_vm/tests/builtins_temporal_tests.rs +++ b/crates/oxide_vm/tests/builtins_temporal_tests.rs @@ -1334,3 +1334,155 @@ fn zoned_date_time_equals_receiver_branding_and_non_zoned_arg() { .unwrap(); assert_eq!(str_val(&vm, r), "true|false|false"); } + +#[test] +fn zoned_date_time_from_string_wall_and_exact_time() { + let mut vm = Vm::new(); + // 无偏移注解:墙钟按注解时区换算;带偏移与注解一致:精确时刻(墙钟 - 偏移)。 + let r = eval( + &mut vm, + "Temporal.ZonedDateTime.from('1976-11-18T15:23:30[UTC]').toString() + '|' + + Temporal.ZonedDateTime.from('1976-11-18T15:23:30[+01:00]').epochNanoseconds + '|' + + Temporal.ZonedDateTime.from('1976-11-18T15:23:30+01:00[+01:00]').epochNanoseconds + '|' + + Temporal.ZonedDateTime.from('1976-11-18T15:23:30+01:00[+01:00]').timeZoneId", + ) + .unwrap(); + assert_eq!( + str_val(&vm, r), + "1976-11-18T15:23:30+00:00[UTC]|217175010000000000|217175010000000000|+01:00" + ); +} + +#[test] +fn zoned_date_time_from_string_utc_designator_pins_exact_time() { + let mut vm = Vm::new(); + // Z 使 offsetBehaviour 为 exact:epoch 恒为墙钟时刻,注解时区只改 timeZoneId。 + let r = eval( + &mut vm, + "Temporal.ZonedDateTime.from('1970-01-01T00:00Z[+01:00]').epochNanoseconds + '|' + + Temporal.ZonedDateTime.from('1970-01-01T00:00Z[+01:00]').timeZoneId + '|' + + Temporal.ZonedDateTime.from('1970-01-01T00:00Z[+01:00]').toString()", + ) + .unwrap(); + assert_eq!(str_val(&vm, r), "0|+01:00|1970-01-01T01:00:00+01:00[+01:00]"); +} + +#[test] +fn zoned_date_time_from_string_requires_time_zone_annotation() { + let mut vm = Vm::new(); + // 无时区注解(裸日期时间 / Z / 纯偏移)均抛 RangeError。 + let r = eval( + &mut vm, + "['1970-01-01T00:00', '1970-01-01T00:00Z', '1970-01-01T00:00+01:00'].every( + s => { try { Temporal.ZonedDateTime.from(s); return false; } + catch (e) { return e instanceof RangeError; } })", + ) + .unwrap(); + assert!(r.as_bool()); +} + +#[test] +fn zoned_date_time_from_string_offset_options() { + let mut vm = Vm::new(); + // offset 选项覆盖 critical 标记:use 保留字符串偏移、ignore/prefer 保留墙钟。 + let r = eval( + &mut vm, + "const s = '2022-10-07T18:37-07:00[!UTC]'; + Temporal.ZonedDateTime.from(s, { offset: 'use' }).epochNanoseconds + '|' + + Temporal.ZonedDateTime.from(s, { offset: 'ignore' }).epochNanoseconds + '|' + + Temporal.ZonedDateTime.from(s, { offset: 'prefer' }).epochNanoseconds + '|' + + (() => { try { Temporal.ZonedDateTime.from(s); return 'no-throw'; } + catch (e) { return e instanceof RangeError; } })()", + ) + .unwrap(); + assert_eq!(str_val(&vm, r), "1665193020000000000|1665167820000000000|1665167820000000000|true"); +} + +#[test] +fn zoned_date_time_from_property_bag() { + let mut vm = Vm::new(); + // 纯日期 bag 与完整分量 bag 的墙钟换算与 toString 对拍。 + let r = eval( + &mut vm, + "Temporal.ZonedDateTime.from({ year: 2000, month: 5, day: 2, timeZone: 'UTC' }).toString() + '|' + + Temporal.ZonedDateTime.from({ + year: 2000, month: 5, day: 2, hour: 12, minute: 34, second: 56, + millisecond: 987, microsecond: 654, nanosecond: 321, timeZone: 'UTC' + }).toString() + '|' + + Temporal.ZonedDateTime.from({ year: 2000, month: 5, day: 2, hour: 12, timeZone: '-05:00' }).toString()", + ) + .unwrap(); + assert_eq!( + str_val(&vm, r), + "2000-05-02T00:00:00+00:00[UTC]|2000-05-02T12:34:56.987654321+00:00[UTC]|2000-05-02T12:00:00-05:00[-05:00]" + ); +} + +#[test] +fn zoned_date_time_from_property_bag_offset_conflict() { + let mut vm = Vm::new(); + // bag 内 offset 与 timeZone 不一致:默认与显式 reject 均抛 RangeError。 + let r = eval( + &mut vm, + "const props = { year: 2021, month: 10, day: 28, offset: '-07:00', timeZone: '+01:00' }; + (() => { try { Temporal.ZonedDateTime.from(props); return 'no-throw'; } + catch (e) { return e instanceof RangeError; } })() + '|' + + (() => { try { Temporal.ZonedDateTime.from(props, { offset: 'reject' }); return 'no-throw'; } + catch (e) { return e instanceof RangeError; } })()", + ) + .unwrap(); + assert_eq!(str_val(&vm, r), "true|true"); +} + +#[test] +fn zoned_date_time_from_zdt_object_copies_slots() { + let mut vm = Vm::new(); + // 复制 ZDT 对象:三槽相等且返回新对象;非法选项值校验仍执行。 + let r = eval( + &mut vm, + "const z = new Temporal.ZonedDateTime(217175010123456789n, '+01:00', 'hebrew'); + const c = Temporal.ZonedDateTime.from(z); + (c !== z) + '|' + c.epochNanoseconds + '|' + c.timeZoneId + '|' + c.calendarId + '|' + + (() => { try { Temporal.ZonedDateTime.from(z, { offset: 'bad' }); return 'no-throw'; } + catch (e) { return e instanceof RangeError; } })()", + ) + .unwrap(); + assert_eq!(str_val(&vm, r), "true|217175010123456789|+01:00|hebrew|true"); +} + +#[test] +fn zoned_date_time_from_error_paths() { + let mut vm = Vm::new(); + // 缺 timeZone 的 bag / 非字符串原始值 → TypeError;越界日期 → RangeError。 + let r = eval( + &mut vm, + "( () => { try { Temporal.ZonedDateTime.from({ year: 2000, month: 5, day: 2 }); return 'no-throw'; } + catch (e) { return e instanceof TypeError; } })() + '|' + + [123, null, undefined, 1n].every(v => { + try { Temporal.ZonedDateTime.from(v); return false; } + catch (e) { return e instanceof TypeError; } + }) + '|' + + (() => { try { Temporal.ZonedDateTime.from({ year: -271821, month: 4, day: 19, timeZone: 'UTC' }); return 'no-throw'; } + catch (e) { return e instanceof RangeError; } })()", + ) + .unwrap(); + assert_eq!(str_val(&vm, r), "true|true|true"); +} + +#[test] +fn zoned_date_time_from_options_validation() { + let mut vm = Vm::new(); + // offset/disambiguation 非法 → RangeError;options 非对象 → TypeError。 + let r = eval( + &mut vm, + "const s = '1970-01-01T00:00[UTC]'; + (() => { try { Temporal.ZonedDateTime.from(s, { offset: 'garbage' }); return 'no-throw'; } + catch (e) { return e instanceof RangeError; } })() + '|' + + (() => { try { Temporal.ZonedDateTime.from(s, { disambiguation: 'garbage' }); return 'no-throw'; } + catch (e) { return e instanceof RangeError; } })() + '|' + + (() => { try { Temporal.ZonedDateTime.from(s, null); return 'no-throw'; } + catch (e) { return e instanceof TypeError; } })()", + ) + .unwrap(); + assert_eq!(str_val(&vm, r), "true|true|true"); +}