From d7a63a0d5443db1b3ded0111668a5422244c7385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=88=B1=E7=89=B9=E6=AF=8F=E7=BE=8A=E9=98=9F?= Date: Tue, 11 Aug 2026 12:38:31 +0800 Subject: [PATCH] feat: implement Temporal duration and plain date differences --- crates/oxide_builtins/src/temporal.rs | 540 ++++++++++++++++-- crates/oxide_kernel/src/builtin.rs | 34 +- crates/oxide_kernel/src/kernel.rs | 10 +- crates/oxide_types/src/object.rs | 15 +- crates/oxide_vm/src/bindings/bind_temporal.rs | 74 ++- .../oxide_vm/tests/builtins_temporal_tests.rs | 59 +- 6 files changed, 664 insertions(+), 68 deletions(-) diff --git a/crates/oxide_builtins/src/temporal.rs b/crates/oxide_builtins/src/temporal.rs index d423d5d..7699c75 100644 --- a/crates/oxide_builtins/src/temporal.rs +++ b/crates/oxide_builtins/src/temporal.rs @@ -1,4 +1,4 @@ -use chrono::{DateTime, Datelike, Days, Months, NaiveDate, SecondsFormat, Utc}; +use chrono::{DateTime, Datelike, Days, NaiveDate, SecondsFormat, Utc}; use oxide_kernel::shape_forge::EMPTY_SHAPE_ID; use oxide_runtime_api::{to_number, to_string, NativeResult, VmHost}; @@ -49,6 +49,13 @@ fn ensure_plain_time(vm: &mut H, obj: &JsObject) -> Result<(), JsValu Ok(()) } +fn ensure_duration(vm: &mut H, obj: &JsObject) -> Result<(), JsValue> { + if !obj.is_duration_obj() { + return Err(crate::error::create_type_error(vm, "called on incompatible receiver")); + } + Ok(()) +} + fn receiver_obj(vm: &mut H, args: &[u8]) -> Result<*mut JsObject, JsValue> { let raw = vm.reg(if args.is_empty() { 0 } else { args[0] }); if !raw.is_object() { @@ -123,11 +130,7 @@ pub fn instant_constructor(vm: &mut H, args: &[u8]) -> NativeResult { "Class constructor Temporal.Instant cannot be invoked without 'new'", )); } - let epoch_ns = if args.len() < 2 { - 0.0 - } else { - to_number(vm.reg(args[1])) - }; + let epoch_ns = if args.len() < 2 { 0.0 } else { to_number(vm.reg(args[1])) }; make_instant(vm, epoch_ns as i128) } @@ -228,8 +231,7 @@ pub fn instant_value_of(vm: &mut H, _args: &[u8]) -> NativeResult { } fn make_plain_date(vm: &mut H, year: i32, month: u32, day: u32) -> NativeResult { - let proto = - JsValue::from_js_object(vm.session().builtin_world().plain_date_proto.as_ptr() as *mut JsObject); + let proto = JsValue::from_js_object(vm.session().builtin_world().plain_date_proto.as_ptr() as *mut JsObject); let mut obj = JsObject::new_empty(EMPTY_SHAPE_ID, proto); obj.type_tag = JsObject::OBJ_TYPE_PLAIN_DATE; obj.set_prop_at(0, JsValue::float(year as f64)); @@ -238,6 +240,326 @@ fn make_plain_date(vm: &mut H, year: i32, month: u32, day: u32) -> Na NativeResult::Ok(JsValue::from_js_object(vm.alloc_object(obj))) } +fn make_duration(vm: &mut H, values: [f64; 10]) -> NativeResult { + let proto = JsValue::from_js_object(vm.session().builtin_world().duration_proto.as_ptr() as *mut JsObject); + let mut obj = JsObject::new_empty(EMPTY_SHAPE_ID, proto); + obj.type_tag = JsObject::OBJ_TYPE_DURATION; + for (index, value) in values.into_iter().enumerate() { + obj.set_prop_at(index, JsValue::float(value)); + } + NativeResult::Ok(JsValue::from_js_object(vm.alloc_object(obj))) +} + +fn parse_duration_string(input: &str) -> Option<[f64; 10]> { + let bytes = input.as_bytes(); + let mut cursor = 0usize; + let negative = match bytes.first().copied() { + Some(b'-') => { + cursor += 1; + true + } + Some(b'+') => { + cursor += 1; + false + } + _ => false, + }; + if bytes.get(cursor) != Some(&b'P') { + return None; + } + cursor += 1; + + let mut values = [0.0; 10]; + let mut in_time = false; + let mut saw_unit = false; + let mut saw_time_unit = false; + let mut last_order = 0usize; + let mut fraction_seen = false; + while cursor < bytes.len() { + if bytes[cursor] == b'T' { + if in_time { + return None; + } + in_time = true; + cursor += 1; + continue; + } + + let number_start = cursor; + while bytes.get(cursor).is_some_and(u8::is_ascii_digit) { + cursor += 1; + } + if cursor == number_start { + return None; + } + let whole = input[number_start..cursor].parse::().ok()?; + if !whole.is_finite() { + return None; + } + + let mut fraction = None; + if matches!(bytes.get(cursor), Some(b'.' | b',')) { + cursor += 1; + let fraction_start = cursor; + while bytes.get(cursor).is_some_and(u8::is_ascii_digit) { + cursor += 1; + } + let digits = cursor - fraction_start; + if digits == 0 || digits > 9 { + return None; + } + let numerator = input[fraction_start..cursor].parse::().ok()?; + fraction = Some((numerator, 10_u64.pow(digits as u32))); + } + + let unit = *bytes.get(cursor)?; + cursor += 1; + let (index, order, unit_nanos) = match (in_time, unit) { + (false, b'Y') => (0, 1, None), + (false, b'M') => (1, 2, None), + (false, b'W') => (2, 3, None), + (false, b'D') => (3, 4, None), + (true, b'H') => (4, 5, Some(3_600_000_000_000_u64)), + (true, b'M') => (5, 6, Some(60_000_000_000_u64)), + (true, b'S') => (6, 7, Some(1_000_000_000_u64)), + _ => return None, + }; + if order <= last_order || (fraction_seen && (whole != 0.0 || fraction.is_some_and(|f| f.0 != 0))) { + return None; + } + last_order = order; + values[index] = whole; + saw_unit = true; + saw_time_unit |= in_time; + + if let Some((numerator, scale)) = fraction { + let unit_nanos = unit_nanos?; + let mut nanos = (numerator as u128 * unit_nanos as u128 / scale as u128) as u64; + if index <= 4 { + values[5] += (nanos / 60_000_000_000) as f64; + nanos %= 60_000_000_000; + } + if index <= 5 { + values[6] += (nanos / 1_000_000_000) as f64; + nanos %= 1_000_000_000; + } + values[7] += (nanos / 1_000_000) as f64; + nanos %= 1_000_000; + values[8] += (nanos / 1_000) as f64; + values[9] += (nanos % 1_000) as f64; + fraction_seen = true; + } + } + if !saw_unit || (in_time && !saw_time_unit) { + return None; + } + if negative { + for value in &mut values { + if *value != 0.0 { + *value = -*value; + } + } + } + Some(values) +} + +fn duration_like_values(vm: &mut H, val: JsValue) -> Result<[f64; 10], JsValue> { + if val.is_string() { + return parse_duration_string(&to_string(val)) + .ok_or_else(|| crate::error::create_range_error(vm, "invalid duration string")); + } + if !val.is_object() { + return Err(crate::error::create_type_error(vm, "cannot convert value to Duration")); + } + let ptr = val.as_js_object_ptr(); + if ptr.is_null() { + return Err(crate::error::create_type_error(vm, "cannot convert value to Duration")); + } + let obj = unsafe { &*ptr }; + let names = [ + "years", + "months", + "weeks", + "days", + "hours", + "minutes", + "seconds", + "milliseconds", + "microseconds", + "nanoseconds", + ]; + let mut values = [0.0; 10]; + for (index, name) in names.iter().enumerate() { + let value = if obj.is_duration_obj() { + get_double_prop(obj, index) + } else { + read_prop_number(vm, obj, val, name) + }; + if value.is_nan() { + continue; + } + if !value.is_finite() || value.fract() != 0.0 { + return Err(crate::error::create_range_error(vm, "invalid duration")); + } + values[index] = value; + } + Ok(values) +} + +/// `Temporal.Duration` 构造器,保存十个整数时长分量。 +pub fn duration_constructor(vm: &mut H, args: &[u8]) -> NativeResult { + let ctor_proto = vm.session().builtin_world().duration_proto.as_ptr(); + if !is_ctor_call(vm, args, ctor_proto) { + return NativeResult::Err(crate::error::create_type_error( + vm, + "Class constructor Temporal.Duration cannot be invoked without 'new'", + )); + } + let mut values = [0.0; 10]; + for (index, value) in values.iter_mut().enumerate() { + if args.len() <= index + 1 { + continue; + } + let number = to_number(vm.reg(args[index + 1])); + if !number.is_finite() || number.fract() != 0.0 { + return NativeResult::Err(crate::error::create_range_error(vm, "invalid duration")); + } + *value = number; + } + make_duration(vm, values) +} + +/// `Temporal.Duration.from(value)`,复制 Duration 或读取同名字段。 +pub fn duration_from(vm: &mut H, args: &[u8]) -> NativeResult { + let val = if args.len() > 1 { vm.reg(args[1]) } else { JsValue::undefined() }; + let values = native_try!(duration_like_values(vm, val)); + make_duration(vm, values) +} + +fn duration_values(obj: &JsObject) -> [f64; 10] { + std::array::from_fn(|index| get_double_prop(obj, index)) +} + +fn format_duration_number(value: f64) -> String { + format!("{}", value as i64) +} + +fn format_duration_seconds(seconds: f64, milliseconds: f64, microseconds: f64, nanoseconds: f64) -> Option { + let subsecond = milliseconds * 1_000_000.0 + microseconds * 1_000.0 + nanoseconds; + if seconds == 0.0 && subsecond == 0.0 { + return None; + } + let negative = seconds < 0.0 || (seconds == 0.0 && subsecond < 0.0); + let whole = seconds.abs() as i64; + let fraction = subsecond.abs() as u64; + let mut result = format!("{}", whole); + if fraction != 0 { + let mut digits = format!("{fraction:09}"); + while digits.ends_with('0') { + digits.pop(); + } + result.push('.'); + result.push_str(&digits); + } + if negative { + result.insert(0, '-'); + } + Some(result) +} + +/// `Temporal.Duration.prototype.toString()` 的 ISO 8601 表示。 +pub fn duration_to_string(vm: &mut H, args: &[u8]) -> NativeResult { + let ptr = native_try!(receiver_obj(vm, args)); + let obj = unsafe { &*ptr }; + native_try!(ensure_duration(vm, obj)); + let mut values = duration_values(obj); + let negative = values.iter().any(|value| *value < 0.0); + for value in &mut values { + *value = value.abs(); + } + let mut output = String::from(if negative { "-P" } else { "P" }); + let date_units = [(0, "Y"), (1, "M"), (2, "W"), (3, "D")]; + let mut has_date = false; + for (index, suffix) in date_units { + if values[index] != 0.0 { + has_date = true; + output.push_str(&format_duration_number(values[index])); + output.push_str(suffix); + } + } + let time_units = [(4, "H"), (5, "M")]; + let has_time = values[4..].iter().any(|value| *value != 0.0); + if has_time || !has_date { + output.push('T'); + for (index, suffix) in time_units { + if values[index] != 0.0 { + output.push_str(&format_duration_number(values[index])); + output.push_str(suffix); + } + } + if let Some(seconds) = format_duration_seconds(values[6], values[7], values[8], values[9]) { + output.push_str(&seconds); + output.push('S'); + } else if output.ends_with('T') { + output.push_str("0S"); + } + } + NativeResult::Ok(vm.new_string(&output)) +} + +/// `Temporal.Duration.prototype.valueOf()` 始终拒绝隐式数值转换。 +pub fn duration_value_of(vm: &mut H, _args: &[u8]) -> NativeResult { + NativeResult::Err(crate::error::create_type_error(vm, "Temporal.Duration has no valueOf")) +} + +/// `Temporal.Duration.prototype.abs()`,返回所有分量的绝对值副本。 +pub fn duration_abs(vm: &mut H, args: &[u8]) -> NativeResult { + let ptr = native_try!(receiver_obj(vm, args)); + let obj = unsafe { &*ptr }; + native_try!(ensure_duration(vm, obj)); + let mut values = duration_values(obj); + for value in &mut values { + *value = value.abs(); + } + make_duration(vm, values) +} + +/// `Temporal.Duration.prototype.negated()`,返回非零分量取反的副本。 +pub fn duration_negated(vm: &mut H, args: &[u8]) -> NativeResult { + let ptr = native_try!(receiver_obj(vm, args)); + let obj = unsafe { &*ptr }; + native_try!(ensure_duration(vm, obj)); + let mut values = duration_values(obj); + for value in &mut values { + if *value != 0.0 { + *value = -*value; + } + } + make_duration(vm, values) +} + +macro_rules! duration_getter { + ($name:ident, $index:expr) => { + pub fn $name(vm: &mut H, args: &[u8]) -> NativeResult { + let ptr = native_try!(receiver_obj(vm, args)); + let obj = unsafe { &*ptr }; + native_try!(ensure_duration(vm, obj)); + NativeResult::Ok(JsValue::float(get_double_prop(obj, $index))) + } + }; +} + +duration_getter!(duration_years, 0); +duration_getter!(duration_months, 1); +duration_getter!(duration_weeks, 2); +duration_getter!(duration_days, 3); +duration_getter!(duration_hours, 4); +duration_getter!(duration_minutes, 5); +duration_getter!(duration_seconds, 6); +duration_getter!(duration_milliseconds, 7); +duration_getter!(duration_microseconds, 8); +duration_getter!(duration_nanoseconds, 9); + /// 校验 ISO 日期分量(month 1-12、day 按月份与闰年)。 fn valid_iso_date(year: i32, month: u32, day: u32) -> bool { NaiveDate::from_ymd_opt(year, month, day).is_some() @@ -399,11 +721,7 @@ fn plain_date_ymd(vm: &mut H, args: &[u8]) -> Result<(f64, f64, f64), let ptr = receiver_obj(vm, args)?; let obj = unsafe { &*ptr }; ensure_plain_date(vm, obj)?; - Ok(( - get_double_prop(obj, 0), - get_double_prop(obj, 1), - get_double_prop(obj, 2), - )) + Ok((get_double_prop(obj, 0), get_double_prop(obj, 1), get_double_prop(obj, 2))) } /// `Temporal.PlainDate.prototype.year` getter。 @@ -435,10 +753,16 @@ pub fn plain_date_to_string(vm: &mut H, args: &[u8]) -> NativeResult NativeResult::Ok(vm.new_string(&format!("{year:04}-{month:02}-{day:02}"))) } -fn make_plain_time(vm: &mut H, hour: u32, minute: u32, second: u32, ms: u32, us: u32, ns: u32) -> NativeResult { - let total_ns = hour as f64 * 3.6e12 + minute as f64 * 6e10 + second as f64 * 1e9 + ms as f64 * 1e6 + us as f64 * 1e3 + ns as f64; - let proto = - JsValue::from_js_object(vm.session().builtin_world().plain_time_proto.as_ptr() as *mut JsObject); +fn make_plain_time( + vm: &mut H, hour: u32, minute: u32, second: u32, ms: u32, us: u32, ns: u32, +) -> NativeResult { + let total_ns = hour as f64 * 3.6e12 + + minute as f64 * 6e10 + + second as f64 * 1e9 + + ms as f64 * 1e6 + + us as f64 * 1e3 + + ns as f64; + let proto = JsValue::from_js_object(vm.session().builtin_world().plain_time_proto.as_ptr() as *mut JsObject); let mut obj = JsObject::new_empty(EMPTY_SHAPE_ID, proto); obj.type_tag = JsObject::OBJ_TYPE_PLAIN_TIME; obj.set_prop_at(0, JsValue::float(total_ns)); @@ -565,7 +889,11 @@ fn object_ymd(vm: &mut H, val: JsValue) -> Option<(i32, u32, u32)> { if !val.is_object() { return None; } - let obj = unsafe { &*val.as_js_object_ptr() }; + let ptr = val.as_js_object_ptr(); + if ptr.is_null() { + return None; + } + let obj = unsafe { &*ptr }; if obj.is_plain_date_obj() { return Some(( get_double_prop(obj, 0) as i32, @@ -582,6 +910,77 @@ fn object_ymd(vm: &mut H, val: JsValue) -> Option<(i32, u32, u32)> { Some((y.trunc() as i32, m.trunc() as u32, d.trunc() as u32)) } +fn date_like_ymd(vm: &mut H, val: JsValue) -> Result<(i32, u32, u32), JsValue> { + let ymd = if val.is_string() { + parse_iso_date(&to_string(val)).map_err(|_| crate::error::create_range_error(vm, "invalid ISO 8601 date"))? + } else { + object_ymd(vm, val).ok_or_else(|| crate::error::create_type_error(vm, "cannot convert to PlainDate"))? + }; + if !valid_iso_date(ymd.0, ymd.1, ymd.2) { + return Err(crate::error::create_range_error(vm, "invalid ISO date")); + } + Ok(ymd) +} + +fn read_prop_text(vm: &mut H, obj: &JsObject, receiver: JsValue, name: &str) -> Option { + let key_val = vm.new_string(name); + let si = vm.property_key_si(key_val); + vm.ordinary_get(obj, si, receiver) + .ok() + .filter(|value| value.is_string()) + .map(to_string) +} + +fn largest_unit(vm: &mut H, args: &[u8]) -> Result { + if args.len() <= 2 { + return Ok("days".to_string()); + } + let options = vm.reg(args[2]); + if options.is_nullish() { + return Ok("days".to_string()); + } + if !options.is_object() { + return Err(crate::error::create_type_error(vm, "options must be an object")); + } + let ptr = options.as_js_object_ptr(); + if ptr.is_null() { + return Err(crate::error::create_type_error(vm, "options must be an object")); + } + let unit = read_prop_text(vm, unsafe { &*ptr }, options, "largestUnit").unwrap_or_else(|| "days".to_string()); + let unit = unit.to_ascii_lowercase(); + let unit = unit.strip_suffix('s').unwrap_or(&unit); + match unit { + "year" | "month" | "week" | "day" => Ok(unit.to_string()), + "auto" => Ok("day".to_string()), + _ => Err(crate::error::create_range_error(vm, "invalid largestUnit")), + } +} + +fn date_difference(start: NaiveDate, end: NaiveDate, unit: &str) -> [f64; 10] { + let total_days = end.signed_duration_since(start).num_days(); + if unit == "day" { + return [0.0, 0.0, 0.0, total_days as f64, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + } + if unit == "week" { + return [0.0, 0.0, (total_days / 7) as f64, (total_days % 7) as f64, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + } + + let direction = if end >= start { 1_i64 } else { -1_i64 }; + let mut total_months = (end.year() as i64 - start.year() as i64) * 12 + end.month0() as i64 - start.month0() as i64; + let mut candidate = add_signed_months(start, total_months).unwrap_or(start); + if (direction > 0 && candidate > end) || (direction < 0 && candidate < end) { + total_months -= direction; + candidate = add_signed_months(start, total_months).unwrap_or(start); + } + let remainder_days = end.signed_duration_since(candidate).num_days(); + let (years, months) = if unit == "year" { + (total_months / 12, total_months % 12) + } else { + (0, total_months) + }; + [years as f64, months as f64, 0.0, remainder_days as f64, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] +} + /// `Temporal.PlainDate.prototype.dayOfWeek` getter:ISO 周几(周一 1 … 周日 7)。 pub fn plain_date_day_of_week(vm: &mut H, args: &[u8]) -> NativeResult { let date = match plain_date_naive(vm, args) { @@ -770,46 +1169,42 @@ pub fn plain_date_to_json(vm: &mut H, args: &[u8]) -> NativeResult { NativeResult::Ok(vm.new_string(&format!("{year:04}-{month:02}-{day:02}"))) } -/// 从 duration-like 值读日期单位字段(year/month/week/day);非对象或字段缺失视为 0。 -fn duration_like_date_fields(vm: &mut H, val: JsValue) -> (f64, f64, f64, f64) { - if !val.is_object() { - return (0.0, 0.0, 0.0, 0.0); - } - let obj = unsafe { &*val.as_js_object_ptr() }; - let y = read_prop_number(vm, obj, val, "years"); - let m = read_prop_number(vm, obj, val, "months"); - let w = read_prop_number(vm, obj, val, "weeks"); - let d = read_prop_number(vm, obj, val, "days"); - ( - if y.is_nan() { 0.0 } else { y.trunc() }, - if m.is_nan() { 0.0 } else { m.trunc() }, - if w.is_nan() { 0.0 } else { w.trunc() }, - if d.is_nan() { 0.0 } else { d.trunc() }, - ) -} - /// 按 duration-like 日期字段对日期做加减(Temporal 大单位运算,月份不足日时取月末)。 -fn date_apply_duration( - vm: &mut H, args: &[u8], sign: i64, -) -> NativeResult { +fn date_apply_duration(vm: &mut H, args: &[u8], sign: i64) -> NativeResult { let date = match plain_date_naive(vm, args) { Ok(d) => d, Err(e) => return NativeResult::Err(e), }; let val = if args.len() > 1 { vm.reg(args[1]) } else { JsValue::undefined() }; - let (y, m, w, d) = duration_like_date_fields(vm, val); + let values = match duration_like_values(vm, val) { + Ok(values) => values, + Err(error) => return NativeResult::Err(error), + }; + let [y, m, w, d, h, min, s, ms, us, ns] = values; let mut result = date; - let total_months = (y * 12.0 + m) * sign as f64; - if total_months != 0.0 { - if let Some(nd) = result.checked_add_months(Months::new(total_months as u32)) { + let total_months = ((y * 12.0 + m) * sign as f64) as i64; + if total_months != 0 { + if let Some(nd) = add_signed_months(result, total_months) { result = nd; } else { return NativeResult::Err(crate::error::create_range_error(vm, "date out of range")); } } - let total_days = (w * 7.0 + d) * sign as f64; - if total_days != 0.0 { - if let Some(nd) = result.checked_add_days(Days::new(total_days as u64)) { + let time_days = (h / 24.0 + + min / 1_440.0 + + s / 86_400.0 + + ms / 86_400_000.0 + + us / 86_400_000_000.0 + + ns / 86_400_000_000_000.0) + .trunc(); + let total_days = ((w * 7.0 + d + time_days) * sign as f64) as i64; + if total_days != 0 { + let next = if total_days >= 0 { + result.checked_add_days(Days::new(total_days as u64)) + } else { + result.checked_sub_days(Days::new(total_days.unsigned_abs())) + }; + if let Some(nd) = next { result = nd; } else { return NativeResult::Err(crate::error::create_range_error(vm, "date out of range")); @@ -827,3 +1222,56 @@ pub fn plain_date_add(vm: &mut H, args: &[u8]) -> NativeResult { pub fn plain_date_subtract(vm: &mut H, args: &[u8]) -> NativeResult { date_apply_duration(vm, args, -1) } + +fn add_signed_months(date: NaiveDate, months: i64) -> Option { + let total = date.year() as i64 * 12 + date.month0() as i64 + months; + let year = total.div_euclid(12); + let month = total.rem_euclid(12) as u32 + 1; + let year = i32::try_from(year).ok()?; + let day = date.day().min(days_in_month_iso(year, month)); + NaiveDate::from_ymd_opt(year, month, day) +} + +/// `Temporal.PlainDate.prototype.until(other)`,按默认天单位返回差值。 +pub fn plain_date_until(vm: &mut H, args: &[u8]) -> NativeResult { + let start = match plain_date_naive(vm, args) { + Ok(date) => date, + Err(error) => return NativeResult::Err(error), + }; + let other_value = if args.len() > 1 { vm.reg(args[1]) } else { JsValue::undefined() }; + let (year, month, day) = match date_like_ymd(vm, other_value) { + Ok(value) => value, + Err(error) => return NativeResult::Err(error), + }; + let other = NaiveDate::from_ymd_opt(year, month, day).expect("date_like_ymd validates ISO date"); + let unit = match largest_unit(vm, args) { + Ok(unit) => unit, + Err(error) => return NativeResult::Err(error), + }; + make_duration(vm, date_difference(start, other, &unit)) +} + +/// `Temporal.PlainDate.prototype.since(other)`,按默认天单位返回差值。 +pub fn plain_date_since(vm: &mut H, args: &[u8]) -> NativeResult { + let start = match plain_date_naive(vm, args) { + Ok(date) => date, + Err(error) => return NativeResult::Err(error), + }; + let other_value = if args.len() > 1 { vm.reg(args[1]) } else { JsValue::undefined() }; + let (year, month, day) = match date_like_ymd(vm, other_value) { + Ok(value) => value, + Err(error) => return NativeResult::Err(error), + }; + let other = NaiveDate::from_ymd_opt(year, month, day).expect("date_like_ymd validates ISO date"); + let unit = match largest_unit(vm, args) { + Ok(unit) => unit, + Err(error) => return NativeResult::Err(error), + }; + let mut values = date_difference(start, other, &unit); + for value in &mut values { + if *value != 0.0 { + *value = -*value; + } + } + make_duration(vm, values) +} diff --git a/crates/oxide_kernel/src/builtin.rs b/crates/oxide_kernel/src/builtin.rs index 8a045e4..a066c8f 100644 --- a/crates/oxide_kernel/src/builtin.rs +++ b/crates/oxide_kernel/src/builtin.rs @@ -251,6 +251,8 @@ pub struct BuiltinWorld { pub plain_date_proto: P, pub plain_time_constructor: P, pub plain_time_proto: P, + pub duration_constructor: P, + pub duration_proto: P, pub bigint_constructor: P, pub bigint_proto: P, pub stub_objects: Vec>, @@ -504,10 +506,11 @@ fn wire_builtin_world_links(world: &BuiltinWorld) { wire_ctor_proto(&world.instant_constructor, &world.instant_proto); wire_ctor_proto(&world.plain_date_constructor, &world.plain_date_proto); wire_ctor_proto(&world.plain_time_constructor, &world.plain_time_proto); + wire_ctor_proto(&world.duration_constructor, &world.duration_proto); wire_ctor_proto(&world.bigint_constructor, &world.bigint_proto); let obj_proto_val = JsValue::from_js_object(world.object_proto.as_ptr() as *mut JsObject); - let non_object_protos: [&P; 18] = [ + let non_object_protos: [&P; 19] = [ &world.array_proto, &world.function_proto, &world.string_proto, @@ -525,6 +528,7 @@ fn wire_builtin_world_links(world: &BuiltinWorld) { &world.instant_proto, &world.plain_date_proto, &world.plain_time_proto, + &world.duration_proto, &world.bigint_proto, ]; for proto in &non_object_protos { @@ -660,6 +664,8 @@ impl BuiltinWorld { BuiltinId::PlainDateProto => &self.plain_date_proto, BuiltinId::PlainTimeConstructor => &self.plain_time_constructor, BuiltinId::PlainTimeProto => &self.plain_time_proto, + BuiltinId::DurationConstructor => &self.duration_constructor, + BuiltinId::DurationProto => &self.duration_proto, BuiltinId::BigIntConstructor => &self.bigint_constructor, BuiltinId::BigIntProto => &self.bigint_proto, } @@ -710,6 +716,7 @@ impl BuiltinWorld { make_named_pair(string_forge, shape_forge, labels, "PlainDate"); let (plain_time_proto, plain_time_constructor) = make_named_pair(string_forge, shape_forge, labels, "PlainTime"); + let (duration_proto, duration_constructor) = make_named_pair(string_forge, shape_forge, labels, "Duration"); let (bigint_proto, bigint_constructor) = make_named_pair(string_forge, shape_forge, labels, "BigInt"); let stub_objects = Vec::new(); @@ -791,6 +798,8 @@ impl BuiltinWorld { plain_date_proto, plain_time_constructor, plain_time_proto, + duration_constructor, + duration_proto, bigint_constructor, bigint_proto, stub_objects, @@ -967,13 +976,24 @@ impl BuiltinWorld { biguint64array_proto: current.biguint64array_proto.clone(), } }; - let (temporal_object, temporal_now_object, instant_proto, instant_constructor, plain_date_proto, plain_date_constructor, plain_time_proto, plain_time_constructor) = if dirty.temporal { - let (instant_proto, instant_constructor) = - make_named_pair(string_forge, shape_forge, labels, "Instant"); + let ( + temporal_object, + temporal_now_object, + instant_proto, + instant_constructor, + plain_date_proto, + plain_date_constructor, + plain_time_proto, + plain_time_constructor, + duration_proto, + duration_constructor, + ) = if dirty.temporal { + let (instant_proto, instant_constructor) = make_named_pair(string_forge, shape_forge, labels, "Instant"); let (plain_date_proto, plain_date_constructor) = make_named_pair(string_forge, shape_forge, labels, "PlainDate"); let (plain_time_proto, plain_time_constructor) = make_named_pair(string_forge, shape_forge, labels, "PlainTime"); + let (duration_proto, duration_constructor) = make_named_pair(string_forge, shape_forge, labels, "Duration"); ( P::new(JsObject::new_empty(EMPTY_SHAPE_ID, JsValue::null())), P::new(JsObject::new_empty(EMPTY_SHAPE_ID, JsValue::null())), @@ -983,6 +1003,8 @@ impl BuiltinWorld { plain_date_constructor, plain_time_proto, plain_time_constructor, + duration_proto, + duration_constructor, ) } else { ( @@ -994,6 +1016,8 @@ impl BuiltinWorld { current.plain_date_constructor.clone(), current.plain_time_proto.clone(), current.plain_time_constructor.clone(), + current.duration_proto.clone(), + current.duration_constructor.clone(), ) }; let (bigint_proto, bigint_constructor) = if dirty.stubs { @@ -1081,6 +1105,8 @@ impl BuiltinWorld { plain_date_proto, plain_time_constructor, plain_time_proto, + duration_constructor, + duration_proto, bigint_constructor, bigint_proto, stub_objects, diff --git a/crates/oxide_kernel/src/kernel.rs b/crates/oxide_kernel/src/kernel.rs index 21f6c6f..82d07a4 100644 --- a/crates/oxide_kernel/src/kernel.rs +++ b/crates/oxide_kernel/src/kernel.rs @@ -225,7 +225,7 @@ pub struct KernelSession { /// 维护注意:每个新增的 `BuiltinWorld` 对象字段都必须加到这里以及 /// `KernelSession::dirty_since_snapshot()`,以便选择性重置重建正确的 /// builtin 家族。 -pub const NUM_BUILTINS: usize = 78; +pub const NUM_BUILTINS: usize = 80; /// 内置对象枚举 id,与 `BuiltinWorld` 中的存储槽一一对应。 /// @@ -312,6 +312,8 @@ pub enum BuiltinId { PlainTimeProto = 75, BigIntConstructor = 76, BigIntProto = 77, + DurationConstructor = 78, + DurationProto = 79, } impl BuiltinId { @@ -395,6 +397,8 @@ impl BuiltinId { BuiltinId::PlainTimeProto, BuiltinId::BigIntConstructor, BuiltinId::BigIntProto, + BuiltinId::DurationConstructor, + BuiltinId::DurationProto, ]; } @@ -627,7 +631,9 @@ impl KernelSession { || gen(BuiltinId::PlainDateConstructor) != snap(BuiltinId::PlainDateConstructor) || gen(BuiltinId::PlainDateProto) != snap(BuiltinId::PlainDateProto) || gen(BuiltinId::PlainTimeConstructor) != snap(BuiltinId::PlainTimeConstructor) - || gen(BuiltinId::PlainTimeProto) != snap(BuiltinId::PlainTimeProto), + || gen(BuiltinId::PlainTimeProto) != snap(BuiltinId::PlainTimeProto) + || gen(BuiltinId::DurationConstructor) != snap(BuiltinId::DurationConstructor) + || gen(BuiltinId::DurationProto) != snap(BuiltinId::DurationProto), stubs: world.stub_objects.len() != snapshot.stub_objects_len || stub_generations_dirty || gen(BuiltinId::BigIntConstructor) != snap(BuiltinId::BigIntConstructor) diff --git a/crates/oxide_types/src/object.rs b/crates/oxide_types/src/object.rs index baebaf8..62aa9cf 100644 --- a/crates/oxide_types/src/object.rs +++ b/crates/oxide_types/src/object.rs @@ -443,6 +443,8 @@ impl JsObject { pub const OBJ_TYPE_PLAIN_DATE: u8 = 15; /// Temporal.PlainTime 对象:午夜后纳秒存于 prop 0。 pub const OBJ_TYPE_PLAIN_TIME: u8 = 16; + /// Temporal.Duration 对象:十个时长分量依次存于 prop 0-9。 + pub const OBJ_TYPE_DURATION: u8 = 17; /// `is_session_epoch` 字段中的 session 标记位。 pub const SESSION_EPOCH_BIT: u8 = 0x01; /// `is_session_epoch` 字段中的 GC 标记位。 @@ -523,6 +525,11 @@ impl JsObject { pub fn is_plain_time_obj(&self) -> bool { self.type_tag == Self::OBJ_TYPE_PLAIN_TIME } + /// 是否 Temporal.Duration 对象。 + #[inline] + pub fn is_duration_obj(&self) -> bool { + self.type_tag == Self::OBJ_TYPE_DURATION + } /// 构造无属性、可扩展的空对象(`new Object()` 的基础对象)。 pub fn new_empty(shape_id: ShapeId, proto: JsValue) -> Self { @@ -897,13 +904,9 @@ impl JsObject { if self.is_array() { let count = self.array_prop_count as usize; if pos < count { - return self - .array_elements_meta_vec() - .and_then(|vec| vec.get(pos).copied().flatten()); + return self.array_elements_meta_vec().and_then(|vec| vec.get(pos).copied().flatten()); } - return self - .prop_meta_vec() - .and_then(|vec| vec.get(pos - count).copied().flatten()); + return self.prop_meta_vec().and_then(|vec| vec.get(pos - count).copied().flatten()); } self.prop_meta_vec().and_then(|vec| vec.get(pos).copied().flatten()) } diff --git a/crates/oxide_vm/src/bindings/bind_temporal.rs b/crates/oxide_vm/src/bindings/bind_temporal.rs index e28770e..7c9b8ea 100644 --- a/crates/oxide_vm/src/bindings/bind_temporal.rs +++ b/crates/oxide_vm/src/bindings/bind_temporal.rs @@ -5,7 +5,7 @@ use oxide_kernel::kernel::{KernelCore, KernelSession}; use oxide_types::object::JsObject; use oxide_types::value::JsValue; -/// 把 Temporal 命名空间及其 Now/Instant/PlainDate/PlainTime 子对象绑定到 global。 +/// 把 Temporal 命名空间及其子对象绑定到 global。 pub fn bind_temporal(core: &Arc, session: &KernelSession, global: &mut JsObject) { let world = session.builtin_world(); let temporal_ptr = world.temporal_object.as_ptr() as *mut JsObject; @@ -20,11 +20,7 @@ pub fn bind_temporal(core: &Arc, session: &KernelSession, global: &m core, &[ ("instant", oxide_builtins::temporal::now_instant:: as *const (), 0), - ( - "timeZoneId", - oxide_builtins::temporal::now_time_zone_id:: as *const (), - 0, - ), + ("timeZoneId", oxide_builtins::temporal::now_time_zone_id:: as *const (), 0), ], ); @@ -218,12 +214,18 @@ pub fn bind_temporal(core: &Arc, session: &KernelSession, global: &m plain_date_proto, core, &[ - ("toString", oxide_builtins::temporal::plain_date_to_string:: as *const (), 0), + ( + "toString", + oxide_builtins::temporal::plain_date_to_string:: as *const (), + 0, + ), ("toJSON", oxide_builtins::temporal::plain_date_to_json:: as *const (), 0), ("valueOf", oxide_builtins::temporal::plain_date_value_of:: as *const (), 0), ("equals", oxide_builtins::temporal::plain_date_equals:: as *const (), 1), ("add", oxide_builtins::temporal::plain_date_add:: as *const (), 1), ("subtract", oxide_builtins::temporal::plain_date_subtract:: as *const (), 1), + ("until", oxide_builtins::temporal::plain_date_until:: as *const (), 1), + ("since", oxide_builtins::temporal::plain_date_since:: as *const (), 1), ], ); @@ -283,7 +285,62 @@ pub fn bind_temporal(core: &Arc, session: &KernelSession, global: &m world, plain_time_proto, core, - &[("toString", oxide_builtins::temporal::plain_time_to_string:: as *const (), 0)], + &[( + "toString", + oxide_builtins::temporal::plain_time_to_string:: as *const (), + 0, + )], + ); + + // Temporal.Duration:构造器、from、分量 getter 与 ISO 字符串转换。 + let duration_ctor_ptr = world.duration_constructor.as_ptr() as *mut JsObject; + let duration_ctor = unsafe { &mut *duration_ctor_ptr }; + configure_native_constructor( + duration_ctor, + oxide_builtins::temporal::duration_constructor:: as *const (), + 10, + ); + apply_binding_table( + world, + duration_ctor, + core, + &[("from", oxide_builtins::temporal::duration_from:: as *const (), 1)], + ); + let duration_proto_ptr = world.duration_proto.as_ptr() as *mut JsObject; + let duration_proto = unsafe { &mut *duration_proto_ptr }; + for (name, getter) in [ + ("years", oxide_builtins::temporal::duration_years:: as *const ()), + ("months", oxide_builtins::temporal::duration_months:: as *const ()), + ("weeks", oxide_builtins::temporal::duration_weeks:: as *const ()), + ("days", oxide_builtins::temporal::duration_days:: as *const ()), + ("hours", oxide_builtins::temporal::duration_hours:: as *const ()), + ("minutes", oxide_builtins::temporal::duration_minutes:: as *const ()), + ("seconds", oxide_builtins::temporal::duration_seconds:: as *const ()), + ( + "milliseconds", + oxide_builtins::temporal::duration_milliseconds:: as *const (), + ), + ( + "microseconds", + oxide_builtins::temporal::duration_microseconds:: as *const (), + ), + ( + "nanoseconds", + oxide_builtins::temporal::duration_nanoseconds:: as *const (), + ), + ] { + bind_accessor_getter(core, session, duration_proto, name, getter); + } + apply_binding_table( + world, + duration_proto, + core, + &[ + ("abs", oxide_builtins::temporal::duration_abs:: as *const (), 0), + ("negated", oxide_builtins::temporal::duration_negated:: as *const (), 0), + ("toString", oxide_builtins::temporal::duration_to_string:: as *const (), 0), + ("valueOf", oxide_builtins::temporal::duration_value_of:: as *const (), 0), + ], ); // 把子对象挂到 Temporal 命名空间对象上,再把 Temporal 挂到 global。 @@ -291,5 +348,6 @@ pub fn bind_temporal(core: &Arc, session: &KernelSession, global: &m bind_global_value(core, temporal, "Instant", JsValue::from_js_object(instant_ctor_ptr)); bind_global_value(core, temporal, "PlainDate", JsValue::from_js_object(plain_date_ctor_ptr)); bind_global_value(core, temporal, "PlainTime", JsValue::from_js_object(plain_time_ctor_ptr)); + bind_global_value(core, temporal, "Duration", JsValue::from_js_object(duration_ctor_ptr)); bind_global_value(core, global, "Temporal", JsValue::from_js_object(temporal_ptr)); } diff --git a/crates/oxide_vm/tests/builtins_temporal_tests.rs b/crates/oxide_vm/tests/builtins_temporal_tests.rs index eab9de2..5c4295b 100644 --- a/crates/oxide_vm/tests/builtins_temporal_tests.rs +++ b/crates/oxide_vm/tests/builtins_temporal_tests.rs @@ -112,8 +112,11 @@ fn instant_value_of_throws() { #[test] fn instant_method_on_non_instant_throws() { let mut vm = Vm::new(); - let r = eval(&mut vm, "try { Temporal.Instant.prototype.toString.call({}) } catch (e) { e.constructor.name }") - .unwrap(); + let r = eval( + &mut vm, + "try { Temporal.Instant.prototype.toString.call({}) } catch (e) { e.constructor.name }", + ) + .unwrap(); assert_eq!(str_val(&vm, r), "TypeError"); } @@ -188,6 +191,58 @@ fn plain_date_method_on_non_date_throws() { assert_eq!(str_val(&vm, r), "TypeError"); } +#[test] +fn plain_date_add_and_subtract_support_negative_offsets() { + let mut vm = Vm::new(); + let r = eval(&mut vm, "new Temporal.PlainDate(2024, 1, 31).add({months:1}).toString()").unwrap(); + assert_eq!(str_val(&vm, r), "2024-02-29"); + let r = eval(&mut vm, "new Temporal.PlainDate(2024, 1, 5).subtract({days:10}).toString()").unwrap(); + assert_eq!(str_val(&vm, r), "2023-12-26"); +} + +#[test] +fn plain_date_until_and_since_return_duration() { + let mut vm = Vm::new(); + let r = eval(&mut vm, "new Temporal.PlainDate(2024, 1, 1).until('2024-01-06').toString()").unwrap(); + assert_eq!(str_val(&vm, r), "P5D"); + assert_eq!( + num( + &mut vm, + "new Temporal.PlainDate(2024, 1, 6).since(new Temporal.PlainDate(2024, 1, 11)).days", + ), + -5.0 + ); + assert_eq!( + num( + &mut vm, + "new Temporal.PlainDate(1997, 7, 16).since(new Temporal.PlainDate(2021, 7, 15), {largestUnit:'years'}).days", + ), + -29.0 + ); +} + +#[test] +fn duration_constructor_exposes_components() { + let mut vm = Vm::new(); + let r = eval(&mut vm, "new Temporal.Duration(1,2,3,4,5,6,7,8,9,10).toString()").unwrap(); + assert_eq!(str_val(&vm, r), "P1Y2M3W4DT5H6M7.00800901S"); + assert_eq!(num(&mut vm, "new Temporal.Duration(1,2,3,4,5,6,7,8,9,10).nanoseconds"), 10.0); + let r = eval(&mut vm, "Temporal.Duration.from('-PT24.567890123H').toString()").unwrap(); + assert_eq!(str_val(&vm, r), "-PT24H34M4.4044428S"); + assert_eq!(num(&mut vm, "Temporal.Duration.from('P43Y').years"), 43.0); + let r = eval(&mut vm, "new Temporal.Duration(1,2,3,4).negated().abs().toString()").unwrap(); + assert_eq!(str_val(&vm, r), "P1Y2M3W4D"); +} + +#[test] +fn plain_date_balances_duration_time_units_into_days() { + let mut vm = Vm::new(); + let r = eval(&mut vm, "new Temporal.PlainDate(2000,5,2).add('P1DT24H1440M86400S').toString()").unwrap(); + assert_eq!(str_val(&vm, r), "2000-05-06"); + let r = eval(&mut vm, "new Temporal.PlainDate(2000,5,2).add('-PT24.567890123H').toString()").unwrap(); + assert_eq!(str_val(&vm, r), "2000-05-01"); +} + // -- Temporal.PlainTime -- #[test]