mirror of
https://github.com/pjh456/OxideJS.git
synced 2026-10-23 18:21:13 +08:00
refactor: temporal 字符串解析与算历层并入 common
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user