refactor: temporal 字符串解析与算历层并入 common

This commit is contained in:
2026-09-17 07:54:13 +00:00
parent a83916c060
commit e4207b41c0
2 changed files with 609 additions and 605 deletions
+609 -2
View File
@@ -16,8 +16,8 @@ use oxide_types::object::JsObject;
use oxide_types::value::JsValue;
use super::{
days_in_month, parse_temporal_calendar_string, temporal_calendar_id, valid_iso_date, valid_plain_date_time_range,
valid_plain_time,
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,
};
pub(crate) const MAX_INSTANT_NS: i128 = 8_640_000_000_000_000_000_000;
@@ -549,3 +549,610 @@ pub(crate) fn month_day_bag_calendar_id<H: VmHost>(vm: &mut H, value: JsValue) -
}
Err(crate::error::create_type_error(vm, "invalid calendar"))
}
pub(crate) fn parse_digits(bytes: &[u8], cursor: &mut usize, count: usize) -> Option<i128> {
let end = cursor.checked_add(count)?;
let digits = bytes.get(*cursor..end)?;
if !digits.iter().all(u8::is_ascii_digit) {
return None;
}
*cursor = end;
digits
.iter()
.try_fold(0_i128, |value, digit| value.checked_mul(10)?.checked_add((digit - b'0') as i128))
}
pub(crate) fn is_leap_year(year: i128) -> bool {
year.rem_euclid(4) == 0 && (year.rem_euclid(100) != 0 || year.rem_euclid(400) == 0)
}
pub(crate) fn days_in_month(year: i128, month: i128) -> Option<i128> {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => Some(31),
4 | 6 | 9 | 11 => Some(30),
2 => Some(if is_leap_year(year) { 29 } else { 28 }),
_ => None,
}
}
pub(crate) fn days_from_civil(mut year: i128, month: i128, day: i128) -> i128 {
year -= i128::from(month <= 2);
let era = year.div_euclid(400);
let year_of_era = year - era * 400;
let shifted_month = month + if month > 2 { -3 } else { 9 };
let day_of_year = (153 * shifted_month + 2) / 5 + day - 1;
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
era * 146_097 + day_of_era - 719_468
}
pub(crate) enum FractionalSecondDigitsInput {
Auto,
Number(f64),
String(String),
}
pub(crate) fn parse_fractional_second_digits<H: VmHost>(
vm: &mut H, input: FractionalSecondDigitsInput,
) -> Result<Option<usize>, JsValue> {
match input {
FractionalSecondDigitsInput::Auto => Ok(None),
FractionalSecondDigitsInput::String(value) if value == "auto" => Ok(None),
FractionalSecondDigitsInput::String(_) => {
Err(crate::error::create_range_error(vm, "invalid fractionalSecondDigits"))
}
FractionalSecondDigitsInput::Number(value) => {
let digits = value.floor();
if !digits.is_finite() || !(0.0..=9.0).contains(&digits) {
return Err(crate::error::create_range_error(vm, "invalid fractionalSecondDigits"));
}
Ok(Some(digits as usize))
}
}
}
pub(crate) fn parse_offset_minutes(value: &str) -> Option<i32> {
let bytes = value.as_bytes();
if bytes.len() != 6 || !matches!(bytes[0], b'+' | b'-') || bytes[3] != b':' {
return None;
}
if !bytes[1..3].iter().all(u8::is_ascii_digit) || !bytes[4..6].iter().all(u8::is_ascii_digit) {
return None;
}
let hours = i32::from(bytes[1] - b'0') * 10 + i32::from(bytes[2] - b'0');
let minutes = i32::from(bytes[4] - b'0') * 10 + i32::from(bytes[5] - b'0');
if hours > 23 || minutes > 59 {
return None;
}
let magnitude = hours * 60 + minutes;
Some(if bytes[0] == b'-' { -magnitude } else { magnitude })
}
pub(crate) fn canonical_time_zone(value: &str) -> Option<(String, i32)> {
let input = value.trim();
if input.eq_ignore_ascii_case("UTC") || input.eq_ignore_ascii_case("Z") {
return Some(("UTC".to_string(), 0));
}
if let Some(offset) = parse_offset_minutes(input) {
return Some((input.to_string(), offset));
}
// ±HH 基本偏移("+01" / "-05"):3 字符,hour ≤ 23,分钟恒 0。仅放宽构造器时区接受面。
if input.len() == 3
&& matches!(input.as_bytes()[0], b'+' | b'-')
&& input.as_bytes()[1..3].iter().all(u8::is_ascii_digit)
{
let hour = (input.as_bytes()[1] - b'0') as i32 * 10 + (input.as_bytes()[2] - b'0') as i32;
if hour <= 23 {
let sign = if input.as_bytes()[0] == b'-' { -1 } else { 1 };
return Some((input.to_string(), sign * hour * 60));
}
return None;
}
// ±HHMM 基本偏移("+0000" / "-0530"):5 字符,无冒号,分钟位可选非零。4 位无冒号归一形式。
if input.len() == 5
&& matches!(input.as_bytes()[0], b'+' | b'-')
&& input.as_bytes()[1..5].iter().all(u8::is_ascii_digit)
{
let hour = (input.as_bytes()[1] - b'0') as i32 * 10 + (input.as_bytes()[2] - b'0') as i32;
let minute = (input.as_bytes()[3] - b'0') as i32 * 10 + (input.as_bytes()[4] - b'0') as i32;
if hour <= 23 && minute <= 59 {
let sign = if input.as_bytes()[0] == b'-' { -1 } else { 1 };
return Some((input.to_string(), sign * (hour * 60 + minute)));
}
return None;
}
if input.starts_with("-000000") {
return None;
}
// 带 annotation 的日期时间以最后一个方括号内标识符为准。
if let Some(open) = input.rfind('[') {
if !input.ends_with(']') || open + 2 > input.len() {
return None;
}
let annotation = &input[open + 1..input.len() - 1];
if annotation.eq_ignore_ascii_case("UTC") {
return Some(("UTC".to_string(), 0));
}
return parse_offset_minutes(annotation).map(|offset| (annotation.to_string(), offset));
}
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));
}
let offset_start = time
.char_indices()
.rev()
.find_map(|(index, ch)| matches!(ch, '+' | '-').then_some(index))?;
let offset_id = &time[offset_start..];
parse_offset_minutes(offset_id).map(|offset| (offset_id.to_string(), offset))
}
pub(crate) fn civil_from_days(days: i128) -> (i128, i128, i128) {
let shifted = days + 719_468;
let era = shifted.div_euclid(146_097);
let day_of_era = shifted - era * 146_097;
let year_of_era = (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let mut year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_prime = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
year += i128::from(month <= 2);
(year, month, day)
}
pub(crate) fn format_iso_year(year: i128) -> String {
if (0..=9_999).contains(&year) {
format!("{year:04}")
} else if year >= 0 {
format!("+{year:06}")
} else {
format!("-{:06}", -year)
}
}
pub(crate) fn validate_temporal_annotation_suffix(mut suffix: &str) -> Result<(), String> {
let mut calendar_seen = false;
let mut saw_critical_calendar = false;
let mut time_zone_seen = false;
while !suffix.is_empty() {
if !suffix.starts_with('[') {
return Err("invalid annotation suffix".into());
}
let Some(end) = suffix.find(']') else {
return Err("unterminated annotation".into());
};
if end == 1 {
return Err("empty annotation".into());
}
let annotation = &suffix[1..end];
let (critical, body) = annotation.strip_prefix('!').map_or((false, annotation), |body| (true, body));
if let Some((key, value)) = body.split_once('=') {
if key.bytes().any(|byte| byte.is_ascii_uppercase()) {
return Err("annotation keys must be lowercase".into());
}
if key == "u-ca" {
if calendar_seen {
if critical || saw_critical_calendar {
return Err("invalid calendar annotation".into());
}
} else if is_builtin_calendar_id(value).is_none() {
return Err("invalid calendar annotation".into());
} else {
calendar_seen = true;
saw_critical_calendar = critical;
}
} else if critical {
return Err("unknown critical annotation".into());
}
} else {
if time_zone_seen {
return Err("invalid time-zone annotation".into());
}
time_zone_seen = true;
}
suffix = &suffix[end + 1..];
}
Ok(())
}
/// 剥离 ISO 偏移(±HH、±HHMM、±HH:MM、±HHMMSS、±HH:MM:SS,秒可带小数),返回剩余部分。
pub(crate) fn strip_iso_offset(input: &str) -> Result<&str, String> {
let bytes = input.as_bytes();
if bytes.len() < 3 || !matches!(bytes[0], b'+' | b'-') {
return Err("invalid ISO offset".into());
}
let colon = bytes.len() > 3 && bytes[3] == b':';
let mut i = 1usize;
let take_digits = |count: usize, cursor: &mut usize| -> Result<(), String> {
if *cursor + count > bytes.len() || !bytes[*cursor..*cursor + count].iter().all(u8::is_ascii_digit) {
return Err("invalid ISO offset".into());
}
*cursor += count;
Ok(())
};
take_digits(2, &mut i)?;
if i < bytes.len() {
if colon {
if bytes[i] != b':' {
return Err("invalid ISO offset".into());
}
i += 1;
}
if i + 2 <= bytes.len() && bytes[i..i + 2].iter().all(u8::is_ascii_digit) {
i += 2;
if i < bytes.len() {
if colon {
if bytes[i] != b':' {
return Err("invalid ISO offset".into());
}
i += 1;
}
if i + 2 <= bytes.len() && bytes[i..i + 2].iter().all(u8::is_ascii_digit) {
i += 2;
if i < bytes.len() && matches!(bytes[i], b'.' | b',') {
i += 1;
let start = i;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i == start {
return Err("invalid ISO offset fraction".into());
}
if i - start > 9 {
return Err("invalid ISO offset fraction".into());
}
}
}
}
}
}
let digits: String = input[1..i].chars().filter(|ch| ch.is_ascii_digit()).take(6).collect();
if digits.len() < 2 {
return Err("invalid ISO offset".into());
}
let hour: u32 = digits[..2].parse().map_err(|_| "invalid ISO offset".to_string())?;
let minute: u32 = if digits.len() >= 4 {
digits[2..4].parse().map_err(|_| "invalid ISO offset".to_string())?
} else {
0
};
let second: u32 = if digits.len() >= 6 {
digits[4..6].parse().map_err(|_| "invalid ISO offset".to_string())?
} else {
0
};
if hour > 23 || minute > 59 || second > 59 {
return Err("invalid ISO offset".into());
}
Ok(&input[i..])
}
/// ParseTemporalDateString:PlainDate 字符串(时间部分可选且被忽略,仅按日期做范围校验)。
pub(crate) fn parse_plain_date_string(input: &str) -> Result<(i32, u32, u32), String> {
parse_temporal_string_impl(input, false).map(|(year, month, day, _)| (year, month, day))
}
pub(crate) fn parse_temporal_string_impl(
input: &str, enforce_date_time_range: bool,
) -> Result<(i32, u32, u32, f64), String> {
let trimmed = input.trim();
if trimmed.contains('\u{2212}') {
return Err("variant minus sign is not valid for PlainDateTime".into());
}
let text = trimmed.to_owned();
let annotation_start = text.find('[').unwrap_or(text.len());
validate_temporal_annotation_suffix(&text[annotation_start..])?;
let text = &text[..annotation_start];
if text.contains('Z') || text.contains('z') {
return Err("UTC designator is not valid for PlainDateTime".into());
}
let separator = text.find(['T', 't', ' ']);
let date_part = match separator {
Some(index) => &text[..index],
None => text,
};
let (year, month, day) = parse_iso_date(date_part)?;
let Some(separator) = separator else {
if !valid_iso_date(year, month, day)
|| (enforce_date_time_range && !valid_plain_date_time_range(year, month, day, 0.0))
{
return Err("invalid ISO date".into());
}
return Ok((year, month, day, 0.0));
};
let time_and_suffix = &text[separator + 1..];
let time_end = time_and_suffix
.char_indices()
.find_map(|(index, ch)| matches!(ch, '+' | '-').then_some(index))
.unwrap_or(time_and_suffix.len());
if time_end < time_and_suffix.len() && !strip_iso_offset(&time_and_suffix[time_end..])?.is_empty() {
return Err("invalid ISO offset".into());
}
let time = &time_and_suffix[..time_end];
if time.is_empty() {
return Err("missing ISO time".into());
}
let (clock, fraction) = match time.find(['.', ',']) {
Some(index) => (&time[..index], Some(&time[index + 1..])),
None => (time, None),
};
let fields = if clock.contains(':') {
clock.split(':').collect::<Vec<_>>()
} else {
if clock.len() % 2 != 0 || clock.len() > 6 {
return Err("invalid ISO time".into());
}
clock
.as_bytes()
.chunks(2)
.map(std::str::from_utf8)
.collect::<Result<Vec<_>, _>>()
.map_err(|_| "invalid ISO time".to_string())?
};
if fields.is_empty() || fields.len() > 3 || fields.iter().any(|field| field.len() != 2) {
return Err("invalid ISO time".into());
}
if fraction.is_some() && fields.len() < 3 {
return Err("fractional hours and minutes are not valid".into());
}
let parse_field = |index: usize| -> Result<u32, String> {
fields
.get(index)
.map_or(Ok(0), |field| field.parse().map_err(|_| "invalid ISO time".to_string()))
};
let hour = parse_field(0)?;
let minute = parse_field(1)?;
let mut second = parse_field(2)?;
if second == 60 {
second = 59;
}
let subsecond = match fraction {
Some(digits) if !digits.is_empty() && digits.len() <= 9 && digits.bytes().all(|byte| byte.is_ascii_digit()) => {
let mut padded = digits.to_owned();
padded.extend(std::iter::repeat('0').take(9 - digits.len()));
padded.parse::<u32>().map_err(|_| "invalid ISO fraction".to_string())?
}
Some(_) => return Err("invalid ISO fraction".into()),
None => 0,
};
let millisecond = subsecond / 1_000_000;
let microsecond = subsecond / 1_000 % 1_000;
let nanosecond = subsecond % 1_000;
if !valid_plain_time(hour, minute, second, millisecond, microsecond, nanosecond) {
return Err("invalid ISO time".into());
}
let total_ns = hour as f64 * 3_600_000_000_000.0
+ minute as f64 * 60_000_000_000.0
+ second as f64 * 1_000_000_000.0
+ subsecond as f64;
if !valid_iso_date(year, month, day)
|| (enforce_date_time_range && !valid_plain_date_time_range(year, month, day, total_ns))
{
return Err("invalid ISO date".into());
}
Ok((year, month, day, total_ns))
}
/// 规范 BuiltinCalendarID 全集(18 项)。匹配用 eq_ignore_ascii_case(ASCII 折叠,
/// 禁 to_lowercase:U+0130 点 I 会被 Unicode 折叠成 i+组合符,误判为 iso8601)。
pub(crate) const CALENDAR_ID_WHITELIST: [&str; 18] = [
"buddhist",
"chinese",
"coptic",
"dangi",
"ethioaa",
"ethiopic",
"gregory",
"hebrew",
"indian",
"islamic",
"islamic-civil",
"islamic-rcy",
"islamic-tbla",
"islamic-umalqura",
"iso8601",
"japanese",
"persian",
"roc",
];
/// 严格白名单匹配:命中返回规范小写形式,未命中返回 None。
pub(crate) fn is_builtin_calendar_id(value: &str) -> Option<&'static str> {
CALENDAR_ID_WHITELIST.iter().find(|id| value.eq_ignore_ascii_case(id)).copied()
}
/// 严格日历 ID 解析:只走 18 项白名单,拒绝 ISO 串(含注解/compact/extended/空串)。
/// 供构造器日历参数与注解值判定使用。
pub(crate) fn parse_temporal_calendar_id_strict(input: &str) -> Result<String, String> {
let trimmed = input.trim();
is_builtin_calendar_id(trimmed)
.map(str::to_string)
.ok_or_else(|| "invalid calendar".to_string())
}
/// ParseTemporalCalendarString:日历标识符 = 18 项内置日历 ID(ASCII 大小写不敏感)
/// 或合法 ISO 日期(-时间)字符串(含部分日期 YYYY-MM / MM-DD,可选时间、偏移、注解)。
/// 返回规范化日历 ID:白名单命中返回规范小写,ISO 串路径恒为 "iso8601"。
pub(crate) fn parse_temporal_calendar_string(input: &str) -> Result<String, String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err("invalid calendar".into());
}
if let Some(id) = is_builtin_calendar_id(trimmed) {
return Ok(id.to_string());
}
if trimmed.contains('\u{2212}') {
return Err("variant minus sign is not valid for calendar".into());
}
// 完整日期时间字符串(含注解校验),例如 2020-01-01T00:00:00.000000000[u-ca=iso8601]
if parse_plain_date_time_string(trimmed).is_ok() {
return Ok("iso8601".to_string());
}
// 纯时间字符串(TemporalTimeString),例如 "15:23" / "T0030" / "152330.1-08"。
if parse_plain_time_string(trimmed).is_some() {
return Ok("iso8601".to_string());
}
// 部分日期:YYYY-MM 或 MM-DD(可带注解)
let text = trimmed.to_owned();
let annotation_start = text.find('[').unwrap_or(text.len());
validate_temporal_annotation_suffix(&text[annotation_start..])?;
let text = &text[..annotation_start];
if text.contains(['Z', 'z']) {
return Err("UTC designator is not valid for calendar".into());
}
parse_partial_calendar_date(text)?;
Ok("iso8601".to_string())
}
/// 部分 ISO 日期(无时间):YYYY[-MM[-DD]] 或 MM-DD;校验月份/日期基本范围并拒绝负零年。
pub(crate) fn parse_partial_calendar_date(input: &str) -> Result<(), String> {
let bytes = input.as_bytes();
let mut i = 0usize;
let signed = i < bytes.len() && matches!(bytes[i], b'+' | b'-');
let negative = signed && bytes[i] == b'-';
if signed {
i += 1;
}
let start = i;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
let digits = &input[start..i];
let rest = &input[i..];
if digits.is_empty() {
return Err("invalid calendar date".into());
}
// MM-DD 形式(无年份)
if digits.len() == 2 {
if !rest.starts_with('-') {
return Err("invalid calendar date".into());
}
let day_part = &rest[1..];
if day_part.len() != 2 || !day_part.bytes().all(|byte| byte.is_ascii_digit()) {
return Err("invalid calendar day".into());
}
let month: u32 = digits.parse().map_err(|_| "invalid calendar month".to_string())?;
let day: u32 = day_part.parse().map_err(|_| "invalid calendar day".to_string())?;
if month == 0 || month > 12 || day == 0 || day > 31 {
return Err("invalid calendar date".into());
}
return Ok(());
}
if !(4..=6).contains(&digits.len()) {
return Err("invalid calendar year".into());
}
if negative && digits.bytes().all(|byte| byte == b'0') {
return Err("invalid calendar negative zero year".into());
}
let mut rest2 = rest;
let mut month: Option<u32> = None;
let mut day: Option<u32> = None;
if rest2.starts_with('-') {
rest2 = &rest2[1..];
if rest2.len() < 2 || !rest2.as_bytes()[..2].iter().all(u8::is_ascii_digit) {
return Err("invalid calendar month".into());
}
month = Some(rest2[..2].parse().map_err(|_| "invalid calendar month".to_string())?);
rest2 = &rest2[2..];
if rest2.starts_with('-') {
rest2 = &rest2[1..];
if rest2.len() != 2 || !rest2.bytes().all(|byte| byte.is_ascii_digit()) {
return Err("invalid calendar day".into());
}
day = Some(rest2.parse().map_err(|_| "invalid calendar day".to_string())?);
rest2 = "";
}
}
if !rest2.is_empty() {
return Err("invalid trailing calendar content".into());
}
if month.is_some_and(|m| m == 0 || m > 12) || day.is_some_and(|d| d == 0 || d > 31) {
return Err("invalid calendar date".into());
}
Ok(())
}
/// ToTemporalCalendar(宽松版,property bag 路径用):undefined → None;
/// string → 宽松解析(白名单 ID 或 ISO 串);Temporal 实例 → 读日历槽(不触发属性 getter);
/// 其他类型 → TypeError。
pub(crate) fn temporal_calendar_id<H: VmHost>(vm: &mut H, value: JsValue) -> Result<Option<String>, JsValue> {
if value.is_undefined() {
return Ok(None);
}
if value.is_string() {
return parse_temporal_calendar_string(&to_string(value))
.map(Some)
.map_err(|_| crate::error::create_range_error(vm, "invalid calendar"));
}
if value.is_object() {
let ptr = value.as_js_object_ptr();
if !ptr.is_null() {
let obj = unsafe { &*ptr };
if obj.is_plain_date_obj() {
return Ok(Some(get_calendar_id(obj, 3)));
}
if obj.is_plain_date_time_obj() {
return Ok(Some(get_calendar_id(obj, 4)));
}
if obj.is_zoned_date_time_obj() {
return Ok(Some(get_calendar_id(obj, 2)));
}
if obj.is_plain_month_day_obj() || obj.is_plain_year_month_obj() {
return Ok(Some(get_calendar_id(obj, 3)));
}
}
}
Err(crate::error::create_type_error(vm, "invalid calendar"))
}
/// ToTemporalCalendar 严格版(构造器日历参数用):字符串只走 18 项白名单,拒绝 ISO 串;
/// undefined / 其他分支与宽松版一致。
pub(crate) fn temporal_calendar_id_strict<H: VmHost>(vm: &mut H, value: JsValue) -> Result<Option<String>, JsValue> {
if value.is_string() {
return parse_temporal_calendar_id_strict(&to_string(value))
.map(Some)
.map_err(|_| crate::error::create_range_error(vm, "invalid calendar"));
}
temporal_calendar_id(vm, value)
}
/// 将 ZonedDateTime 按时区偏移转换为本地 PlainDateTime 分量。
pub(crate) fn zoned_date_time_plain_parts<H: VmHost>(
vm: &mut H, obj: &JsObject,
) -> Result<(i32, u32, u32, f64), JsValue> {
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));
let Some(offset_minutes) = instant_time_zone_offset(&time_zone_id) else {
return Err(crate::error::create_range_error(vm, "invalid time zone"));
};
const DAY_NS: i128 = 86_400_000_000_000;
let local_ns = epoch_ns + i128::from(offset_minutes) * 60_000_000_000;
let days = local_ns.div_euclid(DAY_NS);
let time_ns = local_ns.rem_euclid(DAY_NS) as f64;
let (year, month, day) = civil_from_days(days);
if !valid_plain_date_time_range(year as i32, month as u32, day as u32, time_ns) {
return Err(crate::error::create_range_error(vm, "invalid date-time"));
}
Ok((year as i32, month as u32, day as u32, time_ns))
}
/// 本地墙钟分量 + 时区偏移(分钟)→ 纪元纳秒(zoned_date_time_plain_parts 的逆)。
///
/// # 边界与前提
/// - (year, month, day, time_ns) 须已通过 valid_iso_date / valid_plain_time 校验(调用方保证)。
/// - 仅做 checked 溢出防护,Instant 范围校验由调用方按需执行。
pub(crate) 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)?
.checked_add(time_ns as i128)?
.checked_sub(i128::from(offset_minutes) * 60_000_000_000)
}
-603
View File
@@ -10,41 +10,6 @@ mod common;
use common::*;
fn parse_digits(bytes: &[u8], cursor: &mut usize, count: usize) -> Option<i128> {
let end = cursor.checked_add(count)?;
let digits = bytes.get(*cursor..end)?;
if !digits.iter().all(u8::is_ascii_digit) {
return None;
}
*cursor = end;
digits
.iter()
.try_fold(0_i128, |value, digit| value.checked_mul(10)?.checked_add((digit - b'0') as i128))
}
fn is_leap_year(year: i128) -> bool {
year.rem_euclid(4) == 0 && (year.rem_euclid(100) != 0 || year.rem_euclid(400) == 0)
}
fn days_in_month(year: i128, month: i128) -> Option<i128> {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => Some(31),
4 | 6 | 9 | 11 => Some(30),
2 => Some(if is_leap_year(year) { 29 } else { 28 }),
_ => None,
}
}
fn days_from_civil(mut year: i128, month: i128, day: i128) -> i128 {
year -= i128::from(month <= 2);
let era = year.div_euclid(400);
let year_of_era = year - era * 400;
let shifted_month = month + if month > 2 { -3 } else { 9 };
let day_of_year = (153 * shifted_month + 2) / 5 + day - 1;
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
era * 146_097 + day_of_era - 719_468
}
fn instant_string_without_annotations(input: &str) -> Option<&str> {
let Some(first_annotation) = input.find('[') else {
return Some(input);
@@ -796,140 +761,10 @@ pub fn instant_since<H: VmHost>(vm: &mut H, args: &[u8]) -> NativeResult {
instant_difference(vm, args, true)
}
enum FractionalSecondDigitsInput {
Auto,
Number(f64),
String(String),
}
fn parse_fractional_second_digits<H: VmHost>(
vm: &mut H, input: FractionalSecondDigitsInput,
) -> Result<Option<usize>, JsValue> {
match input {
FractionalSecondDigitsInput::Auto => Ok(None),
FractionalSecondDigitsInput::String(value) if value == "auto" => Ok(None),
FractionalSecondDigitsInput::String(_) => {
Err(crate::error::create_range_error(vm, "invalid fractionalSecondDigits"))
}
FractionalSecondDigitsInput::Number(value) => {
let digits = value.floor();
if !digits.is_finite() || !(0.0..=9.0).contains(&digits) {
return Err(crate::error::create_range_error(vm, "invalid fractionalSecondDigits"));
}
Ok(Some(digits as usize))
}
}
}
fn parse_offset_minutes(value: &str) -> Option<i32> {
let bytes = value.as_bytes();
if bytes.len() != 6 || !matches!(bytes[0], b'+' | b'-') || bytes[3] != b':' {
return None;
}
if !bytes[1..3].iter().all(u8::is_ascii_digit) || !bytes[4..6].iter().all(u8::is_ascii_digit) {
return None;
}
let hours = i32::from(bytes[1] - b'0') * 10 + i32::from(bytes[2] - b'0');
let minutes = i32::from(bytes[4] - b'0') * 10 + i32::from(bytes[5] - b'0');
if hours > 23 || minutes > 59 {
return None;
}
let magnitude = hours * 60 + minutes;
Some(if bytes[0] == b'-' { -magnitude } else { magnitude })
}
fn canonical_time_zone(value: &str) -> Option<(String, i32)> {
let input = value.trim();
if input.eq_ignore_ascii_case("UTC") || input.eq_ignore_ascii_case("Z") {
return Some(("UTC".to_string(), 0));
}
if let Some(offset) = parse_offset_minutes(input) {
return Some((input.to_string(), offset));
}
// ±HH 基本偏移("+01" / "-05"):3 字符,hour ≤ 23,分钟恒 0。仅放宽构造器时区接受面。
if input.len() == 3
&& matches!(input.as_bytes()[0], b'+' | b'-')
&& input.as_bytes()[1..3].iter().all(u8::is_ascii_digit)
{
let hour = (input.as_bytes()[1] - b'0') as i32 * 10 + (input.as_bytes()[2] - b'0') as i32;
if hour <= 23 {
let sign = if input.as_bytes()[0] == b'-' { -1 } else { 1 };
return Some((input.to_string(), sign * hour * 60));
}
return None;
}
// ±HHMM 基本偏移("+0000" / "-0530"):5 字符,无冒号,分钟位可选非零。4 位无冒号归一形式。
if input.len() == 5
&& matches!(input.as_bytes()[0], b'+' | b'-')
&& input.as_bytes()[1..5].iter().all(u8::is_ascii_digit)
{
let hour = (input.as_bytes()[1] - b'0') as i32 * 10 + (input.as_bytes()[2] - b'0') as i32;
let minute = (input.as_bytes()[3] - b'0') as i32 * 10 + (input.as_bytes()[4] - b'0') as i32;
if hour <= 23 && minute <= 59 {
let sign = if input.as_bytes()[0] == b'-' { -1 } else { 1 };
return Some((input.to_string(), sign * (hour * 60 + minute)));
}
return None;
}
if input.starts_with("-000000") {
return None;
}
// 带 annotation 的日期时间以最后一个方括号内标识符为准。
if let Some(open) = input.rfind('[') {
if !input.ends_with(']') || open + 2 > input.len() {
return None;
}
let annotation = &input[open + 1..input.len() - 1];
if annotation.eq_ignore_ascii_case("UTC") {
return Some(("UTC".to_string(), 0));
}
return parse_offset_minutes(annotation).map(|offset| (annotation.to_string(), offset));
}
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));
}
let offset_start = time
.char_indices()
.rev()
.find_map(|(index, ch)| matches!(ch, '+' | '-').then_some(index))?;
let offset_id = &time[offset_start..];
parse_offset_minutes(offset_id).map(|offset| (offset_id.to_string(), offset))
}
fn instant_time_zone_offset(value: &str) -> Option<i32> {
canonical_time_zone(value).map(|(_, offset)| offset)
}
fn civil_from_days(days: i128) -> (i128, i128, i128) {
let shifted = days + 719_468;
let era = shifted.div_euclid(146_097);
let day_of_era = shifted - era * 146_097;
let year_of_era = (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let mut year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_prime = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
year += i128::from(month <= 2);
(year, month, day)
}
fn format_iso_year(year: i128) -> String {
if (0..=9_999).contains(&year) {
format!("{year:04}")
} else if year >= 0 {
format!("+{year:06}")
} else {
format!("-{:06}", -year)
}
}
fn format_instant_iso(
epoch_ns: i128, offset_minutes: Option<i32>, include_seconds: bool, fractional_digits: Option<usize>,
) -> Option<String> {
@@ -5057,436 +4892,10 @@ pub fn plain_date_time_constructor<H: VmHost>(vm: &mut H, args: &[u8]) -> Native
)
}
fn validate_temporal_annotation_suffix(mut suffix: &str) -> Result<(), String> {
let mut calendar_seen = false;
let mut saw_critical_calendar = false;
let mut time_zone_seen = false;
while !suffix.is_empty() {
if !suffix.starts_with('[') {
return Err("invalid annotation suffix".into());
}
let Some(end) = suffix.find(']') else {
return Err("unterminated annotation".into());
};
if end == 1 {
return Err("empty annotation".into());
}
let annotation = &suffix[1..end];
let (critical, body) = annotation.strip_prefix('!').map_or((false, annotation), |body| (true, body));
if let Some((key, value)) = body.split_once('=') {
if key.bytes().any(|byte| byte.is_ascii_uppercase()) {
return Err("annotation keys must be lowercase".into());
}
if key == "u-ca" {
if calendar_seen {
if critical || saw_critical_calendar {
return Err("invalid calendar annotation".into());
}
} else if is_builtin_calendar_id(value).is_none() {
return Err("invalid calendar annotation".into());
} else {
calendar_seen = true;
saw_critical_calendar = critical;
}
} else if critical {
return Err("unknown critical annotation".into());
}
} else {
if time_zone_seen {
return Err("invalid time-zone annotation".into());
}
time_zone_seen = true;
}
suffix = &suffix[end + 1..];
}
Ok(())
}
/// 剥离 ISO 偏移(±HH、±HHMM、±HH:MM、±HHMMSS、±HH:MM:SS,秒可带小数),返回剩余部分。
fn strip_iso_offset(input: &str) -> Result<&str, String> {
let bytes = input.as_bytes();
if bytes.len() < 3 || !matches!(bytes[0], b'+' | b'-') {
return Err("invalid ISO offset".into());
}
let colon = bytes.len() > 3 && bytes[3] == b':';
let mut i = 1usize;
let take_digits = |count: usize, cursor: &mut usize| -> Result<(), String> {
if *cursor + count > bytes.len() || !bytes[*cursor..*cursor + count].iter().all(u8::is_ascii_digit) {
return Err("invalid ISO offset".into());
}
*cursor += count;
Ok(())
};
take_digits(2, &mut i)?;
if i < bytes.len() {
if colon {
if bytes[i] != b':' {
return Err("invalid ISO offset".into());
}
i += 1;
}
if i + 2 <= bytes.len() && bytes[i..i + 2].iter().all(u8::is_ascii_digit) {
i += 2;
if i < bytes.len() {
if colon {
if bytes[i] != b':' {
return Err("invalid ISO offset".into());
}
i += 1;
}
if i + 2 <= bytes.len() && bytes[i..i + 2].iter().all(u8::is_ascii_digit) {
i += 2;
if i < bytes.len() && matches!(bytes[i], b'.' | b',') {
i += 1;
let start = i;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i == start {
return Err("invalid ISO offset fraction".into());
}
if i - start > 9 {
return Err("invalid ISO offset fraction".into());
}
}
}
}
}
}
let digits: String = input[1..i].chars().filter(|ch| ch.is_ascii_digit()).take(6).collect();
if digits.len() < 2 {
return Err("invalid ISO offset".into());
}
let hour: u32 = digits[..2].parse().map_err(|_| "invalid ISO offset".to_string())?;
let minute: u32 = if digits.len() >= 4 {
digits[2..4].parse().map_err(|_| "invalid ISO offset".to_string())?
} else {
0
};
let second: u32 = if digits.len() >= 6 {
digits[4..6].parse().map_err(|_| "invalid ISO offset".to_string())?
} else {
0
};
if hour > 23 || minute > 59 || second > 59 {
return Err("invalid ISO offset".into());
}
Ok(&input[i..])
}
fn parse_plain_date_time_string(input: &str) -> Result<(i32, u32, u32, f64), String> {
parse_temporal_string_impl(input, true)
}
/// ParseTemporalDateString:PlainDate 字符串(时间部分可选且被忽略,仅按日期做范围校验)。
fn parse_plain_date_string(input: &str) -> Result<(i32, u32, u32), String> {
parse_temporal_string_impl(input, false).map(|(year, month, day, _)| (year, month, day))
}
fn parse_temporal_string_impl(input: &str, enforce_date_time_range: bool) -> Result<(i32, u32, u32, f64), String> {
let trimmed = input.trim();
if trimmed.contains('\u{2212}') {
return Err("variant minus sign is not valid for PlainDateTime".into());
}
let text = trimmed.to_owned();
let annotation_start = text.find('[').unwrap_or(text.len());
validate_temporal_annotation_suffix(&text[annotation_start..])?;
let text = &text[..annotation_start];
if text.contains('Z') || text.contains('z') {
return Err("UTC designator is not valid for PlainDateTime".into());
}
let separator = text.find(['T', 't', ' ']);
let date_part = match separator {
Some(index) => &text[..index],
None => text,
};
let (year, month, day) = parse_iso_date(date_part)?;
let Some(separator) = separator else {
if !valid_iso_date(year, month, day)
|| (enforce_date_time_range && !valid_plain_date_time_range(year, month, day, 0.0))
{
return Err("invalid ISO date".into());
}
return Ok((year, month, day, 0.0));
};
let time_and_suffix = &text[separator + 1..];
let time_end = time_and_suffix
.char_indices()
.find_map(|(index, ch)| matches!(ch, '+' | '-').then_some(index))
.unwrap_or(time_and_suffix.len());
if time_end < time_and_suffix.len() && !strip_iso_offset(&time_and_suffix[time_end..])?.is_empty() {
return Err("invalid ISO offset".into());
}
let time = &time_and_suffix[..time_end];
if time.is_empty() {
return Err("missing ISO time".into());
}
let (clock, fraction) = match time.find(['.', ',']) {
Some(index) => (&time[..index], Some(&time[index + 1..])),
None => (time, None),
};
let fields = if clock.contains(':') {
clock.split(':').collect::<Vec<_>>()
} else {
if clock.len() % 2 != 0 || clock.len() > 6 {
return Err("invalid ISO time".into());
}
clock
.as_bytes()
.chunks(2)
.map(std::str::from_utf8)
.collect::<Result<Vec<_>, _>>()
.map_err(|_| "invalid ISO time".to_string())?
};
if fields.is_empty() || fields.len() > 3 || fields.iter().any(|field| field.len() != 2) {
return Err("invalid ISO time".into());
}
if fraction.is_some() && fields.len() < 3 {
return Err("fractional hours and minutes are not valid".into());
}
let parse_field = |index: usize| -> Result<u32, String> {
fields
.get(index)
.map_or(Ok(0), |field| field.parse().map_err(|_| "invalid ISO time".to_string()))
};
let hour = parse_field(0)?;
let minute = parse_field(1)?;
let mut second = parse_field(2)?;
if second == 60 {
second = 59;
}
let subsecond = match fraction {
Some(digits) if !digits.is_empty() && digits.len() <= 9 && digits.bytes().all(|byte| byte.is_ascii_digit()) => {
let mut padded = digits.to_owned();
padded.extend(std::iter::repeat('0').take(9 - digits.len()));
padded.parse::<u32>().map_err(|_| "invalid ISO fraction".to_string())?
}
Some(_) => return Err("invalid ISO fraction".into()),
None => 0,
};
let millisecond = subsecond / 1_000_000;
let microsecond = subsecond / 1_000 % 1_000;
let nanosecond = subsecond % 1_000;
if !valid_plain_time(hour, minute, second, millisecond, microsecond, nanosecond) {
return Err("invalid ISO time".into());
}
let total_ns = hour as f64 * 3_600_000_000_000.0
+ minute as f64 * 60_000_000_000.0
+ second as f64 * 1_000_000_000.0
+ subsecond as f64;
if !valid_iso_date(year, month, day)
|| (enforce_date_time_range && !valid_plain_date_time_range(year, month, day, total_ns))
{
return Err("invalid ISO date".into());
}
Ok((year, month, day, total_ns))
}
/// 规范 BuiltinCalendarID 全集(18 项)。匹配用 eq_ignore_ascii_case(ASCII 折叠,
/// 禁 to_lowercase:U+0130 点 I 会被 Unicode 折叠成 i+组合符,误判为 iso8601)。
const CALENDAR_ID_WHITELIST: [&str; 18] = [
"buddhist",
"chinese",
"coptic",
"dangi",
"ethioaa",
"ethiopic",
"gregory",
"hebrew",
"indian",
"islamic",
"islamic-civil",
"islamic-rcy",
"islamic-tbla",
"islamic-umalqura",
"iso8601",
"japanese",
"persian",
"roc",
];
/// 严格白名单匹配:命中返回规范小写形式,未命中返回 None。
fn is_builtin_calendar_id(value: &str) -> Option<&'static str> {
CALENDAR_ID_WHITELIST.iter().find(|id| value.eq_ignore_ascii_case(id)).copied()
}
/// 严格日历 ID 解析:只走 18 项白名单,拒绝 ISO 串(含注解/compact/extended/空串)。
/// 供构造器日历参数与注解值判定使用。
fn parse_temporal_calendar_id_strict(input: &str) -> Result<String, String> {
let trimmed = input.trim();
is_builtin_calendar_id(trimmed)
.map(str::to_string)
.ok_or_else(|| "invalid calendar".to_string())
}
/// ParseTemporalCalendarString:日历标识符 = 18 项内置日历 ID(ASCII 大小写不敏感)
/// 或合法 ISO 日期(-时间)字符串(含部分日期 YYYY-MM / MM-DD,可选时间、偏移、注解)。
/// 返回规范化日历 ID:白名单命中返回规范小写,ISO 串路径恒为 "iso8601"。
fn parse_temporal_calendar_string(input: &str) -> Result<String, String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err("invalid calendar".into());
}
if let Some(id) = is_builtin_calendar_id(trimmed) {
return Ok(id.to_string());
}
if trimmed.contains('\u{2212}') {
return Err("variant minus sign is not valid for calendar".into());
}
// 完整日期时间字符串(含注解校验),例如 2020-01-01T00:00:00.000000000[u-ca=iso8601]
if parse_plain_date_time_string(trimmed).is_ok() {
return Ok("iso8601".to_string());
}
// 纯时间字符串(TemporalTimeString),例如 "15:23" / "T0030" / "152330.1-08"。
if parse_plain_time_string(trimmed).is_some() {
return Ok("iso8601".to_string());
}
// 部分日期:YYYY-MM 或 MM-DD(可带注解)
let text = trimmed.to_owned();
let annotation_start = text.find('[').unwrap_or(text.len());
validate_temporal_annotation_suffix(&text[annotation_start..])?;
let text = &text[..annotation_start];
if text.contains(['Z', 'z']) {
return Err("UTC designator is not valid for calendar".into());
}
parse_partial_calendar_date(text)?;
Ok("iso8601".to_string())
}
/// 部分 ISO 日期(无时间):YYYY[-MM[-DD]] 或 MM-DD;校验月份/日期基本范围并拒绝负零年。
fn parse_partial_calendar_date(input: &str) -> Result<(), String> {
let bytes = input.as_bytes();
let mut i = 0usize;
let signed = i < bytes.len() && matches!(bytes[i], b'+' | b'-');
let negative = signed && bytes[i] == b'-';
if signed {
i += 1;
}
let start = i;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
let digits = &input[start..i];
let rest = &input[i..];
if digits.is_empty() {
return Err("invalid calendar date".into());
}
// MM-DD 形式(无年份)
if digits.len() == 2 {
if !rest.starts_with('-') {
return Err("invalid calendar date".into());
}
let day_part = &rest[1..];
if day_part.len() != 2 || !day_part.bytes().all(|byte| byte.is_ascii_digit()) {
return Err("invalid calendar day".into());
}
let month: u32 = digits.parse().map_err(|_| "invalid calendar month".to_string())?;
let day: u32 = day_part.parse().map_err(|_| "invalid calendar day".to_string())?;
if month == 0 || month > 12 || day == 0 || day > 31 {
return Err("invalid calendar date".into());
}
return Ok(());
}
if !(4..=6).contains(&digits.len()) {
return Err("invalid calendar year".into());
}
if negative && digits.bytes().all(|byte| byte == b'0') {
return Err("invalid calendar negative zero year".into());
}
let mut rest2 = rest;
let mut month: Option<u32> = None;
let mut day: Option<u32> = None;
if rest2.starts_with('-') {
rest2 = &rest2[1..];
if rest2.len() < 2 || !rest2.as_bytes()[..2].iter().all(u8::is_ascii_digit) {
return Err("invalid calendar month".into());
}
month = Some(rest2[..2].parse().map_err(|_| "invalid calendar month".to_string())?);
rest2 = &rest2[2..];
if rest2.starts_with('-') {
rest2 = &rest2[1..];
if rest2.len() != 2 || !rest2.bytes().all(|byte| byte.is_ascii_digit()) {
return Err("invalid calendar day".into());
}
day = Some(rest2.parse().map_err(|_| "invalid calendar day".to_string())?);
rest2 = "";
}
}
if !rest2.is_empty() {
return Err("invalid trailing calendar content".into());
}
if month.is_some_and(|m| m == 0 || m > 12) || day.is_some_and(|d| d == 0 || d > 31) {
return Err("invalid calendar date".into());
}
Ok(())
}
/// ToTemporalCalendar(宽松版,property bag 路径用):undefined → None;
/// string → 宽松解析(白名单 ID 或 ISO 串);Temporal 实例 → 读日历槽(不触发属性 getter);
/// 其他类型 → TypeError。
fn temporal_calendar_id<H: VmHost>(vm: &mut H, value: JsValue) -> Result<Option<String>, JsValue> {
if value.is_undefined() {
return Ok(None);
}
if value.is_string() {
return parse_temporal_calendar_string(&to_string(value))
.map(Some)
.map_err(|_| crate::error::create_range_error(vm, "invalid calendar"));
}
if value.is_object() {
let ptr = value.as_js_object_ptr();
if !ptr.is_null() {
let obj = unsafe { &*ptr };
if obj.is_plain_date_obj() {
return Ok(Some(get_calendar_id(obj, 3)));
}
if obj.is_plain_date_time_obj() {
return Ok(Some(get_calendar_id(obj, 4)));
}
if obj.is_zoned_date_time_obj() {
return Ok(Some(get_calendar_id(obj, 2)));
}
if obj.is_plain_month_day_obj() || obj.is_plain_year_month_obj() {
return Ok(Some(get_calendar_id(obj, 3)));
}
}
}
Err(crate::error::create_type_error(vm, "invalid calendar"))
}
/// ToTemporalCalendar 严格版(构造器日历参数用):字符串只走 18 项白名单,拒绝 ISO 串;
/// undefined / 其他分支与宽松版一致。
fn temporal_calendar_id_strict<H: VmHost>(vm: &mut H, value: JsValue) -> Result<Option<String>, JsValue> {
if value.is_string() {
return parse_temporal_calendar_id_strict(&to_string(value))
.map(Some)
.map_err(|_| crate::error::create_range_error(vm, "invalid calendar"));
}
temporal_calendar_id(vm, value)
}
/// 将 ZonedDateTime 按时区偏移转换为本地 PlainDateTime 分量。
fn zoned_date_time_plain_parts<H: VmHost>(vm: &mut H, obj: &JsObject) -> Result<(i32, u32, u32, f64), JsValue> {
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));
let Some(offset_minutes) = instant_time_zone_offset(&time_zone_id) else {
return Err(crate::error::create_range_error(vm, "invalid time zone"));
};
const DAY_NS: i128 = 86_400_000_000_000;
let local_ns = epoch_ns + i128::from(offset_minutes) * 60_000_000_000;
let days = local_ns.div_euclid(DAY_NS);
let time_ns = local_ns.rem_euclid(DAY_NS) as f64;
let (year, month, day) = civil_from_days(days);
if !valid_plain_date_time_range(year as i32, month as u32, day as u32, time_ns) {
return Err(crate::error::create_range_error(vm, "invalid date-time"));
}
Ok((year as i32, month as u32, day as u32, time_ns))
}
/// 当地午夜纪元纳秒(含 Instant 范围校验,spec GetStartOfDay 语义);越界返回 None。
fn start_of_day_epoch_ns(year: i32, month: u32, day: u32, offset_minutes: i32) -> Option<i128> {
start_of_day_epoch_ns_by_days(days_from_civil(i128::from(year), i128::from(month), i128::from(day)), offset_minutes)
@@ -5500,18 +4909,6 @@ fn start_of_day_epoch_ns_by_days(days: i128, offset_minutes: i32) -> Option<i128
(start.unsigned_abs() <= MAX_INSTANT_NS as u128).then_some(start)
}
/// 本地墙钟分量 + 时区偏移(分钟)→ 纪元纳秒(zoned_date_time_plain_parts 的逆)。
///
/// # 边界与前提
/// - (year, month, day, time_ns) 须已通过 valid_iso_date / valid_plain_time 校验(调用方保证)。
/// - 仅做 checked 溢出防护,Instant 范围校验由调用方按需执行。
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)?
.checked_add(time_ns as i128)?
.checked_sub(i128::from(offset_minutes) * 60_000_000_000)
}
fn plain_date_time_like_parts<H: VmHost>(
vm: &mut H, value: JsValue, constrain: bool,
) -> Result<(i32, u32, u32, f64, Option<String>), JsValue> {