feat: ZonedDateTime.from 三分支解析(字符串/对象/property bag + offset/disambiguation 选项)

This commit is contained in:
2026-08-16 22:50:48 +08:00
parent e2d43a8dfb
commit 0e88377ea4
3 changed files with 445 additions and 2 deletions
+287 -2
View File
@@ -1372,7 +1372,6 @@ pub fn zoned_date_time_calendar_id<H: VmHost>(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<H: VmHost>(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<i32> {
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<i32> {
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<H: VmHost>(
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<H: VmHost>(
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<H: VmHost>(
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<H: VmHost>(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<i128
/// # 边界与前提
/// - (year, month, day, time_ns) 须已通过 valid_iso_date / valid_plain_time 校验(调用方保证)。
/// - 仅做 checked 溢出防护,Instant 范围校验由调用方按需执行。
#[allow(dead_code)]
fn local_to_epoch_ns(year: i32, month: u32, day: u32, time_ns: f64, offset_minutes: i32) -> Option<i128> {
let days = days_from_civil(i128::from(year), i128::from(month), i128::from(day));
days.checked_mul(86_400_000_000_000)?
@@ -148,6 +148,12 @@ pub fn bind_temporal(core: &Arc<KernelCore>, 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::<crate::vm::Vm> 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 };
@@ -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");
}