From 4dece4146bb3ec64674f68dea1ef9b6a47d36101 Mon Sep 17 00:00:00 2001 From: pjh456 <147148383@qq.com> Date: Mon, 28 Sep 2026 01:38:23 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20temporal=20IANA=20=E5=8C=BA=E6=8E=A5?= =?UTF-8?q?=E5=8F=97=E9=9D=A2=E4=B8=8E=20epoch=20=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E5=81=8F=E7=A7=BB=E8=AF=BB=E5=8F=96=E8=B7=AF=E5=BE=84=E5=8A=A0?= =?UTF-8?q?=E4=BA=9A=E5=88=86=E9=92=9F=E5=81=8F=E7=A7=BB=20getter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/oxide_builtins/src/temporal/common.rs | 109 +++++++++++++++--- .../oxide_builtins/src/temporal/duration.rs | 11 +- crates/oxide_builtins/src/temporal/instant.rs | 24 ++-- crates/oxide_builtins/src/temporal/mod.rs | 2 + crates/oxide_builtins/src/temporal/tests.rs | 60 +++++++++- .../oxide_builtins/src/temporal/time_zone.rs | 41 ++++++- .../src/temporal/zoned_date_time.rs | 101 +++++++++------- 7 files changed, 264 insertions(+), 84 deletions(-) diff --git a/crates/oxide_builtins/src/temporal/common.rs b/crates/oxide_builtins/src/temporal/common.rs index 3f1defef..33ffdd8e 100644 --- a/crates/oxide_builtins/src/temporal/common.rs +++ b/crates/oxide_builtins/src/temporal/common.rs @@ -20,9 +20,10 @@ use oxide_runtime_api::{to_number, to_string, NativeResult, VmHost}; use oxide_types::object::JsObject; use oxide_types::value::JsValue; +use super::time_zone::{tz_canonical_name, zone_offset_seconds}; use super::{ - instant_time_zone_offset, parse_iso_date, parse_plain_date_time_string, parse_plain_time_string, valid_iso_date, - valid_plain_date_time_range, valid_plain_time, + parse_iso_date, parse_plain_date_time_string, parse_plain_time_string, valid_iso_date, valid_plain_date_time_range, + valid_plain_time, }; pub(crate) const MAX_INSTANT_NS: i128 = 8_640_000_000_000_000_000_000; @@ -658,13 +659,71 @@ pub(crate) fn parse_offset_seconds(value: &str) -> Option { Some(if bytes[0] == b'-' { -magnitude } else { magnitude }) } -pub(crate) fn canonical_time_zone(value: &str) -> Option<(String, i64)> { +/// 数值偏移串统一解析为秒(±HH:MM / ±HH / ±HHMM / ±HH:MM:SS 四形式)。 +/// +/// # 边界与前提 +/// - 四形式以外的串(含带小数秒的 ±HH:MM:SS.xxx)返回 None。 +/// - 小时 ≤ 23、分钟/秒 ≤ 59,越界返回 None。 +pub(crate) fn fixed_offset_seconds(value: &str) -> Option { + let bytes = value.as_bytes(); + let sign = match bytes.first() { + Some(b'+') => 1_i64, + Some(b'-') => -1_i64, + _ => return None, + }; + let digits = |range: std::ops::Range| -> Option { + if bytes[range.clone()].iter().all(u8::is_ascii_digit) { + Some(bytes[range].iter().map(|b| i64::from(b - b'0')).fold(0, |acc, d| acc * 10 + d)) + } else { + None + } + }; + // ±HH:MM(6 字符):canonical_time_zone 接受面与 zone_offset_seconds 快速路径共用。 + if bytes.len() == 6 && bytes[3] == b':' { + let hour = digits(1..3)?; + let minute = digits(4..6)?; + return (hour <= 23 && minute <= 59).then_some(sign * (hour * 3600 + minute * 60)); + } + // ±HH(3 字符):小时制简写,分钟恒 0。 + if bytes.len() == 3 { + let hour = digits(1..3)?; + return (hour <= 23).then_some(sign * hour * 3600); + } + // ±HHMM(5 字符):无冒号归一形式。 + if bytes.len() == 5 { + let hour = digits(1..3)?; + let minute = digits(3..5)?; + return (hour <= 23 && minute <= 59).then_some(sign * (hour * 3600 + minute * 60)); + } + // ±HH:MM:SS(9 字符):亚分钟偏移,zone_offset_seconds 快速路径专用。 + if bytes.len() == 9 && bytes[3] == b':' && bytes[6] == b':' { + let hour = digits(1..3)?; + let minute = digits(4..6)?; + let second = digits(7..9)?; + return (hour <= 23 && minute <= 59 && second <= 59).then_some(sign * (hour * 3600 + minute * 60 + second)); + } + None +} + +/// 时区标识符规范化:UTC/Z 归一为 "UTC",数值偏移串原样保留,IANA 区名解别名后 +/// 返回规范名。 +/// +/// # 步骤 +/// 1. UTC/Z(大小写折叠)→ "UTC"。 +/// 2. 数值偏移串(±HH:MM / ±HH / ±HHMM)原样保留为时区 ID。 +/// 3. IANA 区名(含 legacy 别名)命中区表 → 规范名。 +/// 4. 带注解的日期时间以最后一个方括号内标识符为准;日期时间串尾偏移按偏移串处理。 +/// +/// # 边界与前提 +/// - 返回只含规范名,不含偏移:偏移是 epoch 的函数,由 zone_offset_seconds 统一提供。 +/// - 非法形式(越界小时/分钟、-000000 前缀、未知区名)返回 None。 +pub(crate) fn canonical_time_zone(value: &str) -> Option { let input = value.trim(); if input.eq_ignore_ascii_case("UTC") || input.eq_ignore_ascii_case("Z") { - return Some(("UTC".to_string(), 0)); + return Some("UTC".to_string()); } - if let Some(offset) = parse_offset_seconds(input) { - return Some((input.to_string(), offset)); + if parse_offset_seconds(input).is_some() { + return Some(input.to_string()); } // ±HH 基本偏移("+01" / "-05"):3 字符,hour ≤ 23,分钟恒 0。仅放宽构造器时区接受面。 @@ -674,8 +733,7 @@ pub(crate) fn canonical_time_zone(value: &str) -> Option<(String, i64)> { { let hour = (input.as_bytes()[1] - b'0') as i64 * 10 + (input.as_bytes()[2] - b'0') as i64; if hour <= 23 { - let sign = if input.as_bytes()[0] == b'-' { -1 } else { 1 }; - return Some((input.to_string(), sign * hour * 3600)); + return Some(input.to_string()); } return None; } @@ -688,8 +746,7 @@ pub(crate) fn canonical_time_zone(value: &str) -> Option<(String, i64)> { let hour = (input.as_bytes()[1] - b'0') as i64 * 10 + (input.as_bytes()[2] - b'0') as i64; let minute = (input.as_bytes()[3] - b'0') as i64 * 10 + (input.as_bytes()[4] - b'0') as i64; if hour <= 23 && minute <= 59 { - let sign = if input.as_bytes()[0] == b'-' { -1 } else { 1 }; - return Some((input.to_string(), sign * (hour * 3600 + minute * 60))); + return Some(input.to_string()); } return None; } @@ -697,6 +754,11 @@ pub(crate) fn canonical_time_zone(value: &str) -> Option<(String, i64)> { return None; } + // IANA 区名(含 legacy 别名):解别名后返回规范名。 + if let Some(name) = tz_canonical_name(input) { + return Some(name.to_string()); + } + // 带 annotation 的日期时间以最后一个方括号内标识符为准。 if let Some(open) = input.rfind('[') { if !input.ends_with(']') || open + 2 > input.len() { @@ -704,22 +766,25 @@ pub(crate) fn canonical_time_zone(value: &str) -> Option<(String, i64)> { } let annotation = &input[open + 1..input.len() - 1]; if annotation.eq_ignore_ascii_case("UTC") { - return Some(("UTC".to_string(), 0)); + return Some("UTC".to_string()); } - return parse_offset_seconds(annotation).map(|offset| (annotation.to_string(), offset)); + if parse_offset_seconds(annotation).is_some() { + return Some(annotation.to_string()); + } + return tz_canonical_name(annotation).map(|name| name.to_string()); } let time_start = input.find(['T', 't', ' '])?; let time = &input[time_start + 1..]; if time.ends_with(['Z', 'z']) { - return Some(("UTC".to_string(), 0)); + 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..]; - parse_offset_seconds(offset_id).map(|offset| (offset_id.to_string(), offset)) + parse_offset_seconds(offset_id).map(|_| offset_id.to_string()) } pub(crate) fn civil_from_days(days: i128) -> (i128, i128, i128) { @@ -1154,6 +1219,20 @@ pub(crate) fn temporal_calendar_id_strict(vm: &mut H, value: JsValue) temporal_calendar_id(vm, value) } +/// 偏移秒数格式化为 `±HH:MM`;秒数非分钟整数倍时补秒段(`±HH:MM:SS`)。 +/// +/// # 边界与前提 +/// - 输入为有符号偏移秒(|offset| ≤ 24 小时),负值取绝对值后拼负号。 +pub(crate) fn format_offset_seconds_text(offset_seconds: i64) -> String { + let sign = if offset_seconds < 0 { '-' } else { '+' }; + let magnitude = offset_seconds.abs(); + if magnitude % 60 != 0 { + format!("{sign}{:02}:{:02}:{:02}", magnitude / 3600, (magnitude % 3600) / 60, magnitude % 60) + } else { + format!("{sign}{:02}:{:02}", magnitude / 3600, (magnitude % 3600) / 60) + } +} + /// 将 ZonedDateTime 按时区偏移转换为本地 PlainDateTime 分量。 pub(crate) fn zoned_date_time_plain_parts( vm: &mut H, obj: &JsObject, @@ -1162,7 +1241,7 @@ pub(crate) fn zoned_date_time_plain_parts( return Err(crate::error::create_range_error(vm, "invalid ZonedDateTime")); }; let time_zone_id = to_string(obj.get_prop_at(1)); - let Some(offset_seconds) = instant_time_zone_offset(&time_zone_id) else { + let Some(offset_seconds) = zone_offset_seconds(&time_zone_id, epoch_ns.div_euclid(1_000_000_000) as i64) else { return Err(crate::error::create_range_error(vm, "invalid time zone")); }; const DAY_NS: i128 = 86_400_000_000_000; diff --git a/crates/oxide_builtins/src/temporal/duration.rs b/crates/oxide_builtins/src/temporal/duration.rs index 814cfaa9..75f7d425 100644 --- a/crates/oxide_builtins/src/temporal/duration.rs +++ b/crates/oxide_builtins/src/temporal/duration.rs @@ -15,7 +15,8 @@ use super::difference::{ add_date_duration, add_days_iso, compare_iso_date, date_duration_sign, date_until_iso, nudge_iso_difference, nudge_window, plain_date_time_unit_index, round_instant_difference, DifferenceSettings, DAY_NS, MAX_ISO_DAY, }; -use super::instant::{instant_rounding_mode, instant_time_zone_offset, InstantRoundingMode}; +use super::instant::{instant_rounding_mode, InstantRoundingMode}; +use super::time_zone::zone_offset_seconds; use super::zoned_date_time::{parse_any_offset_seconds, valid_offset_fraction, zoned_date_time_string_parts}; use super::{make_duration, parse_plain_date_time_string}; @@ -306,11 +307,11 @@ fn duration_relative_to_date( if let Ok((year, month, day, _time_ns)) = parse_plain_date_time_string(&text) { return Ok(Some((i128::from(year), i128::from(month), i128::from(day)))); } - // 回退支:plain 首支解析失败的串(含 Z 的串或非法注解)。命名区当前全部 RangeError - // (canonical_time_zone 拒 IANA),可解析的带注解串已被 plain 首支接受。 - // 按 instant 解析 + 注解时区反推墙钟日期。 + // 回退支:plain 首支解析失败的串(含 Z 的串、IANA 区注解串)。 + // 按 instant 解析 + 注解时区偏移(epoch 依赖查找)反推墙历日期。 let (epoch_ns, time_zone_id, _calendar) = zoned_date_time_string_parts(vm, &text, "reject")?; - let offset_seconds = instant_time_zone_offset(&time_zone_id).unwrap_or(0); + let offset_seconds = zone_offset_seconds(&time_zone_id, epoch_ns.div_euclid(1_000_000_000) as i64) + .ok_or_else(|| crate::error::create_range_error(vm, "invalid time zone"))?; let wall_ns = epoch_ns + i128::from(offset_seconds) * 1_000_000_000; let (year, month, day) = civil_from_days(wall_ns.div_euclid(DAY_NS)); return Ok(Some((year, month, day))); diff --git a/crates/oxide_builtins/src/temporal/instant.rs b/crates/oxide_builtins/src/temporal/instant.rs index f32eba5c..f4a00026 100644 --- a/crates/oxide_builtins/src/temporal/instant.rs +++ b/crates/oxide_builtins/src/temporal/instant.rs @@ -7,12 +7,14 @@ use oxide_types::object::JsObject; use oxide_types::value::JsValue; use super::common::native_try; +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, 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, receiver_obj, round_instant_difference, temporal_option_number, - temporal_option_string, temporal_option_value, FractionalSecondDigitsInput, MAX_INSTANT_NS, + 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, receiver_obj, + round_instant_difference, temporal_option_number, temporal_option_string, temporal_option_value, + FractionalSecondDigitsInput, MAX_INSTANT_NS, }; pub(crate) fn instant_string_without_annotations(input: &str) -> Option<&str> { @@ -727,11 +729,7 @@ pub fn instant_since(vm: &mut H, args: &[u8]) -> NativeResult { instant_difference(vm, args, true) } -pub(crate) fn instant_time_zone_offset(value: &str) -> Option { - canonical_time_zone(value).map(|(_, offset)| offset) -} - -fn format_instant_iso( +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; @@ -767,9 +765,7 @@ fn format_instant_iso( } if let Some(offset) = offset_seconds { - let sign = if offset < 0 { '-' } else { '+' }; - let magnitude = offset.abs(); - output.push_str(&format!("{sign}{:02}:{:02}", magnitude / 3600, (magnitude % 3600) / 60)); + output.push_str(&format_offset_seconds_text(offset)); } else { output.push('Z'); } @@ -857,7 +853,7 @@ pub fn instant_to_string(vm: &mut H, args: &[u8]) -> NativeResult { return NativeResult::Err(crate::error::create_type_error(vm, "invalid time zone")); } let time_zone = to_string(time_zone_raw); - match instant_time_zone_offset(&time_zone) { + match zone_offset_seconds(&time_zone, epoch_ns.div_euclid(1_000_000_000) as i64) { Some(offset) => Some(offset), None => return NativeResult::Err(crate::error::create_range_error(vm, "invalid time zone")), } @@ -900,7 +896,7 @@ pub fn instant_to_zoned_date_time_iso(vm: &mut H, args: &[u8]) -> Nat return NativeResult::Err(crate::error::create_type_error(vm, "invalid time zone")); } let input = to_string(raw); - let Some((time_zone_id, _)) = canonical_time_zone(&input) else { + let Some(time_zone_id) = canonical_time_zone(&input) else { return NativeResult::Err(crate::error::create_range_error(vm, "invalid time zone")); }; make_zoned_date_time(vm, epoch_ns, &time_zone_id, "iso8601") diff --git a/crates/oxide_builtins/src/temporal/mod.rs b/crates/oxide_builtins/src/temporal/mod.rs index efcb7b3a..c33e6441 100644 --- a/crates/oxide_builtins/src/temporal/mod.rs +++ b/crates/oxide_builtins/src/temporal/mod.rs @@ -19,6 +19,8 @@ pub use plain_date_time::*; pub use plain_month_day::*; pub use plain_time::*; pub use plain_year_month::*; +#[cfg(test)] +use time_zone::*; pub use zoned_date_time::*; #[cfg(test)] diff --git a/crates/oxide_builtins/src/temporal/tests.rs b/crates/oxide_builtins/src/temporal/tests.rs index 852b60f9..ea443451 100644 --- a/crates/oxide_builtins/src/temporal/tests.rs +++ b/crates/oxide_builtins/src/temporal/tests.rs @@ -164,10 +164,10 @@ fn zoned_date_time_string_wall_day_range_boundary() { #[test] fn canonical_time_zone_4_digit_offset() { - // ±HHMM 无冒号形式归一:ID 保留原串,offset 秒数正确换算。 - assert_eq!(canonical_time_zone("+0000"), Some(("+0000".to_string(), 0))); - assert_eq!(canonical_time_zone("-0530"), Some(("-0530".to_string(), -19800))); - assert_eq!(canonical_time_zone("+2330"), Some(("+2330".to_string(), 84600))); + // ±HHMM 无冒号形式归一:ID 保留原串(偏移秒值由 fixed_offset_seconds 单独提供)。 + assert_eq!(canonical_time_zone("+0000"), Some("+0000".to_string())); + assert_eq!(canonical_time_zone("-0530"), Some("-0530".to_string())); + assert_eq!(canonical_time_zone("+2330"), Some("+2330".to_string())); // 非法分钟/小时拒绝。 assert_eq!(canonical_time_zone("+2400"), None); assert_eq!(canonical_time_zone("+0060"), None); @@ -182,8 +182,8 @@ fn offset_seconds_representation_sub_minute() { assert_eq!(parse_any_offset_seconds("+01:00:00"), Some(3600)); assert_eq!(parse_any_offset_seconds("-23:59:59"), Some(-(23 * 3600 + 59 * 60 + 59))); // 固定偏移秒值与分钟值 ×60 一致(±HH:MM 六字符形)。 - assert_eq!(canonical_time_zone("+01:00"), Some(("+01:00".to_string(), 3600))); - assert_eq!(canonical_time_zone("-05:30"), Some(("-05:30".to_string(), -19800))); + assert_eq!(canonical_time_zone("+01:00"), Some("+01:00".to_string())); + assert_eq!(canonical_time_zone("-05:30"), Some("-05:30".to_string())); // 亚分钟偏移经 local_to_epoch_ns 与 parse_instant_string 互逆对拍。 assert_eq!( parse_instant_string("2024-01-01T00:00:00-00:44:30"), @@ -385,3 +385,51 @@ fn plain_time_apply_duration_ignores_date_units() { // 忽略 days:time_delta 不含 DAY_NS 分量。 assert_eq!(time_delta % 86_400_000_000_000, 9_000_000_000_000); } + +#[test] +fn canonical_time_zone_iana_acceptance() { + // IANA 区名(含 legacy 别名)解到规范名;非法区名拒绝。 + assert_eq!(canonical_time_zone("America/New_York"), Some("America/New_York".to_string())); + assert_eq!(canonical_time_zone("Asia/Calcutta"), Some("Asia/Kolkata".to_string())); + assert_eq!(canonical_time_zone("Etc/Ignored"), Some("Etc/UTC".to_string())); + assert_eq!(canonical_time_zone("Not/AZone"), None); + // UTC / 数值偏移臂不变(只返回规范名,不再返回偏移)。 + assert_eq!(canonical_time_zone("UTC"), Some("UTC".to_string())); + assert_eq!(canonical_time_zone("Z"), Some("UTC".to_string())); + assert_eq!(canonical_time_zone("+01:00"), Some("+01:00".to_string())); + // 注解串:IANA 区注解解到规范名。 + assert_eq!(canonical_time_zone("2024-01-01T00:00:00[UTC]"), Some("UTC".to_string())); + assert_eq!( + canonical_time_zone("2024-01-01T00:00:00[America/New_York]"), + Some("America/New_York".to_string()) + ); +} + +#[test] +fn zone_offset_seconds_fixed_and_iana() { + // 固定区退化情形:偏移与 epoch 无关,恒返常量(含亚分钟偏移)。 + assert_eq!(zone_offset_seconds("+05:30", 0), Some(19800)); + assert_eq!(zone_offset_seconds("-00:44:30", 1_000_000_000), Some(-2670)); + assert_eq!(zone_offset_seconds("UTC", 0), Some(0)); + // IANA 区:1970 前后两点(EST / EDT 边界 2000-04-02T07:00Z,纽约本地 02:00)。 + assert_eq!(zone_offset_seconds("America/New_York", 0), Some(-18000)); + assert_eq!(zone_offset_seconds("America/New_York", 954_658_800), Some(-14400)); + assert_eq!(zone_offset_seconds("America/New_York", 954_658_800 - 1), Some(-18000)); + // 非法区名返回 None。 + assert_eq!(zone_offset_seconds("Not/AZone", 0), None); +} + +#[test] +fn sub_minute_offset_getter_branch() { + // Monrovia -00:44:30:四格式化面补秒段;整分钟偏移不补。 + assert_eq!(format_offset_seconds_text(-2670), "-00:44:30"); + assert_eq!(format_offset_seconds_text(2670), "+00:44:30"); + assert_eq!(format_offset_seconds_text(3600), "+01:00"); + assert_eq!(format_time_zone_annotation("-00:44:30", false), "[-00:44:30]"); + let iso = format_zoned_date_time_iso(0, -2670, "-00:44:30", "iso8601", true, None, "auto", "auto", "auto"); + assert_eq!(iso, Some("1969-12-31T23:15:30-00:44:30[-00:44:30]".to_string())); + assert_eq!( + format_instant_iso(0, Some(-2670), true, None), + Some("1969-12-31T23:15:30-00:44:30".to_string()) + ); +} diff --git a/crates/oxide_builtins/src/temporal/time_zone.rs b/crates/oxide_builtins/src/temporal/time_zone.rs index a8c9ec36..938f7ea9 100644 --- a/crates/oxide_builtins/src/temporal/time_zone.rs +++ b/crates/oxide_builtins/src/temporal/time_zone.rs @@ -13,6 +13,8 @@ use std::sync::LazyLock; +use super::common::fixed_offset_seconds; + /// 单个时区的解析结果:transition 表加偏移查找所需的预存值。 /// /// `transitions` 为 `(纪元秒, 该时刻起生效的偏移秒)`,按纪元秒严格升序; @@ -1121,7 +1123,6 @@ static ZONE_TABLE: LazyLock = LazyLock::new(|| { /// /// # 注意事项 /// - 首次调用触发全量解析(毫秒级),之后为纯二分。 -#[allow(dead_code)] pub(crate) fn tz_zone_data(name: &str) -> Option<&'static TzifZone> { let target = match LEGACY_ALIASES.binary_search_by(|a| a.0.cmp(name)) { Ok(i) => LEGACY_ALIASES[i].1, @@ -1133,6 +1134,26 @@ pub(crate) fn tz_zone_data(name: &str) -> Option<&'static TzifZone> { } } +/// 按 IANA 区名(含 legacy 别名)查规范区名。 +/// +/// # 步骤 +/// 1. 先解 legacy 别名(`LEGACY_ALIASES` 二分),未命中则原名。 +/// 2. 在区表二分查找,命中返回其规范名(`&'static str`)。 +/// +/// # 边界与前提 +/// - 别名只解一层:别名目标是现行区名,不再二次解别名。 +pub(crate) fn tz_canonical_name(name: &str) -> Option<&'static str> { + let target = match LEGACY_ALIASES.binary_search_by(|a| a.0.cmp(name)) { + Ok(i) => LEGACY_ALIASES[i].1, + Err(_) => name, + }; + ZONE_TABLE + .zones + .binary_search_by(|z| z.0.cmp(target)) + .ok() + .map(|i| ZONE_TABLE.zones[i].0) +} + /// 查某区在 `epoch_s`(纪元秒)生效的 UTC 偏移(秒)。 /// /// # 步骤 @@ -1147,7 +1168,6 @@ pub(crate) fn tz_zone_data(name: &str) -> Option<&'static TzifZone> { /// # 注意事项 /// - 只支持正向查找(epoch → 偏移);local → epoch 的逆换算(ambiguous / /// nonexistent)由调用方迭代,不在本函数。 -#[allow(dead_code)] pub(crate) fn tz_offset_seconds(zone: &str, epoch_s: i64) -> Option { let z = tz_zone_data(zone)?; if z.transitions.is_empty() { @@ -1163,6 +1183,23 @@ pub(crate) fn tz_offset_seconds(zone: &str, epoch_s: i64) -> Option { } } +/// 统一时区偏移查找(epoch 依赖):固定偏移区(数值偏移串)走快速路径恒返常量, +/// IANA 区走 transition 表二分。 +/// +/// # 步骤 +/// 1. 数值偏移串(±HH:MM / ±HH / ±HHMM / ±HH:MM:SS)直接解析为秒返回。 +/// 2. 未命中走 `tz_offset_seconds`(IANA 区,含 legacy 别名)。 +/// +/// # 边界与前提 +/// - 固定偏移区是 zone-aware 路径的退化情形:偏移与 epoch 无关,恒返回常量。 +/// - 非法区名(非数值偏移串且不在区表)返回 `None`。 +pub(crate) fn zone_offset_seconds(zone: &str, epoch_s: i64) -> Option { + if let Some(offset) = fixed_offset_seconds(zone) { + return Some(offset); + } + tz_offset_seconds(zone, epoch_s) +} + /// 判断 `name` 是否为可接受的 IANA 区名(含 legacy 别名)。 #[allow(dead_code)] pub(crate) fn tz_is_iana_zone(name: &str) -> bool { diff --git a/crates/oxide_builtins/src/temporal/zoned_date_time.rs b/crates/oxide_builtins/src/temporal/zoned_date_time.rs index a95ef840..daf0e08d 100644 --- a/crates/oxide_builtins/src/temporal/zoned_date_time.rs +++ b/crates/oxide_builtins/src/temporal/zoned_date_time.rs @@ -8,19 +8,20 @@ use oxide_types::value::JsValue; use super::common::native_try; use super::common::{ - canonical_time_zone, civil_from_days, days_from_civil, days_in_month, ensure_zoned_date_time, format_iso_year, - get_calendar_id, get_double_prop, get_instant_epoch_ns, initialize_temporal_receiver, is_ctor_call, - local_to_epoch_ns, make_instant, make_zoned_date_time, parse_digits, parse_fractional_second_digits, - parse_temporal_string_impl, plain_date_time_object_parts, receiver_obj, + canonical_time_zone, civil_from_days, days_from_civil, days_in_month, ensure_zoned_date_time, fixed_offset_seconds, + format_iso_year, format_offset_seconds_text, get_calendar_id, get_double_prop, get_instant_epoch_ns, + initialize_temporal_receiver, is_ctor_call, local_to_epoch_ns, make_instant, make_zoned_date_time, parse_digits, + parse_fractional_second_digits, parse_temporal_string_impl, plain_date_time_object_parts, receiver_obj, reject_partial_object_with_calendar_or_time_zone, temporal_calendar_id, temporal_calendar_id_strict, temporal_option_number, temporal_option_string, temporal_option_value, temporal_overflow, temporal_string_strict, zoned_date_time_plain_parts, FractionalSecondDigitsInput, MAX_INSTANT_NS, }; use super::difference::{difference_core, parse_difference_settings, MAX_ISO_DAY}; use super::instant::{ - instant_rounding_mode, instant_string_without_annotations, instant_time_zone_offset, parse_instant_string, - primitive_to_bigint, round_instant_ns, + instant_rounding_mode, instant_string_without_annotations, parse_instant_string, primitive_to_bigint, + round_instant_ns, }; +use super::time_zone::zone_offset_seconds; use super::{ days_in_month_iso, duration_component_integer, duration_like_values, is_leap_year_iso, make_duration, make_plain_date, make_plain_date_time, make_plain_time, plain_time_components, plain_time_like_ns, valid_iso_date, @@ -45,7 +46,7 @@ pub fn zoned_date_time_constructor(vm: &mut H, args: &[u8]) -> Native return NativeResult::Err(crate::error::create_type_error(vm, "invalid time zone")); } let time_zone_input = to_string(vm.reg(args[2])); - let Some((time_zone_id, _)) = canonical_time_zone(&time_zone_input) else { + let Some(time_zone_id) = canonical_time_zone(&time_zone_input) else { return NativeResult::Err(crate::error::create_range_error(vm, "invalid time zone")); }; let calendar = if args.len() >= 4 && !vm.reg(args[3]).is_undefined() { @@ -295,10 +296,11 @@ fn zoned_date_time_like_epoch_ns( /// 字符串分支:instant 解析 + 时区注解提取,按 offset 选项决定最终 epoch。 /// /// # 步骤 -/// 1. extract_time_zone_annotation 取注解 → canonical_time_zone 得时区 ID 与偏移。 +/// 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 范围校验。 +/// 3. 时区偏移按候选 epoch(墙钟按 UTC 解释)单次查找。 +/// 4. 按 offset 选项 use/ignore/prefer/reject 决策 epoch,并做 Instant 范围校验。 /// /// # 边界与前提 /// - 字符串缺时区注解或注解无法规范化抛 RangeError;主体非法抛 RangeError。 @@ -309,9 +311,8 @@ pub(crate) fn zoned_date_time_string_parts( 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 time_zone_id = + canonical_time_zone(&tz_annotation).ok_or_else(|| 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")); }; @@ -342,6 +343,11 @@ pub(crate) fn zoned_date_time_string_parts( let wall_epoch = |offset_seconds: i64| local_to_epoch_ns(wall_parts.0, wall_parts.1, wall_parts.2, wall_parts.3, offset_seconds); + // 时区偏移是 epoch 的函数:候选 epoch 取墙钟按 UTC 解释的单次查找近似。 + let candidate_ns = wall_epoch(0).ok_or_else(|| crate::error::create_range_error(vm, "invalid date-time"))?; + let time_zone_offset = zone_offset_seconds(&time_zone_id, candidate_ns.div_euclid(1_000_000_000) as i64) + .ok_or_else(|| crate::error::create_range_error(vm, "invalid time zone"))?; + // 按 offsetBehaviour 决策:Z → exact(墙钟 epoch);无偏移 → wall(墙钟 + 时区偏移); // 有偏移 → 按 offset 选项在字符串偏移与时区偏移之间选择。 let epoch_ns = if has_utc_designator { @@ -395,9 +401,8 @@ fn zoned_date_time_bag_parts( 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")); - }; + let time_zone_id = canonical_time_zone(&time_zone_input) + .ok_or_else(|| crate::error::create_range_error(vm, "invalid time zone"))?; // offset 可选:语法校验(ToOffsetString 语义)先于数值字段转换。 let offset_raw = temporal_option_value(vm, obj, value, "offset")?; @@ -406,7 +411,7 @@ fn zoned_date_time_bag_parts( } else { let offset_input = temporal_option_string(vm, offset_raw)?; Some( - instant_time_zone_offset(&offset_input) + fixed_offset_seconds(&offset_input) .ok_or_else(|| crate::error::create_range_error(vm, "invalid offset"))?, ) }; @@ -414,6 +419,12 @@ fn zoned_date_time_bag_parts( // 年月日字段与 calendar:内部先读 calendar 再按字母序读字段并做类型转换校验。 let (year, month, day, total_ns, calendar) = plain_date_time_object_parts(vm, value, obj, false, false)?; + // 时区偏移是 epoch 的函数:候选 epoch 取墙钟按 UTC 解释的单次查找近似。 + let candidate_ns = local_to_epoch_ns(year, month, day, total_ns, 0) + .ok_or_else(|| crate::error::create_range_error(vm, "invalid date-time"))?; + let time_zone_offset = zone_offset_seconds(&time_zone_id, candidate_ns.div_euclid(1_000_000_000) as i64) + .ok_or_else(|| crate::error::create_range_error(vm, "invalid time zone"))?; + // 按 offset 选项决定 epoch(InterpretISODateTimeOffset 固定偏移简化:候选恒唯一)。 let epoch_ns = match (bag_offset_seconds, offset_mode) { (None, _) | (Some(_), "ignore") => local_to_epoch_ns(year, month, day, total_ns, time_zone_offset), @@ -482,13 +493,11 @@ pub fn zoned_date_time_compare(vm: &mut H, args: &[u8]) -> NativeResu })) } -/// 时区注解文本:命名区原样返回,偏移区规范化为 `±HH:MM` 带冒号;critical 时 `!` 置于括号内。 +/// 时区注解文本:命名区原样返回,偏移区规范化为 `±HH:MM` 带冒号(秒非零时 `±HH:MM:SS`); +/// critical 时 `!` 置于括号内。 pub(crate) 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'-')) { - let offset_seconds = instant_time_zone_offset(time_zone_id).unwrap_or(0); - let sign = if offset_seconds < 0 { '-' } else { '+' }; - let magnitude = offset_seconds.abs(); - format!("{sign}{:02}:{:02}", magnitude / 3600, (magnitude % 3600) / 60) + format_offset_seconds_text(fixed_offset_seconds(time_zone_id).unwrap_or(0)) } else { time_zone_id.to_string() }; @@ -504,7 +513,7 @@ pub(crate) fn format_time_zone_annotation(time_zone_id: &str, critical: bool) -> /// # 边界与前提 /// - epoch_ns 须已按 quantum 舍入(调用方保证),此处仅做本地墙钟分解。 /// - offset_name/time_zone_name 取 auto/never/critical,calendar_name 取 auto/never/always/critical。 -/// - 偏移段恒 `±HH:MM` 带冒号;时区注解命名区原样、偏移区规范化。 +/// - 偏移段 `±HH:MM` 带冒号(秒非零时 `±HH:MM:SS`);时区注解命名区原样、偏移区规范化。 #[expect(clippy::too_many_arguments)] pub(crate) fn format_zoned_date_time_iso( epoch_ns: i128, offset_seconds: i64, time_zone_id: &str, calendar_id: &str, include_seconds: bool, @@ -544,12 +553,10 @@ pub(crate) fn format_zoned_date_time_iso( // offset 段:auto/always 显示,never 省略,critical 段前加 !。 if offset_name != "never" { - let sign = if offset_seconds < 0 { '-' } else { '+' }; - let magnitude = offset_seconds.abs(); if offset_name == "critical" { output.push('!'); } - output.push_str(&format!("{sign}{:02}:{:02}", magnitude / 3600, (magnitude % 3600) / 60)); + output.push_str(&format_offset_seconds_text(offset_seconds)); } // timeZoneName 注解:auto 显示,never 省略,critical 时 `!` 置于括号内。 @@ -736,7 +743,7 @@ pub fn zoned_date_time_with_time_zone(vm: &mut H, args: &[u8]) -> Nat return NativeResult::Err(crate::error::create_type_error(vm, "invalid time zone")); } let input = to_string(raw); - let Some((time_zone_id, _)) = canonical_time_zone(&input) else { + let Some(time_zone_id) = canonical_time_zone(&input) else { return NativeResult::Err(crate::error::create_range_error(vm, "invalid time zone")); }; make_zoned_date_time(vm, epoch_ns, &time_zone_id, &calendar_id) @@ -1207,7 +1214,10 @@ fn zoned_date_time_other_wall_epoch_ns( if !valid_iso_date(year, month, day) || !total_ns.is_finite() || !(0.0..86_400_000_000_000.0).contains(&total_ns) { return Err(crate::error::create_range_error(vm, "invalid date-time component")); } - let offset_seconds = instant_time_zone_offset(default_tz_id) + // 时区偏移是 epoch 的函数:候选 epoch 取墙钟按 UTC 解释的单次查找近似。 + let candidate_ns = local_to_epoch_ns(year, month, day, total_ns, 0) + .ok_or_else(|| crate::error::create_range_error(vm, "invalid date-time"))?; + let offset_seconds = zone_offset_seconds(default_tz_id, candidate_ns.div_euclid(1_000_000_000) as i64) .ok_or_else(|| crate::error::create_range_error(vm, "invalid time zone"))?; local_to_epoch_ns(year, month, day, total_ns, offset_seconds) .ok_or_else(|| crate::error::create_range_error(vm, "invalid date-time")) @@ -1224,15 +1234,14 @@ fn zoned_date_time_other_bag_epoch_ns( vm: &mut H, value: JsValue, obj: &JsObject, default_tz_id: &str, ) -> Result { let time_zone_raw = temporal_option_value(vm, obj, value, "timeZone")?; - let time_zone_offset = if time_zone_raw.is_undefined() { - instant_time_zone_offset(default_tz_id) - .ok_or_else(|| crate::error::create_range_error(vm, "invalid time zone"))? + let time_zone_id = if time_zone_raw.is_undefined() { + default_tz_id.to_string() } else { if !time_zone_raw.is_string() { return Err(crate::error::create_type_error(vm, "invalid time zone")); } - let input = to_string(time_zone_raw); - instant_time_zone_offset(&input).ok_or_else(|| crate::error::create_range_error(vm, "invalid time zone"))? + canonical_time_zone(&to_string(time_zone_raw)) + .ok_or_else(|| crate::error::create_range_error(vm, "invalid time zone"))? }; let offset_raw = temporal_option_value(vm, obj, value, "offset")?; @@ -1244,12 +1253,17 @@ fn zoned_date_time_other_bag_epoch_ns( } let offset_input = to_string(offset_raw); Some( - instant_time_zone_offset(&offset_input) + fixed_offset_seconds(&offset_input) .ok_or_else(|| crate::error::create_range_error(vm, "invalid offset"))?, ) }; let (year, month, day, total_ns, _calendar) = plain_date_time_object_parts(vm, value, obj, false, false)?; + // 时区偏移是 epoch 的函数:候选 epoch 取墙钟按 UTC 解释的单次查找近似。 + let candidate_ns = local_to_epoch_ns(year, month, day, total_ns, 0) + .ok_or_else(|| crate::error::create_range_error(vm, "invalid date-time"))?; + let time_zone_offset = zone_offset_seconds(&time_zone_id, candidate_ns.div_euclid(1_000_000_000) as i64) + .ok_or_else(|| crate::error::create_range_error(vm, "invalid time zone"))?; if let Some(offset) = bag_offset_seconds { if offset != time_zone_offset { return Err(crate::error::create_range_error(vm, "offset and time zone disagree")); @@ -1266,7 +1280,7 @@ fn zoned_date_time_other_bag_epoch_ns( /// ZDT 差值核心:两 ZDT 的 epoch 差 → receiver 时区下的本地墙钟分量差 → difference_core。 /// /// # 步骤 -/// 1. receiver 三槽读取:epoch、offset 分钟、时区 ID,本地分量经 zoned_date_time_plain_parts。 +/// 1. receiver 三槽读取:epoch、偏移秒、时区 ID,本地分量经 zoned_date_time_plain_parts。 /// 2. other → epoch(zoned_date_time_other_epoch_ns,receiver 时区作 bag 默认)。 /// 3. epoch 相等快速路径:先于任何分量计算返回全零时长。 /// 4. other 本地分量:epoch + offset → div_euclid/rem_euclid 拆墙钟(receiver 时区)。 @@ -1342,7 +1356,7 @@ pub(crate) fn zoned_date_time_round_unit(value: &str) -> Option<(i128, i128)> { /// # 步骤 /// 1. roundTo 解析:undefined → TypeError;字符串 → {smallestUnit: 串};对象 → 依次 Get /// roundingIncrement → roundingMode → smallestUnit,全部读完再统一校验。 -/// 2. receiver:ensure_zoned_date_time → epoch_r;instant_time_zone_offset(tz) 得 offset_seconds; +/// 2. receiver:ensure_zoned_date_time → epoch_r;zone_offset_seconds(tz, epoch_r) 得 offset_seconds; /// zoned_date_time_plain_parts 得 (y, m, d, time_ns)。 /// 3. 单位表查 smallestUnit(None → RangeError "invalid smallest unit")。 /// 4. roundingIncrement 校验:1..=1e9 且真因子(increment < units_per_day 且 @@ -1363,7 +1377,7 @@ pub fn zoned_date_time_round(vm: &mut H, args: &[u8]) -> NativeResult return NativeResult::Err(crate::error::create_range_error(vm, "invalid ZonedDateTime")); }; let time_zone_id = to_string(obj.get_prop_at(1)); - let Some(offset_seconds) = instant_time_zone_offset(&time_zone_id) else { + let Some(offset_seconds) = zone_offset_seconds(&time_zone_id, epoch_r.div_euclid(1_000_000_000) as i64) else { return NativeResult::Err(crate::error::create_range_error(vm, "invalid time zone")); }; let calendar_id = get_calendar_id(obj, 2); @@ -1569,21 +1583,24 @@ zoned_date_time_brand_only_getter!(zoned_date_time_months_in_year, JsValue::floa zoned_date_time_brand_only_getter!(zoned_date_time_era, JsValue::undefined()); zoned_date_time_brand_only_getter!(zoned_date_time_era_year, JsValue::undefined()); -/// 读 ZDT 时区偏移(秒);槽 1 解析失败返回 RangeError。调用方须先 ensure_zoned_date_time。 +/// 读 ZDT 时区偏移(秒):epoch 依赖查找(槽 0 epoch 秒 + 槽 1 时区名 → zone_offset_seconds)。 +/// 调用方须先 ensure_zoned_date_time。 fn zoned_date_time_offset_seconds(vm: &mut H, obj: &JsObject) -> Result { + let Some(epoch_ns) = get_instant_epoch_ns(obj) else { + return Err(crate::error::create_range_error(vm, "invalid ZonedDateTime")); + }; let time_zone_id = to_string(obj.get_prop_at(1)); - instant_time_zone_offset(&time_zone_id).ok_or_else(|| crate::error::create_range_error(vm, "invalid time zone")) + zone_offset_seconds(&time_zone_id, epoch_ns.div_euclid(1_000_000_000) as i64) + .ok_or_else(|| crate::error::create_range_error(vm, "invalid time zone")) } -/// `Temporal.ZonedDateTime.prototype.offset`:由偏移秒数规范化为 ±HH:MM 字符串。 +/// `Temporal.ZonedDateTime.prototype.offset`:由偏移秒数规范化为 `±HH:MM` 字符串(秒非零时 `±HH:MM:SS`)。 pub fn zoned_date_time_offset(vm: &mut H, args: &[u8]) -> NativeResult { let ptr = native_try!(receiver_obj(vm, args)); let obj = unsafe { &*ptr }; native_try!(ensure_zoned_date_time(vm, obj)); let offset_seconds = native_try!(zoned_date_time_offset_seconds(vm, obj)); - let sign = if offset_seconds < 0 { '-' } else { '+' }; - let magnitude = offset_seconds.abs(); - NativeResult::Ok(vm.new_string(&format!("{sign}{:02}:{:02}", magnitude / 3600, (magnitude % 3600) / 60))) + NativeResult::Ok(vm.new_string(&format_offset_seconds_text(offset_seconds))) } /// `Temporal.ZonedDateTime.prototype.offsetNanoseconds`:偏移秒数换算纳秒(f64 精确域内)。