diff --git a/crates/oxide_builtins/src/temporal/common.rs b/crates/oxide_builtins/src/temporal/common.rs index 9bdafca4..2427093c 100644 --- a/crates/oxide_builtins/src/temporal/common.rs +++ b/crates/oxide_builtins/src/temporal/common.rs @@ -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(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 { + 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 { + 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( + vm: &mut H, input: FractionalSecondDigitsInput, +) -> Result, 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 { + 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::>() + } 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::, _>>() + .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 { + 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::().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 { + 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 { + 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 = None; + let mut day: Option = 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(vm: &mut H, value: JsValue) -> Result, 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(vm: &mut H, value: JsValue) -> Result, 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( + 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 { + 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) +} diff --git a/crates/oxide_builtins/src/temporal/mod.rs b/crates/oxide_builtins/src/temporal/mod.rs index 2dc766f7..778baae7 100644 --- a/crates/oxide_builtins/src/temporal/mod.rs +++ b/crates/oxide_builtins/src/temporal/mod.rs @@ -10,41 +10,6 @@ mod common; use common::*; -fn parse_digits(bytes: &[u8], cursor: &mut usize, count: usize) -> Option { - 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 { - 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(vm: &mut H, args: &[u8]) -> NativeResult { instant_difference(vm, args, true) } -enum FractionalSecondDigitsInput { - Auto, - Number(f64), - String(String), -} - -fn parse_fractional_second_digits( - vm: &mut H, input: FractionalSecondDigitsInput, -) -> Result, 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 { - 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 { 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, include_seconds: bool, fractional_digits: Option, ) -> Option { @@ -5057,436 +4892,10 @@ pub fn plain_date_time_constructor(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::>() - } 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::, _>>() - .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 { - 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::().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 { - 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 { - 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 = None; - let mut day: Option = 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(vm: &mut H, value: JsValue) -> Result, 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(vm: &mut H, value: JsValue) -> Result, 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(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 { 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 Option { - 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( vm: &mut H, value: JsValue, constrain: bool, ) -> Result<(i32, u32, u32, f64, Option), JsValue> {