From 02fffcd00cfb42cea334002b65e762366a73999d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 17:42:13 -0800 Subject: [PATCH] JavaScript: String, Number, RegExp and Date built-ins A realm now has String, Number, RegExp and Date as ECMAScript 5.1 sections 15.5, 15.7, 15.9 and 15.10 define them, with Annex B's substr, getYear, setYear and toGMTString. - String: every ES5 prototype method; split, replace, match and search take strings and RegExps. Each new string's length is checked against the heap limit before it is built, and scans take fuel. - Number: toString(radix), toFixed, toExponential and toPrecision format from the number's exact decimal value. - RegExp: a backtracking matcher keeps its choice points and capture state on the heap, within the heap room left, and takes fuel for every step, so a catastrophic pattern stops at the fuel limit. Patterns nested past Options::max_depth give a SyntaxError. Flags g, i and m, lastIndex, exec, test and toString. - Date: chrono converts days to calendar dates, shifting by whole 400-year cycles past its years. Realm::set_clock and Realm::set_time_zone set the clock and local time (UTC by default). Without a hint, a Date converts to a primitive as a string. cargo xtask test262 also runs the suite's built-ins tests for the four and prints their counts. --- Cargo.lock | 1 + crates/js/Cargo.toml | 1 + crates/js/src/builtins.rs | 21 +- crates/js/src/date_builtins.rs | 842 +++++++++++++++++++++ crates/js/src/heap.rs | 18 +- crates/js/src/interp.rs | 30 +- crates/js/src/lib.rs | 17 +- crates/js/src/number.rs | 308 +++++++- crates/js/src/number_builtins.rs | 127 ++++ crates/js/src/realm.rs | 15 + crates/js/src/regexp_builtins.rs | 269 +++++++ crates/js/src/regexp_vm.rs | 1188 ++++++++++++++++++++++++++++++ crates/js/src/string_builtins.rs | 699 ++++++++++++++++++ crates/js/src/text_tests.rs | 441 +++++++++++ crates/js/tests/test262.rs | 140 +++- docs/target-app-parity.md | 1 + xtask/src/main.rs | 3 +- xtask/src/test262.rs | 3 +- 18 files changed, 4062 insertions(+), 62 deletions(-) create mode 100644 crates/js/src/date_builtins.rs create mode 100644 crates/js/src/number_builtins.rs create mode 100644 crates/js/src/regexp_builtins.rs create mode 100644 crates/js/src/regexp_vm.rs create mode 100644 crates/js/src/string_builtins.rs create mode 100644 crates/js/src/text_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 8b98b85e9..6205742a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6337,6 +6337,7 @@ dependencies = [ name = "vectorcraft-js" version = "0.21.0" dependencies = [ + "chrono", "proptest", "unicode-general-category", ] diff --git a/crates/js/Cargo.toml b/crates/js/Cargo.toml index 4cb1e62c2..5848c1a91 100644 --- a/crates/js/Cargo.toml +++ b/crates/js/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true workspace = true [dependencies] +chrono = { workspace = true } unicode-general-category = "1" [dev-dependencies] diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index b16aa9c9e..5f6cb4761 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -1,7 +1,8 @@ //! The built-in objects the interpreter itself needs (ECMA-262 5.1 section 15): `Object` and the //! basic methods of `Object.prototype`, `Function` with `Function.prototype.call`, `apply` and //! `toString`, `eval`, the error constructors and `Error.prototype.toString`, and the prototypes -//! primitive values look properties up on. The rest of the standard library is not here yet. +//! primitive values look properties up on. `String`, `Number`, `RegExp` and `Date` are in their +//! own modules; the rest of the standard library is not here yet. use std::rc::Rc; @@ -16,24 +17,24 @@ fn alloc(interp: &mut Interp, class: Class, proto: Option) -> ObjId { } /// A built-in function object. -fn native(interp: &mut Interp, name: &'static str, f: NativeFn, length: u32, kind: NativeKind, constructor: bool) -> JsResult { +pub(crate) fn native(interp: &mut Interp, name: &'static str, f: NativeFn, length: u32, kind: NativeKind, constructor: bool) -> JsResult { let proto = interp.intrinsics.function_proto; let o = alloc(interp, Class::Function(Func::Native { f, kind, constructor, name }), Some(proto)); interp.define(o, key("length"), Prop::Data { value: Value::Number(f64::from(length)), flags: 0 })?; Ok(o) } -fn method(interp: &mut Interp, on: ObjId, name: &'static str, f: NativeFn, length: u32) -> JsResult<()> { +pub(crate) fn method(interp: &mut Interp, on: ObjId, name: &'static str, f: NativeFn, length: u32) -> JsResult<()> { let m = native(interp, name, f, length, NativeKind::Plain, false)?; interp.define(on, key(name), Prop::Data { value: Value::Object(m), flags: HIDDEN }) } -fn data(interp: &mut Interp, on: ObjId, name: &str, value: Value, flags: u8) -> JsResult<()> { +pub(crate) fn data(interp: &mut Interp, on: ObjId, name: &str, value: Value, flags: u8) -> JsResult<()> { interp.define(on, key(name), Prop::Data { value, flags }) } /// Links a constructor and its prototype and makes the constructor a global. -fn constructor(interp: &mut Interp, name: &'static str, ctor: ObjId, proto: ObjId) -> JsResult<()> { +pub(crate) fn constructor(interp: &mut Interp, name: &'static str, ctor: ObjId, proto: ObjId) -> JsResult<()> { data(interp, ctor, "prototype", Value::Object(proto), 0)?; data(interp, proto, "constructor", Value::Object(ctor), HIDDEN)?; let global = interp.global; @@ -65,7 +66,7 @@ fn try_install(interp: &mut Interp) -> JsResult<()> { } interp.intrinsics.array_proto = alloc(interp, Class::Array { length: 0, length_writable: true }, Some(object_proto)); interp.intrinsics.string_proto = alloc(interp, Class::String(Str::from("")), Some(object_proto)); - interp.intrinsics.number_proto = alloc(interp, Class::Number, Some(object_proto)); + interp.intrinsics.number_proto = alloc(interp, Class::Number(0.0), Some(object_proto)); interp.intrinsics.boolean_proto = alloc(interp, Class::Boolean, Some(object_proto)); interp.intrinsics.regexp_proto = alloc(interp, Class::Ordinary, Some(object_proto)); let thrower = native(interp, "", throw_type_error, 0, NativeKind::Plain, false)?; @@ -98,7 +99,11 @@ fn try_install(interp: &mut Interp) -> JsResult<()> { data(interp, function_proto, "apply", Value::Object(apply), HIDDEN)?; method(interp, function_proto, "toString", function_to_string, 0)?; - install_errors(interp, object_proto) + install_errors(interp, object_proto)?; + crate::string_builtins::install(interp)?; + crate::number_builtins::install(interp)?; + crate::regexp_builtins::install(interp)?; + crate::date_builtins::install(interp) } fn install_errors(interp: &mut Interp, object_proto: ObjId) -> JsResult<()> { @@ -132,7 +137,7 @@ fn install_errors(interp: &mut Interp, object_proto: ObjId) -> JsResult<()> { Ok(()) } -fn arg(args: &[Value], i: usize) -> Value { +pub(crate) fn arg(args: &[Value], i: usize) -> Value { args.get(i).cloned().unwrap_or(Value::Undefined) } diff --git a/crates/js/src/date_builtins.rs b/crates/js/src/date_builtins.rs new file mode 100644 index 000000000..b453910ff --- /dev/null +++ b/crates/js/src/date_builtins.rs @@ -0,0 +1,842 @@ +//! `Date` (ECMA-262 5.1 section 15.9), with `getYear`, `setYear` and `toGMTString` from Annex B. +//! +//! Time values are milliseconds since 1970-01-01 UTC (section 15.9.1). `chrono` turns days into +//! calendar dates and back; past the years it represents, dates are shifted by whole 400-year +//! cycles. The current time comes from the host's clock and local time from the host's +//! time zone ([`DateHost`]): by default the system clock (none in a wasm build, where `Date.now()` +//! is NaN until the host sets one) and UTC. `Date.parse` reads the format of section 15.9.1.15 +//! and the formats `toString` and `toUTCString` write. + +use std::rc::Rc; + +use chrono::{Datelike, NaiveDate}; + +use crate::builtins::{arg, constructor, data, method, native}; +use crate::heap::{Class, NativeFn, NativeKind, Object, key}; +use crate::interp::{ErrorType, Hint, Interp, JsResult}; +use crate::string_builtins::{charge_units, reserve_units, to_integer}; +use crate::value::{Str, Value}; + +/// The clock and time zone a realm's `Date` reads. +#[derive(Clone)] +pub(crate) struct DateHost { + /// The current time value. + pub(crate) clock: Rc f64>, + /// The offset of local time from UTC, in milliseconds, at a UTC time value. + pub(crate) offset: Rc f64>, +} + +impl Default for DateHost { + fn default() -> Self { + Self { clock: Rc::new(system_clock), offset: Rc::new(|_| 0.0) } + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn system_clock() -> f64 { + match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { + Ok(d) => d.as_millis() as f64, + Err(e) => -(e.duration().as_millis() as f64), + } +} + +#[cfg(target_arch = "wasm32")] +fn system_clock() -> f64 { + f64::NAN +} + +const MS_PER_DAY: f64 = 86_400_000.0; +const MS_PER_HOUR: f64 = 3_600_000.0; +const MS_PER_MINUTE: f64 = 60_000.0; +const MS_PER_SECOND: f64 = 1000.0; + +const DAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +// ---- section 15.9.1 ---- + +fn day(t: f64) -> f64 { + (t / MS_PER_DAY).floor() +} + +fn time_within_day(t: f64) -> f64 { + t.rem_euclid(MS_PER_DAY) +} + +/// `chrono`'s day number (0001-01-01 is day 1) of 1970-01-01. +const EPOCH_CE_DAYS: i64 = 719_163; +/// The days in 400 Gregorian years, after which the calendar repeats. +const CYCLE_DAYS: i64 = 146_097; +/// The days from 1970-01-01 to 2000-01-01, where the 400-year cycle `chrono` is asked about starts. +const DAYS_TO_2000: i64 = 10_957; + +/// The year, the month (0 to 11) and the day of the month of a day (days since 1970-01-01). +/// `chrono` computes them for the matching day of the 400-year cycle starting in 2000, and the +/// whole cycles are added back to the year, so days past `chrono`'s range have dates too. +fn civil(day: f64) -> Option<(f64, f64, f64)> { + if !day.is_finite() || day.abs() > 1e9 { + return None; + } + let d = day as i64; + let cycles = (d - DAYS_TO_2000).div_euclid(CYCLE_DAYS); + let date = NaiveDate::from_num_days_from_ce_opt(i32::try_from(d - cycles * CYCLE_DAYS + EPOCH_CE_DAYS).ok()?)?; + Some((f64::from(date.year()) + 400.0 * cycles as f64, f64::from(date.month0()), f64::from(date.day()))) +} + +/// The year of the 400-year cycle starting in 2000 that has `year`'s calendar, and the number of +/// whole cycles between the two. +fn cycle_year(year: f64) -> Option<(i32, i64)> { + if !year.is_finite() || year.abs() > 1e7 { + return None; + } + let y = year as i64; + let cycles = (y - 2000).div_euclid(400); + Some((i32::try_from(y - 400 * cycles).ok()?, cycles)) +} + +/// The day (days since 1970-01-01) on which month `month0` (0 to 11) of `year` starts. +fn first_day_of_month(year: f64, month0: u32) -> Option { + let (y, cycles) = cycle_year(year)?; + let date = NaiveDate::from_ymd_opt(y, month0 + 1, 1)?; + Some((i64::from(date.num_days_from_ce()) - EPOCH_CE_DAYS + cycles * CYCLE_DAYS) as f64) +} + +/// Month `month` (1 to 12) of `year` has a day `date`. +fn is_date(year: f64, month: f64, date: f64) -> bool { + (1.0..=12.0).contains(&month) + && (1.0..=31.0).contains(&date) + && cycle_year(year).is_some_and(|(y, _)| NaiveDate::from_ymd_opt(y, month as u32, date as u32).is_some()) +} + +fn year_from_time(t: f64) -> f64 { + civil(day(t)).map_or(f64::NAN, |(y, _, _)| y) +} + +fn month_from_time(t: f64) -> f64 { + civil(day(t)).map_or(f64::NAN, |(_, m, _)| m) +} + +fn date_from_time(t: f64) -> f64 { + civil(day(t)).map_or(f64::NAN, |(_, _, d)| d) +} + +fn week_day(t: f64) -> f64 { + (day(t) + 4.0).rem_euclid(7.0) +} + +fn hour_from_time(t: f64) -> f64 { + (t / MS_PER_HOUR).floor().rem_euclid(24.0) +} + +fn min_from_time(t: f64) -> f64 { + (t / MS_PER_MINUTE).floor().rem_euclid(60.0) +} + +fn sec_from_time(t: f64) -> f64 { + (t / MS_PER_SECOND).floor().rem_euclid(60.0) +} + +fn ms_from_time(t: f64) -> f64 { + t.rem_euclid(MS_PER_SECOND) +} + +fn make_time(h: f64, m: f64, s: f64, ms: f64) -> f64 { + if !(h.is_finite() && m.is_finite() && s.is_finite() && ms.is_finite()) { + return f64::NAN; + } + to_integer(h) * MS_PER_HOUR + to_integer(m) * MS_PER_MINUTE + to_integer(s) * MS_PER_SECOND + to_integer(ms) +} + +fn make_day(year: f64, month: f64, date: f64) -> f64 { + if !(year.is_finite() && month.is_finite() && date.is_finite()) { + return f64::NAN; + } + let (y, m, dt) = (to_integer(year), to_integer(month), to_integer(date)); + let ym = y + (m / 12.0).floor(); + // Years this far out are past every time value TimeClip keeps. + if ym.abs() > 400_000.0 { + return f64::NAN; + } + first_day_of_month(ym, m.rem_euclid(12.0) as u32).map_or(f64::NAN, |first| first + dt - 1.0) +} + +fn make_date(day: f64, time: f64) -> f64 { + if !(day.is_finite() && time.is_finite()) { + return f64::NAN; + } + day * MS_PER_DAY + time +} + +fn time_clip(t: f64) -> f64 { + if !t.is_finite() || t.abs() > 8.64e15 { f64::NAN } else { to_integer(t) } +} + +impl Interp { + /// The host's offset of local time from UTC at `utc`; an offset that is not finite or is a + /// day or more counts as 0. + fn local_offset(&self, utc: f64) -> f64 { + let o = (self.date_host.offset)(utc); + if o.is_finite() && o.abs() < MS_PER_DAY { o } else { 0.0 } + } + + /// LocalTime (section 15.9.1.9). + fn local_time(&self, t: f64) -> f64 { + t + self.local_offset(t) + } + + /// UTC (section 15.9.1.9): the time value of a local time. + fn utc(&self, t: f64) -> f64 { + if !t.is_finite() { + return f64::NAN; + } + let guess = t - self.local_offset(t); + t - self.local_offset(guess) + } + + fn now(&self) -> f64 { + time_clip((self.date_host.clock)()) + } +} + +// ---- formatting ---- + +fn year_text(y: f64) -> String { + if y < 0.0 { format!("-{:06}", -y as i64) } else { format!("{:04}", y as i64) } +} + +fn offset_text(offset_ms: f64) -> String { + let minutes = (offset_ms / MS_PER_MINUTE).round() as i64; + let sign = if minutes < 0 { '-' } else { '+' }; + format!("GMT{sign}{:02}{:02}", minutes.abs() / 60, minutes.abs() % 60) +} + +fn date_text(t: f64) -> String { + let wd = DAYS.get(week_day(t) as usize).copied().unwrap_or(""); + let mon = MONTHS.get(month_from_time(t) as usize).copied().unwrap_or(""); + format!("{wd} {mon} {:02} {}", date_from_time(t) as i64, year_text(year_from_time(t))) +} + +fn time_text(t: f64) -> String { + format!("{:02}:{:02}:{:02}", hour_from_time(t) as i64, min_from_time(t) as i64, sec_from_time(t) as i64) +} + +impl Interp { + /// `toString`'s format: `Tue Nov 07 2026 12:00:00 GMT+0100`. + fn date_to_string(&self, t: f64) -> String { + if t.is_nan() { + return "Invalid Date".into(); + } + let local = self.local_time(t); + format!("{} {} {}", date_text(local), time_text(local), offset_text(local - t)) + } +} + +fn utc_text(t: f64) -> String { + if t.is_nan() { + return "Invalid Date".into(); + } + let wd = DAYS.get(week_day(t) as usize).copied().unwrap_or(""); + let mon = MONTHS.get(month_from_time(t) as usize).copied().unwrap_or(""); + format!("{wd}, {:02} {mon} {} {} GMT", date_from_time(t) as i64, year_text(year_from_time(t)), time_text(t)) +} + +fn iso_text(t: f64) -> String { + let y = year_from_time(t); + let year = if (0.0..=9999.0).contains(&y) { + format!("{:04}", y as i64) + } else if y < 0.0 { + format!("-{:06}", -y as i64) + } else { + format!("+{:06}", y as i64) + }; + format!("{year}-{:02}-{:02}T{}.{:03}Z", month_from_time(t) as i64 + 1, date_from_time(t) as i64, time_text(t), ms_from_time(t) as i64) +} + +// ---- parsing ---- + +/// Reads the format of section 15.9.1.15: `YYYY[-MM[-DD]][THH:mm[:ss[.sss]][Z|±HH:mm]]`, with +/// six-digit signed years. A missing offset means UTC. +fn parse_iso(s: &[u8]) -> Option { + let mut i = 0; + let num = |i: &mut usize, n: usize| -> Option { + let part = s.get(*i..*i + n)?; + if !part.iter().all(u8::is_ascii_digit) { + return None; + } + *i += n; + Some(part.iter().fold(0.0, |v, d| v * 10.0 + f64::from(d - b'0'))) + }; + let year = match s.first() { + Some(b'+') | Some(b'-') => { + let neg = s.first() == Some(&b'-'); + i = 1; + let y = num(&mut i, 6)?; + if neg && y == 0.0 { + return None; + } + if neg { -y } else { y } + } + _ => num(&mut i, 4)?, + }; + let (mut month, mut date) = (1.0, 1.0); + if s.get(i) == Some(&b'-') { + i += 1; + month = num(&mut i, 2)?; + if s.get(i) == Some(&b'-') { + i += 1; + date = num(&mut i, 2)?; + } + } + let (mut h, mut m, mut sec, mut ms) = (0.0, 0.0, 0.0, 0.0); + let mut offset = 0.0; + if s.get(i) == Some(&b'T') { + i += 1; + h = num(&mut i, 2)?; + if s.get(i) != Some(&b':') { + return None; + } + i += 1; + m = num(&mut i, 2)?; + if s.get(i) == Some(&b':') { + i += 1; + sec = num(&mut i, 2)?; + if s.get(i) == Some(&b'.') { + i += 1; + let start = i; + while s.get(i).is_some_and(u8::is_ascii_digit) { + i += 1; + } + let digits = s.get(start..i)?; + if digits.is_empty() { + return None; + } + ms = digits.iter().take(3).fold(0.0, |v, d| v * 10.0 + f64::from(d - b'0')) * 10f64.powi(3 - digits.len().min(3) as i32); + } + } + match s.get(i) { + Some(b'Z') => i += 1, + Some(&c @ (b'+' | b'-')) => { + i += 1; + let oh = num(&mut i, 2)?; + if s.get(i) != Some(&b':') { + return None; + } + i += 1; + let om = num(&mut i, 2)?; + if oh > 23.0 || om > 59.0 { + return None; + } + offset = (oh * 60.0 + om) * MS_PER_MINUTE * if c == b'-' { -1.0 } else { 1.0 }; + } + _ => {} + } + } + if i != s.len() { + return None; + } + let valid = is_date(year, month, date) && (h < 24.0 || (h == 24.0 && m == 0.0 && sec == 0.0 && ms == 0.0)) && m < 60.0 && sec < 60.0; + if !valid { + return None; + } + let t = make_date(make_day(year, month - 1.0, date), make_time(h, m, sec, ms)) - offset; + Some(time_clip(t)) +} + +/// Reads the formats `toString` and `toUTCString` write: a weekday name, a month name, a day, a +/// year, an optional `HH:mm[:ss]` time and an optional `GMT[±hhmm]` offset, in either order of +/// day and month. Without an offset the time is local. +fn parse_text(interp: &Interp, s: &str) -> Option { + let mut words: Vec<&str> = Vec::new(); + for w in s.split(|c: char| c.is_whitespace() || c == ',') { + if w.starts_with('(') { + break; + } + if !w.is_empty() { + words.push(w); + } + } + let (mut month, mut date, mut year) = (None, None, None); + let (mut h, mut m, mut sec) = (0.0, 0.0, 0.0); + let mut offset: Option = None; + for w in words { + let lower = w.to_ascii_lowercase(); + if DAYS.iter().any(|d| lower.starts_with(&d.to_ascii_lowercase())) && month.is_none() && date.is_none() && w.len() >= 3 && !w.contains(':') { + continue; + } + if let Some(mi) = MONTHS.iter().position(|mo| lower.starts_with(&mo.to_ascii_lowercase())) { + month = Some(mi as f64); + continue; + } + if let Some(rest) = lower.strip_prefix("gmt").or_else(|| lower.strip_prefix("utc")).or_else(|| (lower == "z").then_some("")) { + if rest.is_empty() { + offset = Some(0.0); + continue; + } + let (sign, digits) = match rest.as_bytes().first() { + Some(b'+') => (1.0, rest.get(1..)?), + Some(b'-') => (-1.0, rest.get(1..)?), + _ => return None, + }; + let digits = digits.replace(':', ""); + if digits.len() != 4 || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let v: f64 = digits.parse().ok()?; + offset = Some(sign * ((v / 100.0).floor() * 60.0 + v % 100.0) * MS_PER_MINUTE); + continue; + } + if w.contains(':') { + let parts: Vec<&str> = w.split(':').collect(); + let field = |i: usize| -> Option { parts.get(i).map(|p| p.parse::().ok()).unwrap_or(Some(0.0)) }; + if parts.len() < 2 || parts.len() > 3 || !parts.iter().all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) { + return None; + } + (h, m, sec) = (field(0)?, field(1)?, field(2)?); + if h > 24.0 || m > 59.0 || sec > 59.0 { + return None; + } + continue; + } + let (neg, digits) = match w.strip_prefix('-') { + Some(d) => (true, d), + None => (false, w), + }; + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let v: f64 = digits.parse().ok()?; + if date.is_none() && !neg && digits.len() <= 2 { + date = Some(v); + } else if year.is_none() { + year = Some(if neg { -v } else { v }); + } else { + return None; + } + } + let (month, date, year) = (month?, date?, year?); + if !(1.0..=31.0).contains(&date) { + return None; + } + let local = make_date(make_day(year, month, date), make_time(h, m, sec, 0.0)); + let t = match offset { + Some(o) => local - o, + None => interp.utc(local), + }; + Some(time_clip(t)) +} + +/// Takes fuel for the units of `s` the parsers read. +fn parse_date(interp: &mut Interp, s: &Str) -> JsResult { + charge_units(interp, s.len())?; + let text = s.to_rust_string(); + let trimmed = text.trim(); + Ok(parse_iso(trimmed.as_bytes()).or_else(|| parse_text(interp, trimmed)).unwrap_or(f64::NAN)) +} + +// ---- the constructor ---- + +pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { + let object_proto = interp.intrinsics.object_proto; + let proto = interp.heap.alloc_object(Object::new(Class::Date(f64::NAN), Some(object_proto))); + interp.intrinsics.date_proto = proto; + let ctor = native(interp, "Date", date_ctor, 7, NativeKind::Plain, true)?; + constructor(interp, "Date", ctor, proto)?; + method(interp, ctor, "parse", date_parse, 1)?; + method(interp, ctor, "UTC", date_utc, 7)?; + method(interp, ctor, "now", date_now, 0)?; + let methods: [(&'static str, NativeFn, u32); 44] = [ + ("toString", to_string, 0), + ("toDateString", to_date_string, 0), + ("toTimeString", to_time_string, 0), + ("toLocaleString", to_string, 0), + ("toLocaleDateString", to_date_string, 0), + ("toLocaleTimeString", to_time_string, 0), + ("valueOf", get_time, 0), + ("getTime", get_time, 0), + ("getFullYear", get_full_year, 0), + ("getUTCFullYear", get_utc_full_year, 0), + ("getMonth", get_month, 0), + ("getUTCMonth", get_utc_month, 0), + ("getDate", get_date, 0), + ("getUTCDate", get_utc_date, 0), + ("getDay", get_day, 0), + ("getUTCDay", get_utc_day, 0), + ("getHours", get_hours, 0), + ("getUTCHours", get_utc_hours, 0), + ("getMinutes", get_minutes, 0), + ("getUTCMinutes", get_utc_minutes, 0), + ("getSeconds", get_seconds, 0), + ("getUTCSeconds", get_utc_seconds, 0), + ("getMilliseconds", get_milliseconds, 0), + ("getUTCMilliseconds", get_utc_milliseconds, 0), + ("getTimezoneOffset", get_timezone_offset, 0), + ("setTime", set_time, 1), + ("setMilliseconds", set_milliseconds, 1), + ("setUTCMilliseconds", set_utc_milliseconds, 1), + ("setSeconds", set_seconds, 2), + ("setUTCSeconds", set_utc_seconds, 2), + ("setMinutes", set_minutes, 3), + ("setUTCMinutes", set_utc_minutes, 3), + ("setHours", set_hours, 4), + ("setUTCHours", set_utc_hours, 4), + ("setDate", set_date, 1), + ("setUTCDate", set_utc_date, 1), + ("setMonth", set_month, 2), + ("setUTCMonth", set_utc_month, 2), + ("setFullYear", set_full_year, 3), + ("setUTCFullYear", set_utc_full_year, 3), + ("toISOString", to_iso_string, 0), + ("toJSON", to_json, 1), + ("getYear", get_year, 0), + ("setYear", set_year, 1), + ]; + for (name, f, len) in methods { + method(interp, proto, name, f, len)?; + } + // Annex B.2.6: `toGMTString` is the same function object as `toUTCString`. + method(interp, proto, "toUTCString", to_utc_string, 0)?; + let utc = interp.get_value(&Value::Object(proto), &key("toUTCString"))?; + data(interp, proto, "toGMTString", utc, crate::heap::HIDDEN) +} + +fn text(interp: &mut Interp, s: &str) -> JsResult { + reserve_units(interp, s.len())?; + Ok(Value::String(Str::from(s))) +} + +/// The numbers of the arguments from `year` on, as `new Date(y, m, …)` and `Date.UTC` read them +/// (sections 15.9.3.1 and 15.9.4.3): the local or UTC date they describe, before TimeClip. An +/// omitted month is January, which `Date.UTC(year)` uses (section 15.9.4.3 leaves a call with +/// fewer than two arguments to the implementation). +fn date_from_parts(interp: &mut Interp, args: &[Value]) -> JsResult { + let mut n = [f64::NAN, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0]; + for (i, slot) in n.iter_mut().enumerate() { + if let Some(v) = args.get(i) { + *slot = interp.to_number(v)?; + } + } + let [y, m, dt, h, min, s, ms] = n; + let yi = to_integer(y); + let yr = if !y.is_nan() && (0.0..=99.0).contains(&yi) { 1900.0 + yi } else { y }; + Ok(make_date(make_day(yr, m, dt), make_time(h, min, s, ms))) +} + +/// `Date(…)`, `new Date()`, `new Date(value)` and `new Date(y, m, …)` (sections 15.9.2 and +/// 15.9.3). +fn date_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) -> JsResult { + if !construct { + let s = interp.date_to_string(interp.now()); + return text(interp, &s); + } + let tv = match args.len() { + 0 => interp.now(), + 1 => { + let v = interp.to_primitive(&args[0], Hint::Default)?; + match &v { + Value::String(s) => parse_date(interp, s)?, + _ => time_clip(interp.to_number(&v)?), + } + } + _ => { + let local = date_from_parts(interp, args)?; + time_clip(interp.utc(local)) + } + }; + let proto = interp.intrinsics.date_proto; + Ok(Value::Object(interp.heap.alloc_object(Object::new(Class::Date(tv), Some(proto))))) +} + +fn date_parse(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let s = interp.to_str(&arg(args, 0))?; + Ok(Value::Number(parse_date(interp, &s)?)) +} + +fn date_utc(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let t = date_from_parts(interp, args)?; + Ok(Value::Number(time_clip(t))) +} + +fn date_now(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + Ok(Value::Number(interp.now())) +} + +// ---- Date.prototype ---- + +/// The time value of a Date object `this`. +fn this_time(interp: &mut Interp, this: &Value) -> JsResult { + if let Some(Class::Date(t)) = this.as_object().and_then(|o| interp.heap.object(o)).map(|o| &o.class) { + return Ok(*t); + } + Err(interp.type_error("Date.prototype method called on a value that is not a Date object")) +} + +fn set_this_time(interp: &mut Interp, this: &Value, t: f64) -> JsResult { + if let Some(o) = this.as_object().and_then(|o| interp.heap.object_mut(o)) + && let Class::Date(slot) = &mut o.class + { + *slot = t; + } + Ok(Value::Number(t)) +} + +fn to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let t = this_time(interp, &this)?; + let s = interp.date_to_string(t); + text(interp, &s) +} + +fn to_date_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let t = this_time(interp, &this)?; + let s = if t.is_nan() { "Invalid Date".into() } else { date_text(interp.local_time(t)) }; + text(interp, &s) +} + +fn to_time_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let t = this_time(interp, &this)?; + let s = if t.is_nan() { + "Invalid Date".into() + } else { + let local = interp.local_time(t); + format!("{} {}", time_text(local), offset_text(local - t)) + }; + text(interp, &s) +} + +fn to_utc_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let t = this_time(interp, &this)?; + text(interp, &utc_text(t)) +} + +/// `toISOString` (section 15.9.5.43). +fn to_iso_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let t = this_time(interp, &this)?; + if !t.is_finite() { + return Err(interp.throw(ErrorType::Range, "invalid time value")); + } + text(interp, &iso_text(t)) +} + +/// `toJSON` (section 15.9.5.44). +fn to_json(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let o = interp.to_object(&this)?; + let ov = Value::Object(o); + interp.temp_roots.push(ov.clone()); + let r = (|| { + let tv = interp.to_primitive(&ov, Hint::Number)?; + if let Value::Number(n) = tv + && !n.is_finite() + { + return Ok(Value::Null); + } + let f = interp.get_value(&ov, &key("toISOString"))?; + if !interp.is_callable(&f) { + return Err(interp.type_error("toISOString is not a function")); + } + interp.call(&f, &ov, &[]) + })(); + interp.temp_roots.pop(); + r +} + +fn get_time(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + this_time(interp, &this).map(Value::Number) +} + +/// A getter: `f` of the time value, in local time when `local`, NaN for an invalid date. +fn getter(interp: &mut Interp, this: &Value, local: bool, f: fn(f64) -> f64) -> JsResult { + let t = this_time(interp, this)?; + if t.is_nan() { + return Ok(Value::Number(f64::NAN)); + } + let t = if local { interp.local_time(t) } else { t }; + Ok(Value::Number(f(t))) +} + +macro_rules! getters { + ($($name:ident, $local:expr, $f:expr;)*) => { + $(fn $name(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + getter(interp, &this, $local, $f) + })* + }; +} + +getters! { + get_full_year, true, year_from_time; + get_utc_full_year, false, year_from_time; + get_month, true, month_from_time; + get_utc_month, false, month_from_time; + get_date, true, date_from_time; + get_utc_date, false, date_from_time; + get_day, true, week_day; + get_utc_day, false, week_day; + get_hours, true, hour_from_time; + get_utc_hours, false, hour_from_time; + get_minutes, true, min_from_time; + get_utc_minutes, false, min_from_time; + get_seconds, true, sec_from_time; + get_utc_seconds, false, sec_from_time; + get_milliseconds, true, ms_from_time; + get_utc_milliseconds, false, ms_from_time; +} + +/// `getYear` (Annex B.2.4). +fn get_year(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + getter(interp, &this, true, |t| year_from_time(t) - 1900.0) +} + +fn get_timezone_offset(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let t = this_time(interp, &this)?; + if t.is_nan() { + return Ok(Value::Number(f64::NAN)); + } + Ok(Value::Number((t - interp.local_time(t)) / MS_PER_MINUTE)) +} + +fn set_time(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + this_time(interp, &this)?; + let t = interp.to_number(&arg(args, 0))?; + set_this_time(interp, &this, time_clip(t)) +} + +/// The time fields a setter starts at: the setter sets that field and the smaller ones after it +/// from its arguments. +#[derive(Clone, Copy)] +enum TimeField { + Hour = 0, + Minute = 1, + Second = 2, + Ms = 3, +} + +/// `setHours`, `setMinutes`, `setSeconds` and `setMilliseconds`, local or UTC (sections +/// 15.9.5.28 to 15.9.5.35). +fn set_time_fields(interp: &mut Interp, this: &Value, args: &[Value], local: bool, first: TimeField, count: usize) -> JsResult { + let tv = this_time(interp, this)?; + let t = if local { interp.local_time(tv) } else { tv }; + let mut fields = [hour_from_time(t), min_from_time(t), sec_from_time(t), ms_from_time(t)]; + let first = first as usize; + for k in 0..count { + let given = k == 0 || args.get(k).is_some(); + if given { + let v = interp.to_number(&arg(args, k))?; + if let Some(slot) = fields.get_mut(first + k) { + *slot = v; + } + } + } + let [h, m, s, ms] = fields; + let date = make_date(day(t), make_time(h, m, s, ms)); + let u = time_clip(if local { interp.utc(date) } else { date }); + set_this_time(interp, this, u) +} + +/// The date fields a setter starts at. +#[derive(Clone, Copy)] +enum DateField { + Year = 0, + Month = 1, + Date = 2, +} + +/// `setFullYear`, `setMonth` and `setDate`, local or UTC (sections 15.9.5.36 to 15.9.5.41). +fn set_date_fields(interp: &mut Interp, this: &Value, args: &[Value], local: bool, first: DateField, count: usize) -> JsResult { + let tv = this_time(interp, this)?; + let mut t = if local { interp.local_time(tv) } else { tv }; + // setFullYear starts from +0 on an invalid date. + if matches!(first, DateField::Year) && t.is_nan() { + t = 0.0; + } + let mut fields = [year_from_time(t), month_from_time(t), date_from_time(t)]; + let first = first as usize; + for k in 0..count { + let given = k == 0 || args.get(k).is_some(); + if given { + let v = interp.to_number(&arg(args, k))?; + if let Some(slot) = fields.get_mut(first + k) { + *slot = v; + } + } + } + let [y, m, d] = fields; + let date = make_date(make_day(y, m, d), time_within_day(t)); + let u = time_clip(if local { interp.utc(date) } else { date }); + set_this_time(interp, this, u) +} + +macro_rules! setters { + ($($name:ident, $f:ident, $local:expr, $field:expr, $count:expr;)*) => { + $(fn $name(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + $f(interp, &this, args, $local, $field, $count) + })* + }; +} + +setters! { + set_milliseconds, set_time_fields, true, TimeField::Ms, 1; + set_utc_milliseconds, set_time_fields, false, TimeField::Ms, 1; + set_seconds, set_time_fields, true, TimeField::Second, 2; + set_utc_seconds, set_time_fields, false, TimeField::Second, 2; + set_minutes, set_time_fields, true, TimeField::Minute, 3; + set_utc_minutes, set_time_fields, false, TimeField::Minute, 3; + set_hours, set_time_fields, true, TimeField::Hour, 4; + set_utc_hours, set_time_fields, false, TimeField::Hour, 4; + set_date, set_date_fields, true, DateField::Date, 1; + set_utc_date, set_date_fields, false, DateField::Date, 1; + set_month, set_date_fields, true, DateField::Month, 2; + set_utc_month, set_date_fields, false, DateField::Month, 2; + set_full_year, set_date_fields, true, DateField::Year, 3; + set_utc_full_year, set_date_fields, false, DateField::Year, 3; +} + +/// `setYear` (Annex B.2.5). +fn set_year(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let tv = this_time(interp, &this)?; + let t = if tv.is_nan() { 0.0 } else { interp.local_time(tv) }; + let y = interp.to_number(&arg(args, 0))?; + if y.is_nan() { + return set_this_time(interp, &this, f64::NAN); + } + let yi = to_integer(y); + let yr = if (0.0..=99.0).contains(&yi) { 1900.0 + yi } else { y }; + let d = make_day(yr, month_from_time(t), date_from_time(t)); + let u = time_clip(interp.utc(make_date(d, time_within_day(t)))); + set_this_time(interp, &this, u) +} + +#[cfg(test)] +mod tests { + use super::{date_from_time, first_day_of_month, iso_text, make_date, make_day, make_time, month_from_time, parse_iso, week_day, year_from_time}; + + #[test] + fn time_values_split_into_fields_and_back() { + // 2000-02-29 is a leap day; 1969-12-31 is before the epoch. + let cases: &[(f64, f64, f64, f64)] = &[(2000.0, 1.0, 29.0, 2.0), (1969.0, 11.0, 31.0, 3.0), (1970.0, 0.0, 1.0, 4.0), (-1.0, 0.0, 1.0, 5.0)]; + for &(y, m, d, wd) in cases { + let t = make_date(make_day(y, m, d), make_time(13.0, 5.0, 7.0, 9.0)); + assert_eq!((year_from_time(t), month_from_time(t), date_from_time(t), week_day(t)), (y, m, d, wd), "{y}-{m}-{d}"); + } + assert_eq!(first_day_of_month(1970.0, 0), Some(0.0)); + assert_eq!(make_day(2001.0, -1.0, 1.0), make_day(2000.0, 11.0, 1.0)); + // The ends of the time value range lie past chrono's years; whole 400-year cycles reach them. + assert_eq!(make_day(275_760.0, 8.0, 13.0), 1e8); + assert_eq!(make_day(-271_821.0, 3.0, 20.0), -1e8); + assert_eq!((year_from_time(8.64e15), month_from_time(8.64e15), date_from_time(8.64e15)), (275_760.0, 8.0, 13.0)); + assert_eq!((year_from_time(-8.64e15), month_from_time(-8.64e15), date_from_time(-8.64e15)), (-271_821.0, 3.0, 20.0)); + assert_eq!(make_day(1600.0, 1.0, 29.0) + 1.0, make_day(1600.0, 2.0, 1.0)); + assert_eq!(make_day(1900.0, 1.0, 29.0), make_day(1900.0, 2.0, 1.0)); + } + + #[test] + fn the_iso_format_reads_back_what_to_iso_string_writes() { + for t in [0.0, 951_782_400_123.0, -62_198_755_200_000.0, 8.64e15, -8.64e15] { + let text = iso_text(t); + assert_eq!(parse_iso(text.as_bytes()), Some(t), "{text}"); + } + assert_eq!(parse_iso(b"1970"), Some(0.0)); + assert_eq!(parse_iso(b"1970-01-01T00:00+01:00"), Some(-3_600_000.0)); + assert_eq!(parse_iso(b"1970-01-01T24:00:00Z"), Some(86_400_000.0)); + for bad in ["1970-13-01", "1970-02-30", "1970-01-01T24:00:01", "-000000-01-01", "1970-1-1", "1970-01-01T10", "1970-01-01 10:00"] { + assert_eq!(parse_iso(bad.as_bytes()), None, "{bad}"); + } + } +} diff --git a/crates/js/src/heap.rs b/crates/js/src/heap.rs index 82268f489..69b0b4f40 100644 --- a/crates/js/src/heap.rs +++ b/crates/js/src/heap.rs @@ -206,10 +206,12 @@ pub(crate) enum Class { Arguments(Box), /// A `Boolean` object made by ToObject. Boolean, - /// A `Number` object made by ToObject. - Number, + /// A `Number` object and its number. + Number(f64), String(Str), - RegExp, + RegExp(Rc), + /// A `Date` object and its time value. + Date(f64), ForIn(Box), } @@ -223,9 +225,10 @@ impl Class { Class::Error => "Error", Class::Arguments(_) => "Arguments", Class::Boolean => "Boolean", - Class::Number => "Number", + Class::Number(_) => "Number", Class::String(_) => "String", - Class::RegExp => "RegExp", + Class::RegExp(_) => "RegExp", + Class::Date(_) => "Date", } } } @@ -528,6 +531,11 @@ fn trace_object(o: &Object, work: &mut Vec, strings: &mut StringSizes) -> u } Class::Arguments(map) => work.push(map.env.0), Class::String(s) => strings.add(s), + Class::RegExp(re) => { + if strings.seen.insert(Rc::as_ptr(re) as *const () as usize) { + strings.bytes += re.heap_bytes(); + } + } Class::ForIn(state) => { work.extend(state.object.iter().map(|o| o.0)); strings.bytes += state.keys.iter().map(|k| string_bytes(k.len())).sum::(); diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 7df501699..974e0c181 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -68,6 +68,7 @@ pub(crate) struct Intrinsics { pub(crate) number_proto: ObjId, pub(crate) boolean_proto: ObjId, pub(crate) regexp_proto: ObjId, + pub(crate) date_proto: ObjId, pub(crate) error_protos: [ObjId; 7], pub(crate) eval: ObjId, pub(crate) throw_type_error: ObjId, @@ -84,6 +85,7 @@ impl Intrinsics { number_proto: o, boolean_proto: o, regexp_proto: o, + date_proto: o, error_protos: [o; 7], eval: o, throw_type_error: o, @@ -112,6 +114,7 @@ impl Intrinsics { self.number_proto, self.boolean_proto, self.regexp_proto, + self.date_proto, self.eval, self.throw_type_error, ]; @@ -161,6 +164,8 @@ pub(crate) struct Interp { pub(crate) throw_site: Option, /// The last exception a run did not catch. pub(crate) last_thrown: Value, + /// The clock and time zone `Date` reads. + pub(crate) date_host: crate::date_builtins::DateHost, } /// The least heap size a collection waits for. @@ -286,6 +291,7 @@ impl Interp { source_units: 0, throw_site: None, last_thrown: Value::Undefined, + date_host: crate::date_builtins::DateHost::default(), }; crate::builtins::install(&mut interp); interp.gc_threshold = interp.next_threshold(); @@ -412,6 +418,13 @@ impl Interp { Ok(()) } + /// Collects garbage now, for a caller whose work ran out of heap room, and sets when the + /// next collection runs. + pub(crate) fn collect_for_room(&mut self) { + self.collect_garbage(); + self.gc_threshold = self.next_threshold(); + } + /// Charges code compiled at run time to the heap, reserving its estimated size and `extra` /// more first. The code is not reachable from the roots yet, so a collection here leaves it /// out of the live total and the charge adds it. @@ -1334,16 +1347,7 @@ impl Interp { fn op_regexp(&mut self, i: u32) -> JsResult<()> { let Some((source, flags)) = self.frame()?.code.regexps.get(i as usize).cloned() else { return internal("regular expression out of range") }; - let proto = self.intrinsics.regexp_proto; - let r = self.heap.alloc_object(Object::new(Class::RegExp, Some(proto))); - let flag_units = flags.flat(); - let has = |c: u8| flag_units.contains(&u16::from(c)); - let (g, i_, m) = (has(b'g'), has(b'i'), has(b'm')); - self.define(r, key("source"), Prop::Data { value: Value::String(source), flags: 0 })?; - self.define(r, key("global"), Prop::Data { value: Value::Bool(g), flags: 0 })?; - self.define(r, key("ignoreCase"), Prop::Data { value: Value::Bool(i_), flags: 0 })?; - self.define(r, key("multiline"), Prop::Data { value: Value::Bool(m), flags: 0 })?; - self.define(r, key("lastIndex"), Prop::Data { value: Value::Number(0.0), flags: WRITABLE })?; + let r = crate::regexp_builtins::new_regexp(self, &source, &flags)?; self.push(Value::Object(r)) } @@ -1928,7 +1932,9 @@ impl Interp { #[allow(clippy::wrong_self_convention)] pub(crate) fn to_primitive(&mut self, v: &Value, hint: Hint) -> JsResult { let Value::Object(o) = v else { return Ok(v.clone()) }; - let order = if hint == Hint::String { ["toString", "valueOf"] } else { ["valueOf", "toString"] }; + // Without a hint, a Date object converts as with the String hint. + let date = hint == Hint::Default && matches!(self.heap.object(*o).map(|ob| &ob.class), Some(Class::Date(_))); + let order = if hint == Hint::String || date { ["toString", "valueOf"] } else { ["valueOf", "toString"] }; for name in order { let f = self.get_from(*o, &key(name), v)?; if self.is_callable(&f) { @@ -2002,7 +2008,7 @@ impl Interp { Value::Object(o) => return Ok(*o), Value::Undefined | Value::Null => return Err(self.type_error("cannot convert undefined or null to an object")), Value::Bool(_) => (Class::Boolean, self.intrinsics.boolean_proto), - Value::Number(_) => (Class::Number, self.intrinsics.number_proto), + Value::Number(n) => (Class::Number(*n), self.intrinsics.number_proto), Value::String(s) => (Class::String(s.clone()), self.intrinsics.string_proto), }; Ok(self.heap.alloc_object(Object::new(class, Some(proto)))) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 9c0c192aa..3c36efb13 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -23,9 +23,13 @@ //! verifier, and runs it on an interpreter that keeps JavaScript calls on its own frame stack, so //! deep recursion in a script does not use the native stack. Objects and environments live in an //! arena with a mark-and-sweep collector; strings built by repeated `+` are flattened and dropped -//! with loops. The realm provides the language (sections 10 to 14 of ECMA-262 5.1) and a minimal -//! set of built-ins: `Object` and `Object.prototype`'s basic methods, `Function` with -//! `Function.prototype.call` and `apply`, `eval`, and the error constructors. Every run is bounded +//! with loops. The realm provides the language (sections 10 to 14 of ECMA-262 5.1) and these +//! built-ins: `Object` and `Object.prototype`'s basic methods, `Function` with +//! `Function.prototype.call` and `apply`, `eval`, the error constructors, and `String`, `Number`, +//! `RegExp` and `Date` (sections 15.5, 15.7, 15.9 and 15.10). Regular expressions run on a +//! backtracking matcher that keeps its choice points on its own stack and takes fuel for every +//! step; `Date` reads the clock and time zone the host sets with [`Realm::set_clock`] and +//! [`Realm::set_time_zone`]. Every run is bounded //! by [`Limits`]: instructions run (fuel), heap bytes, frames, nested host calls, and an //! [`Interrupt`] flag another thread can set. A run that reaches a limit ends with //! [`RunErrorKind::Limit`], which a script's `try` cannot catch. @@ -36,14 +40,19 @@ mod builtins; mod bytecode; mod chars; mod compiler; +mod date_builtins; mod error; mod heap; mod interp; mod lexer; mod number; +mod number_builtins; mod parser; mod realm; mod regexp; +mod regexp_builtins; +mod regexp_vm; +mod string_builtins; mod token; mod value; @@ -173,3 +182,5 @@ impl LineTable { mod interp_tests; #[cfg(test)] mod tests; +#[cfg(test)] +mod text_tests; diff --git a/crates/js/src/number.rs b/crates/js/src/number.rs index 4afd48076..e7782ac8e 100644 --- a/crates/js/src/number.rs +++ b/crates/js/src/number.rs @@ -1,4 +1,7 @@ -//! Number to string conversion (ECMA-262 5.1 section 9.8.1), for numeric property names. +//! Number to string conversion (ECMA-262 5.1 section 9.8.1), for numeric property names, and the +//! formats of `Number.prototype`'s `toString(radix)`, `toFixed`, `toExponential` and +//! `toPrecision` (section 15.7.4). The fixed, exponential and precision formats round the +//! number's exact decimal value, ties away from zero, as the specification's "larger n" says. /// The string ToString gives for the number `x`: the shortest decimal digits that read back as /// `x`, laid out as section 9.8.1 says. @@ -38,9 +41,310 @@ pub fn to_string(x: f64) -> String { } } +/// The exact decimal digits of a positive finite `x`, without trailing zeros, and the position of +/// the decimal point: `x = 0.d1d2… × 10^point`. +fn exact_digits(x: f64) -> (Vec, i64) { + // A double's exact decimal expansion has at most 767 significant digits. + let sci = format!("{x:.800e}"); + let (mantissa, exp) = sci.split_once('e').unwrap_or((&sci, "0")); + let mut digits: Vec = mantissa.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect(); + while digits.len() > 1 && digits.last() == Some(&0) { + digits.pop(); + } + (digits, exp.parse::().unwrap_or(0) + 1) +} + +/// The shortest digits that read back as a positive finite `x`, and the decimal point's position. +fn shortest_digits(x: f64) -> (Vec, i64) { + let sci = format!("{x:e}"); + let (mantissa, exp) = sci.split_once('e').unwrap_or((&sci, "0")); + (mantissa.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect(), exp.parse::().unwrap_or(0) + 1) +} + +/// Rounds `digits` (a number `0.d1d2… × 10^point`) to its first `keep` digits, a half rounding +/// up. Returns exactly `keep` digits and the new point; `keep` may be 0 or negative, which leaves +/// either nothing or a single `1` one place up. +fn round_digits(digits: &[u8], point: i64, keep: i64) -> (Vec, i64) { + if keep < 0 { + return (Vec::new(), point); + } + let k = usize::try_from(keep).unwrap_or(usize::MAX); + if digits.len() <= k { + let mut out = digits.to_vec(); + out.resize(k, 0); + return (out, point); + } + let mut out: Vec = digits.get(..k).unwrap_or_default().to_vec(); + if digits.get(k).is_some_and(|&d| d >= 5) { + let mut i = out.len(); + loop { + if i == 0 { + out.insert(0, 1); + out.truncate(k.max(1)); + return (out, point + 1); + } + i -= 1; + match out.get_mut(i) { + Some(d) if *d == 9 => *d = 0, + Some(d) => { + *d += 1; + break; + } + None => break, + } + } + } + (out, point) +} + +fn digits_text(digits: &[u8]) -> String { + digits.iter().map(|d| char::from(b'0' + d)).collect() +} + +/// `Number.prototype.toFixed` (section 15.7.4.5) for `f` digits after the point, 0 to 20. +pub(crate) fn to_fixed(x: f64, f: usize) -> String { + if x.is_nan() { + return "NaN".into(); + } + if x.abs() >= 1e21 { + return to_string(x); + } + let sign = if x < 0.0 { "-" } else { "" }; + let x = x.abs(); + let fi = i64::try_from(f).unwrap_or(0); + let n = if x == 0.0 { + vec![0] + } else { + let (digits, point) = exact_digits(x); + let (mut n, new_point) = round_digits(&digits, point, point + fi); + if n.is_empty() { + n.push(0); + } else if new_point > point { + // A carry past the first digit leaves one digit more than kept. + n.push(0); + n.truncate(usize::try_from(new_point + fi).unwrap_or(n.len())); + } + n + }; + let mut m = digits_text(&n); + let m_trim = m.trim_start_matches('0'); + m = if m_trim.is_empty() { "0".into() } else { m_trim.into() }; + if f == 0 { + return format!("{sign}{m}"); + } + if m.len() <= f { + m = format!("{}{m}", "0".repeat(f + 1 - m.len())); + } + let (int, frac) = m.split_at(m.len() - f); + format!("{sign}{int}.{frac}") +} + +/// `Number.prototype.toExponential` (section 15.7.4.6) for `f` digits after the point (0 to 20), +/// or as many as the number needs when `f` is `None`. +pub(crate) fn to_exponential(x: f64, f: Option) -> String { + if !x.is_finite() { + return to_string(x); + } + let sign = if x < 0.0 { "-" } else { "" }; + let x = x.abs(); + let (digits, e) = if x == 0.0 { + (vec![0; f.unwrap_or(0) + 1], 0) + } else { + match f { + None => { + let (d, point) = shortest_digits(x); + (d, point - 1) + } + Some(f) => { + let (d, point) = exact_digits(x); + let (d, point) = round_digits(&d, point, i64::try_from(f).unwrap_or(0) + 1); + (d, point - 1) + } + } + }; + let text = digits_text(&digits); + let (first, rest) = text.split_at(1.min(text.len())); + let mantissa = if rest.is_empty() { first.to_string() } else { format!("{first}.{rest}") }; + let esign = if e < 0 { '-' } else { '+' }; + format!("{sign}{mantissa}e{esign}{}", e.abs()) +} + +/// `Number.prototype.toPrecision` (section 15.7.4.7) for `p` significant digits, 1 to 21. +pub(crate) fn to_precision(x: f64, p: usize) -> String { + if !x.is_finite() { + return to_string(x); + } + let sign = if x < 0.0 { "-" } else { "" }; + let x = x.abs(); + let pi = i64::try_from(p).unwrap_or(1); + let (digits, e) = if x == 0.0 { + (vec![0; p], 0) + } else { + let (d, point) = exact_digits(x); + let (d, point) = round_digits(&d, point, pi); + (d, point - 1) + }; + let m = digits_text(&digits); + if e < -6 || e >= pi { + let (first, rest) = m.split_at(1.min(m.len())); + let mantissa = if rest.is_empty() { first.to_string() } else { format!("{first}.{rest}") }; + let esign = if e < 0 { '-' } else { '+' }; + return format!("{sign}{mantissa}e{esign}{}", e.abs()); + } + if e == pi - 1 { + return format!("{sign}{m}"); + } + if e >= 0 { + let (int, frac) = m.split_at(usize::try_from(e + 1).unwrap_or(0).min(m.len())); + return format!("{sign}{int}.{frac}"); + } + format!("{sign}0.{}{m}", "0".repeat(usize::try_from(-(e + 1)).unwrap_or(0))) +} + +/// `Number.prototype.toString(radix)` (section 15.7.4.2) for a radix of 2 to 36 other than 10: +/// the integer part exactly, and fraction digits until the number would read back unchanged. +pub(crate) fn to_radix_string(x: f64, radix: u32) -> String { + if !x.is_finite() || x == 0.0 { + return to_string(x); + } + const CHARS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + let r = f64::from(radix); + let sign = if x < 0.0 { "-" } else { "" }; + let x = x.abs(); + let mut integer = x.floor(); + let mut fraction = x - integer; + // Half the distance to the next double: digits past this resolution are not needed. + let next = f64::from_bits(x.to_bits() + 1); + let mut delta = (0.5 * (next - x)).max(f64::from_bits(1)); + let mut frac_digits: Vec = Vec::new(); + if fraction >= delta { + loop { + fraction *= r; + delta *= r; + let digit = fraction.floor(); + frac_digits.push(digit as u8); + fraction -= digit; + if (fraction > 0.5 || (fraction == 0.5 && (digit as u8) & 1 == 1)) && fraction + delta > 1.0 { + // Round up, carrying into the integer part when every digit is the largest. + loop { + match frac_digits.last_mut() { + None => { + integer += 1.0; + break; + } + Some(d) if u32::from(*d) + 1 == radix => { + frac_digits.pop(); + } + Some(d) => { + *d += 1; + break; + } + } + } + break; + } + if fraction < delta { + break; + } + } + } + let mut int_digits: Vec = Vec::new(); + // Digits below the double's precision are zeros. + while (integer / r) >= 9_007_199_254_740_992.0 { + integer /= r; + int_digits.push(0); + } + loop { + let rem = integer % r; + int_digits.push(rem as u8); + integer = (integer - rem) / r; + if integer <= 0.0 { + break; + } + } + let mut out = String::from(sign); + out.extend(int_digits.iter().rev().map(|&d| char::from(CHARS.get(usize::from(d)).copied().unwrap_or(b'0')))); + if !frac_digits.is_empty() { + out.push('.'); + out.extend(frac_digits.iter().map(|&d| char::from(CHARS.get(usize::from(d)).copied().unwrap_or(b'0')))); + } + out +} + #[cfg(test)] mod tests { - use super::to_string; + use super::{to_exponential, to_fixed, to_precision, to_radix_string, to_string}; + + #[test] + fn fixed_exponential_and_precision_round_the_exact_value_with_ties_away_from_zero() { + let fixed: &[(f64, usize, &str)] = &[ + (0.5, 0, "1"), + (2.5, 0, "3"), + (1.45, 1, "1.4"), + (1.255, 2, "1.25"), + (1.005, 2, "1.00"), + (0.1, 20, "0.10000000000000000555"), + (123.456, 2, "123.46"), + (-1.5, 0, "-2"), + (-0.0000001, 2, "-0.00"), + (-0.0, 2, "0.00"), + (0.0, 0, "0"), + (99.995, 2, "100.00"), + (9.5, 0, "10"), + (1e21, 2, "1e+21"), + (1000000000000000128.0, 0, "1000000000000000128"), + (f64::NAN, 2, "NaN"), + ]; + for (x, f, want) in fixed { + assert_eq!(to_fixed(*x, *f), *want, "({x}).toFixed({f})"); + } + let exp: &[(f64, Option, &str)] = &[ + (123456.0, Some(2), "1.23e+5"), + (123456.0, None, "1.23456e+5"), + (0.00015, Some(1), "1.5e-4"), + (1.25, Some(1), "1.3e+0"), + (0.0, Some(2), "0.00e+0"), + (0.0, None, "0e+0"), + (-6.9e-11, Some(1), "-6.9e-11"), + (9.99, Some(1), "1.0e+1"), + (f64::INFINITY, Some(2), "Infinity"), + ]; + for (x, f, want) in exp { + assert_eq!(to_exponential(*x, *f), *want, "({x}).toExponential({f:?})"); + } + let prec: &[(f64, usize, &str)] = &[ + (123.456, 4, "123.5"), + (0.000123, 2, "0.00012"), + (0.0000001234, 2, "1.2e-7"), + (123456.0, 2, "1.2e+5"), + (123456.0, 6, "123456"), + (0.0, 3, "0.00"), + (1.0, 1, "1"), + (99.5, 2, "1.0e+2"), + (1e21, 21, "1.00000000000000000000e+21"), + ]; + for (x, p, want) in prec { + assert_eq!(to_precision(*x, *p), *want, "({x}).toPrecision({p})"); + } + } + + #[test] + fn radix_strings_give_exact_integers_and_enough_fraction_digits() { + let cases: &[(f64, u32, &str)] = &[ + (255.0, 16, "ff"), + (255.0, 2, "11111111"), + (-255.0, 36, "-73"), + (0.5, 2, "0.1"), + (0.5, 16, "0.8"), + (3.75, 2, "11.11"), + (2f64.powi(60), 16, "1000000000000000"), + (1e21, 36, "5v1j4f4ds7c000"), + (0.1, 3, "0.0022002200220022002200220022002201"), + ]; + for (x, r, want) in cases { + assert_eq!(to_radix_string(*x, *r), *want, "({x}).toString({r})"); + } + } #[test] fn numbers_print_as_section_9_8_1_lays_them_out() { diff --git a/crates/js/src/number_builtins.rs b/crates/js/src/number_builtins.rs new file mode 100644 index 000000000..59ea12813 --- /dev/null +++ b/crates/js/src/number_builtins.rs @@ -0,0 +1,127 @@ +//! `Number` (ECMA-262 5.1 section 15.7): the constructor, its constants and the methods of +//! `Number.prototype`. The formats are in [`crate::number`]. + +use crate::builtins::{arg, constructor, data, method, native}; +use crate::heap::{Class, NativeKind, Object}; +use crate::interp::{ErrorType, Interp, JsResult}; +use crate::number; +use crate::string_builtins::{reserve_units, to_integer}; +use crate::value::{Str, Value}; + +pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { + let proto = interp.intrinsics.number_proto; + let ctor = native(interp, "Number", number_ctor, 1, NativeKind::Plain, true)?; + constructor(interp, "Number", ctor, proto)?; + let constants = [ + ("MAX_VALUE", f64::MAX), + ("MIN_VALUE", f64::from_bits(1)), + ("NaN", f64::NAN), + ("NEGATIVE_INFINITY", f64::NEG_INFINITY), + ("POSITIVE_INFINITY", f64::INFINITY), + ]; + for (name, v) in constants { + data(interp, ctor, name, Value::Number(v), 0)?; + } + method(interp, proto, "toString", to_string, 1)?; + method(interp, proto, "toLocaleString", to_locale_string, 0)?; + method(interp, proto, "valueOf", value_of, 0)?; + method(interp, proto, "toFixed", to_fixed, 1)?; + method(interp, proto, "toExponential", to_exponential, 1)?; + method(interp, proto, "toPrecision", to_precision, 1) +} + +/// `Number(value)` and `new Number(value)` (sections 15.7.1 and 15.7.2). +fn number_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) -> JsResult { + let n = match args.first() { + Some(v) => interp.to_number(v)?, + None => 0.0, + }; + if !construct { + return Ok(Value::Number(n)); + } + let proto = interp.intrinsics.number_proto; + Ok(Value::Object(interp.heap.alloc_object(Object::new(Class::Number(n), Some(proto))))) +} + +/// The number a `Number.prototype` method works on: a number, or a Number object's number. +fn this_number(interp: &mut Interp, this: &Value, name: &str) -> JsResult { + match this { + Value::Number(n) => Ok(*n), + Value::Object(o) => match interp.heap.object(*o).map(|o| &o.class) { + Some(Class::Number(n)) => Ok(*n), + _ => Err(interp.type_error(&format!("Number.prototype.{name} was called on a value that is not a number"))), + }, + _ => Err(interp.type_error(&format!("Number.prototype.{name} was called on a value that is not a number"))), + } +} + +fn text(interp: &mut Interp, s: &str) -> JsResult { + reserve_units(interp, s.len())?; + Ok(Value::String(Str::from(s))) +} + +/// `toString(radix)` (section 15.7.4.2). +fn to_string(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let x = this_number(interp, &this, "toString")?; + let radix = match arg(args, 0) { + Value::Undefined => 10.0, + r => to_integer(interp.to_number(&r)?), + }; + if !(2.0..=36.0).contains(&radix) { + return Err(interp.throw(ErrorType::Range, "the radix must be between 2 and 36")); + } + let s = if radix == 10.0 { number::to_string(x) } else { number::to_radix_string(x, radix as u32) }; + text(interp, &s) +} + +fn to_locale_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let x = this_number(interp, &this, "toLocaleString")?; + text(interp, &number::to_string(x)) +} + +fn value_of(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + this_number(interp, &this, "valueOf").map(Value::Number) +} + +/// `toFixed(fractionDigits)` (section 15.7.4.5). +fn to_fixed(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let x = this_number(interp, &this, "toFixed")?; + let f = to_integer(interp.to_number(&arg(args, 0))?); + if !(0.0..=20.0).contains(&f) { + return Err(interp.throw(ErrorType::Range, "toFixed() digits must be between 0 and 20")); + } + text(interp, &number::to_fixed(x, f as usize)) +} + +/// `toExponential(fractionDigits)` (section 15.7.4.6). +fn to_exponential(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let x = this_number(interp, &this, "toExponential")?; + let digits = arg(args, 0); + let f = to_integer(interp.to_number(&digits)?); + if !x.is_finite() { + return text(interp, &number::to_string(x)); + } + let f = match digits { + Value::Undefined => None, + _ if !(0.0..=20.0).contains(&f) => return Err(interp.throw(ErrorType::Range, "toExponential() digits must be between 0 and 20")), + _ => Some(f as usize), + }; + text(interp, &number::to_exponential(x, f)) +} + +/// `toPrecision(precision)` (section 15.7.4.7). +fn to_precision(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let x = this_number(interp, &this, "toPrecision")?; + let precision = arg(args, 0); + if matches!(precision, Value::Undefined) { + return text(interp, &number::to_string(x)); + } + let p = to_integer(interp.to_number(&precision)?); + if !x.is_finite() { + return text(interp, &number::to_string(x)); + } + if !(1.0..=21.0).contains(&p) { + return Err(interp.throw(ErrorType::Range, "toPrecision() argument must be between 1 and 21")); + } + text(interp, &number::to_precision(x, p as usize)) +} diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index 29d3aacb2..eee516f5b 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -209,6 +209,21 @@ impl Realm { let _ = interp.define(global, key(name), Prop::Data { value: v, flags: HIDDEN }); } + /// Sets the clock `Date.now()` and `new Date()` read: the current time in milliseconds since + /// 1970-01-01 UTC. By default it is the system clock; a wasm build has none, and its default + /// gives NaN. + pub fn set_clock(&mut self, clock: impl Fn() -> f64 + 'static) { + self.interp.date_host.clock = std::rc::Rc::new(clock); + } + + /// Sets the time zone `Date` uses for local time: `offset` gives local time's offset from UTC + /// in milliseconds at a time value (milliseconds since 1970-01-01 UTC), so it can follow + /// daylight saving time. An offset that is not finite or is a day or more counts as 0. By + /// default local time is UTC. + pub fn set_time_zone(&mut self, offset: impl Fn(f64) -> f64 + 'static) { + self.interp.date_host.offset = std::rc::Rc::new(offset); + } + /// Collects garbage now and returns the heap's estimated size in bytes afterwards. pub fn collect_garbage(&mut self) -> usize { self.interp.collect_garbage(); diff --git a/crates/js/src/regexp_builtins.rs b/crates/js/src/regexp_builtins.rs new file mode 100644 index 000000000..a882efd3a --- /dev/null +++ b/crates/js/src/regexp_builtins.rs @@ -0,0 +1,269 @@ +//! `RegExp` (ECMA-262 5.1 section 15.10.3 to 15.10.7): the constructor, regular expression +//! literals' objects, and `exec`, `test` and `toString`. Matching is in [`crate::regexp_vm`]. + +use std::rc::Rc; + +use crate::LimitKind; +use crate::builtins::{arg, constructor, method, native}; +use crate::heap::{Class, NativeKind, OBJECT_BYTES, OPEN, ObjId, Object, PROP_BYTES, Prop, WRITABLE, key, string_bytes}; +use crate::interp::{Abrupt, ErrorType, Interp, JsResult}; +use crate::regexp_vm::{CompileFail, Flags, Matcher, Regex, compile, parse_flags, use_fuel_n}; +use crate::string_builtins::{new_array, reserve_units, rooted, set_element, substring_value, to_integer}; +use crate::value::{Str, Value}; + +pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { + let proto = interp.intrinsics.regexp_proto; + // The prototype is itself a regular expression object, matching the empty string. + let empty = compile_regex(interp, &[], &[])?; + if let Some(p) = interp.heap.object_mut(proto) { + p.class = Class::RegExp(empty); + } + define_flags(interp, proto, Str::from("(?:)"), false, false, false)?; + let ctor = native(interp, "RegExp", regexp_ctor, 2, NativeKind::Plain, true)?; + constructor(interp, "RegExp", ctor, proto)?; + method(interp, proto, "exec", exec, 1)?; + method(interp, proto, "test", test, 1)?; + method(interp, proto, "toString", to_string, 0) +} + +/// The object and the compiled form of a RegExp object. +pub(crate) fn regexp_of(interp: &Interp, v: &Value) -> Option<(ObjId, Rc)> { + let o = v.as_object()?; + match &interp.heap.object(o)?.class { + Class::RegExp(re) => Some((o, re.clone())), + _ => None, + } +} + +/// Checks and compiles a pattern, throwing a `SyntaxError` for a pattern or flags that are not +/// valid. The compiled form's bytes are reserved on the heap. +fn compile_regex(interp: &mut Interp, pattern: &[u16], flags: &[u16]) -> JsResult> { + let flags: Flags = match parse_flags(flags) { + Ok(f) => f, + Err(m) => return Err(interp.throw(ErrorType::Syntax, &m)), + }; + let depth = interp.options.max_depth; + if let Err(m) = crate::regexp::check(pattern, depth) { + return Err(interp.throw(ErrorType::Syntax, &format!("invalid regular expression: {m}"))); + } + // A compile that runs out of room collects garbage and tries once more with the room it frees. + let mut collected = false; + let re = loop { + let room = interp.limits.heap_bytes.saturating_sub(interp.heap.bytes); + match compile(pattern, flags, depth, room, &mut |n| use_fuel_n(interp, n)) { + Ok(re) => break re, + Err(CompileFail::Abrupt(Abrupt::Limit(LimitKind::Heap))) if !collected => { + interp.collect_for_room(); + collected = true; + } + Err(CompileFail::Syntax(m)) => return Err(interp.throw(ErrorType::Syntax, &format!("invalid regular expression: {m}"))), + Err(CompileFail::Abrupt(a)) => return Err(a), + } + }; + let bytes = re.heap_bytes(); + interp.reserve_heap(bytes)?; + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + Ok(Rc::new(re)) +} + +/// A new RegExp object whose `source` property is `source` (section 15.10.4.1, steps 7 to 12). +fn make(interp: &mut Interp, source: Str, pattern: &[u16], flags: &[u16]) -> JsResult { + let re = compile_regex(interp, pattern, flags)?; + interp.reserve_heap(OBJECT_BYTES + 5 * (PROP_BYTES + string_bytes(10)))?; + let (g, i, m) = (parse_flags(flags).unwrap_or_default().global, re.ignore_case, re.multiline); + let proto = interp.intrinsics.regexp_proto; + let r = interp.heap.alloc_object(Object::new(Class::RegExp(re), Some(proto))); + define_flags(interp, r, source, g, i, m)?; + Ok(r) +} + +/// Defines a RegExp object's `source`, `global`, `ignoreCase`, `multiline` and `lastIndex` +/// (section 15.10.7). +fn define_flags(interp: &mut Interp, r: ObjId, source: Str, g: bool, i: bool, m: bool) -> JsResult<()> { + interp.define(r, key("source"), Prop::Data { value: Value::String(source), flags: 0 })?; + interp.define(r, key("global"), Prop::Data { value: Value::Bool(g), flags: 0 })?; + interp.define(r, key("ignoreCase"), Prop::Data { value: Value::Bool(i), flags: 0 })?; + interp.define(r, key("multiline"), Prop::Data { value: Value::Bool(m), flags: 0 })?; + interp.define(r, key("lastIndex"), Prop::Data { value: Value::Number(0.0), flags: WRITABLE })?; + Ok(()) +} + +/// The object a regular expression literal evaluates to (section 7.8.5): a new RegExp object +/// each time. +pub(crate) fn new_regexp(interp: &mut Interp, source: &Str, flags: &Str) -> JsResult { + let pattern = source.flat(); + make(interp, source.clone(), &pattern, &flags.flat()) +} + +/// The `source` text for a pattern given to the constructor: `(?:)` for the empty pattern, and +/// `/` and line terminators escaped, so that `/source/` reads back as the same pattern. Its +/// length is reserved on the heap before it is built. +pub(crate) fn source_text(interp: &mut Interp, pattern: &[u16]) -> JsResult { + if pattern.is_empty() { + return Ok(Str::from("(?:)")); + } + let mut len = 0usize; + source_units(pattern, |u| len = len.saturating_add(u.len())); + reserve_units(interp, len)?; + let mut out = Vec::with_capacity(len); + source_units(pattern, |u| out.extend_from_slice(u)); + Ok(Str::from_units(out)) +} + +/// Passes the units of [`source_text`] for a non-empty pattern to `emit`, a piece at a time. +fn source_units(pattern: &[u16], mut emit: impl FnMut(&[u16])) { + const BACKSLASH: u16 = 0x5C; + let mut in_class = false; + let mut i = 0; + while let Some(&u) = pattern.get(i) { + match u { + 0x5C => { + emit(pattern.get(i..i + 2).unwrap_or(&[BACKSLASH])); + i += 2; + continue; + } + 0x5B => in_class = true, + 0x5D => in_class = false, + 0x2F if !in_class => emit(&[BACKSLASH]), + _ => {} + } + match u { + 0x0A => emit(&[BACKSLASH, 0x6E]), + 0x0D => emit(&[BACKSLASH, 0x72]), + 0x2028 => emit(&[BACKSLASH, 0x75, 0x32, 0x30, 0x32, 0x38]), + 0x2029 => emit(&[BACKSLASH, 0x75, 0x32, 0x30, 0x32, 0x39]), + _ => emit(&[u]), + } + i += 1; + } +} + +/// `new RegExp(pattern, flags)` (section 15.10.4.1). +pub(crate) fn new_regexp_from(interp: &mut Interp, pattern: &Value, flags: &Value) -> JsResult { + if let Some((o, _)) = regexp_of(interp, pattern) { + if !matches!(flags, Value::Undefined) { + return Err(interp.type_error("flags cannot be given with a RegExp object")); + } + let pv = Value::Object(o); + let source = interp.get_value(&pv, &key("source"))?; + let source = interp.to_str(&source)?; + let mut f = String::new(); + for (name, c) in [("global", 'g'), ("ignoreCase", 'i'), ("multiline", 'm')] { + if interp.get_value(&pv, &key(name))?.truthy() { + f.push(c); + } + } + let units = source.flat(); + let pattern: &[u16] = if is_empty_group(&units) { &[] } else { &units }; + return make(interp, source.clone(), pattern, &f.encode_utf16().collect::>()); + } + let p = if matches!(pattern, Value::Undefined) { Str::from("") } else { interp.to_str(pattern)? }; + let f = if matches!(flags, Value::Undefined) { Str::from("") } else { interp.to_str(flags)? }; + let units = p.flat(); + let source = source_text(interp, &units)?; + make(interp, source, &units, &f.flat()) +} + +fn is_empty_group(units: &[u16]) -> bool { + units.iter().copied().eq("(?:)".encode_utf16()) +} + +/// `RegExp(pattern, flags)` and `new RegExp(pattern, flags)` (sections 15.10.3 and 15.10.4). +fn regexp_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) -> JsResult { + let (pattern, flags) = (arg(args, 0), arg(args, 1)); + if !construct && regexp_of(interp, &pattern).is_some() && matches!(flags, Value::Undefined) { + return Ok(pattern); + } + new_regexp_from(interp, &pattern, &flags).map(Value::Object) +} + +fn this_regexp(interp: &mut Interp, this: &Value, name: &str) -> JsResult { + match regexp_of(interp, this) { + Some((o, _)) => Ok(o), + None => Err(interp.type_error(&format!("RegExp.prototype.{name} was called on a value that is not a RegExp object"))), + } +} + +/// The matching steps of `exec` (section 15.10.6.2, steps 3 to 11): the positions of the match +/// and of its groups ([`crate::regexp_vm::NONE`] for a group that did not take part), or `None`. +/// Reads `lastIndex` and `global`, and sets `lastIndex` as `exec` does. +pub(crate) fn exec_raw(interp: &mut Interp, rx: ObjId, s: &Str) -> JsResult>> { + let Some((_, re)) = regexp_of(interp, &Value::Object(rx)) else { return Err(interp.type_error("not a RegExp object")) }; + let rv = Value::Object(rx); + let last_index = key("lastIndex"); + let li = interp.get_value(&rv, &last_index)?; + let li = to_integer(interp.to_number(&li)?); + let global = interp.get_value(&rv, &key("global"))?.truthy(); + let units = s.flat(); + let i = if global { li } else { 0.0 }; + let found = if i < 0.0 || i > units.len() as f64 { + None + } else { + let mut m = Matcher::new(&re, interp)?; + if m.search(interp, &units, i as usize)? { Some(m.caps) } else { None } + }; + match found { + None => { + interp.put(rx, last_index, Value::Number(0.0), true)?; + Ok(None) + } + Some(caps) => { + if global { + let end = caps.get(1).copied().unwrap_or(0); + interp.put(rx, last_index, Value::Number(end as f64), true)?; + } + Ok(Some(caps)) + } + } +} + +/// `exec`'s result for `rx` on `s`: the match array, or `null`. +pub(crate) fn exec_value(interp: &mut Interp, rx: ObjId, s: &Str) -> JsResult { + rooted(interp, |interp| { + let Some(caps) = exec_raw(interp, rx, s)? else { return Ok(Value::Null) }; + let units = s.flat(); + let a = new_array(interp); + interp.reserve_heap(2 * (PROP_BYTES + string_bytes(5)))?; + let index = caps.first().copied().unwrap_or(0); + interp.define(a, key("index"), Prop::Data { value: Value::Number(index as f64), flags: OPEN })?; + interp.define(a, key("input"), Prop::Data { value: Value::String(s.clone()), flags: OPEN })?; + for (n, pair) in caps.chunks(2).enumerate() { + let v = match pair { + [x, y] if *x != crate::regexp_vm::NONE && *y != crate::regexp_vm::NONE => substring_value(interp, &units, *x, *y)?, + _ => Value::Undefined, + }; + set_element(interp, a, n, v)?; + } + Ok(Value::Object(a)) + }) +} + +/// `RegExp.prototype.exec` (section 15.10.6.2). +fn exec(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let rx = this_regexp(interp, &this, "exec")?; + let s = interp.to_str(&arg(args, 0))?; + exec_value(interp, rx, &s) +} + +/// `RegExp.prototype.test` (section 15.10.6.3). +fn test(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let rx = this_regexp(interp, &this, "test")?; + let s = interp.to_str(&arg(args, 0))?; + Ok(Value::Bool(exec_raw(interp, rx, &s)?.is_some())) +} + +/// `RegExp.prototype.toString` (section 15.10.6.4). +fn to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let rx = this_regexp(interp, &this, "toString")?; + let rv = Value::Object(rx); + let source = interp.get_value(&rv, &key("source"))?; + let source = interp.to_str(&source)?; + let mut flags = String::from("/"); + for (name, c) in [("global", 'g'), ("ignoreCase", 'i'), ("multiline", 'm')] { + if interp.get_value(&rv, &key(name))?.truthy() { + flags.push(c); + } + } + let head = interp.concat(&Str::from("/"), &source)?; + interp.concat(&head, &Str::from(flags.as_str())).map(Value::String) +} diff --git a/crates/js/src/regexp_vm.rs b/crates/js/src/regexp_vm.rs new file mode 100644 index 000000000..8f379d1c0 --- /dev/null +++ b/crates/js/src/regexp_vm.rs @@ -0,0 +1,1188 @@ +//! Compiling and matching regular expressions (ECMA-262 5.1 section 15.10.2, with the lenient +//! patterns of Annex B that [`crate::regexp::check`] accepts). +//! +//! A pattern compiles to a list of instructions for a backtracking matcher. The compiler reads the +//! pattern with a loop and a stack of open groups, and the matcher keeps its choice points and the +//! values it must restore on its own stack, so neither recurses however deeply the groups nest. +//! Quantifiers count their iterations in registers instead of repeating their atom's code. Every +//! instruction run and every choice point taken back costs fuel, and the matcher's stack is +//! charged against the heap limit, so a pattern that backtracks without end stops at a limit. + +use std::collections::HashMap; +use std::sync::OnceLock; + +use crate::LimitKind; +use crate::interp::{Abrupt, Interp, JsResult}; + +/// A capture position that is not set. +pub(crate) const NONE: usize = usize::MAX; + +/// Steps the matcher counts before it takes them from the fuel. +const FUEL_BATCH: u64 = 1024; + +#[derive(Clone, Copy, Debug)] +enum Inst { + /// One code unit, already canonicalized when the pattern ignores case. + Char(u16), + /// `.`: any unit but a line terminator. + Any, + Set(u32), + LineStart, + LineEnd, + /// `\b` (true) or `\B` (false). + WordBoundary(bool), + /// Goes on at the first offset, and at the second when that fails. + Split(i32, i32), + Jump(i32), + GroupStart(u32), + GroupEnd(u32), + /// Sets a quantifier's iteration count to zero. + RepInit(u32), + /// A quantifier's head: its atom follows, then [`Inst::RepEnd`]. `caps` is the range of groups + /// inside the atom, cleared before each iteration; `exit` leads past the atom. + Rep { + reg: u32, + min: u64, + max: u64, + greedy: bool, + caps: (u32, u32), + exit: i32, + }, + RepEnd { + reg: u32, + head: i32, + }, + /// A lookahead: its pattern follows, then [`Inst::LookEnd`]; `end` leads past it. + Look { + negative: bool, + end: i32, + }, + LookEnd, + BackRef(u32), + Match, +} + +/// A character class: sorted, merged ranges of code units. +#[derive(Clone, Debug, Default)] +struct CharSet { + ranges: Vec<(u16, u16)>, + negated: bool, +} + +impl CharSet { + fn contains_raw(&self, u: u16) -> bool { + let i = self.ranges.partition_point(|&(_, hi)| hi < u); + self.ranges.get(i).is_some_and(|&(lo, _)| lo <= u) + } +} + +/// The flags of a regular expression (section 15.10.4.1). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct Flags { + pub(crate) global: bool, + pub(crate) ignore_case: bool, + pub(crate) multiline: bool, +} + +/// Reads flags: each of `g`, `i` and `m` at most once. +pub(crate) fn parse_flags(units: &[u16]) -> Result { + let mut f = Flags::default(); + for &u in units { + let slot = match u { + 0x67 => &mut f.global, + 0x69 => &mut f.ignore_case, + 0x6D => &mut f.multiline, + _ => return Err(format!("invalid regular expression flag '{}'", String::from_utf16_lossy(&[u]))), + }; + if *slot { + return Err(format!("regular expression flag '{}' given twice", String::from_utf16_lossy(&[u]))); + } + *slot = true; + } + Ok(f) +} + +/// A compiled regular expression. +pub(crate) struct Regex { + insts: Vec, + sets: Vec, + /// Capturing groups, not counting the whole match. + pub(crate) groups: usize, + loops: usize, + pub(crate) ignore_case: bool, + pub(crate) multiline: bool, +} + +/// Why a pattern did not compile. +pub(crate) enum CompileFail { + Syntax(String), + Abrupt(Abrupt), +} + +impl From for CompileFail { + fn from(s: String) -> Self { + CompileFail::Syntax(s) + } +} + +impl From<&str> for CompileFail { + fn from(s: &str) -> Self { + CompileFail::Syntax(s.into()) + } +} + +impl Regex { + /// The estimated bytes the compiled form holds. + pub(crate) fn heap_bytes(&self) -> usize { + 64 + size_of::() * self.insts.len() + self.sets.iter().map(set_bytes).sum::() + } + + fn canon(&self, u: u16) -> u16 { + if self.ignore_case { canonicalize(u) } else { u } + } + + fn set_matches(&self, set: &CharSet, u: u16) -> bool { + let found = if self.ignore_case { + let k = canonicalize(u); + match case_groups().get(&k) { + Some(group) => group.iter().any(|&c| set.contains_raw(c)), + None => set.contains_raw(u), + } + } else { + set.contains_raw(u) + }; + found != set.negated + } +} + +// ---- characters ---- + +fn is_line_terminator(u: u16) -> bool { + crate::chars::is_line_terminator(u32::from(u)) +} + +fn is_word_char(u: u16) -> bool { + matches!(u, 0x30..=0x39 | 0x41..=0x5A | 0x5F | 0x61..=0x7A) +} + +/// Canonicalize (section 15.10.2.8): the unit's upper case when that is one unit, unless it +/// would turn a unit from 128 up into one below 128. +fn canonicalize(u: u16) -> u16 { + static TABLE: OnceLock> = OnceLock::new(); + let table = TABLE.get_or_init(|| (0..=u16::MAX).map(upper_unit).collect()); + table.get(usize::from(u)).copied().unwrap_or(u) +} + +fn upper_unit(u: u16) -> u16 { + let Some(c) = char::from_u32(u32::from(u)) else { return u }; + let mut upper = c.to_uppercase(); + match (upper.next(), upper.next()) { + (Some(up), None) => match u16::try_from(u32::from(up)) { + Ok(cu) if !(u >= 128 && cu < 128) => cu, + _ => u, + }, + _ => u, + } +} + +/// For each canonical unit that more than one unit canonicalizes to, those units. +fn case_groups() -> &'static HashMap> { + static GROUPS: OnceLock>> = OnceLock::new(); + GROUPS.get_or_init(|| { + let mut groups: HashMap> = HashMap::new(); + for u in 0..=u16::MAX { + let k = canonicalize(u); + if k != u { + groups.entry(k).or_default().push(u); + } + } + for (k, members) in &mut groups { + if canonicalize(*k) == *k { + members.push(*k); + } + } + groups + }) +} + +fn digit_ranges() -> Vec<(u16, u16)> { + vec![(0x30, 0x39)] +} + +fn word_ranges() -> Vec<(u16, u16)> { + vec![(0x30, 0x39), (0x41, 0x5A), (0x5F, 0x5F), (0x61, 0x7A)] +} + +/// White space and line terminators (`\s`). +fn space_ranges() -> Vec<(u16, u16)> { + let mut out: Vec<(u16, u16)> = Vec::new(); + for u in 0..=u16::MAX { + if crate::value::is_str_space(u) { + match out.last_mut() { + Some((_, hi)) if u32::from(*hi) + 1 == u32::from(u) => *hi = u, + _ => out.push((u, u)), + } + } + } + out +} + +fn complement(ranges: &[(u16, u16)]) -> Vec<(u16, u16)> { + let mut out = Vec::new(); + let mut next: u32 = 0; + for &(lo, hi) in ranges { + if u32::from(lo) > next { + out.push((next as u16, lo - 1)); + } + next = u32::from(hi) + 1; + } + if next <= 0xFFFF { + out.push((next as u16, 0xFFFF)); + } + out +} + +/// Sorts and merges ranges. +fn normalize(mut ranges: Vec<(u16, u16)>) -> Vec<(u16, u16)> { + ranges.sort_unstable(); + let mut out: Vec<(u16, u16)> = Vec::with_capacity(ranges.len()); + for (lo, hi) in ranges { + match out.last_mut() { + Some((_, last)) if u32::from(lo) <= u32::from(*last) + 1 => *last = (*last).max(hi), + _ => out.push((lo, hi)), + } + } + out +} + +/// The ranges of a class escape letter (`d`, `D`, `s`, `S`, `w` or `W`). +fn escape_ranges(letter: u16) -> Option> { + static SPACE: OnceLock> = OnceLock::new(); + let space = || SPACE.get_or_init(space_ranges).clone(); + Some(match letter { + 0x64 => digit_ranges(), + 0x44 => complement(&digit_ranges()), + 0x73 => space(), + 0x53 => complement(&space()), + 0x77 => word_ranges(), + 0x57 => complement(&word_ranges()), + _ => return None, + }) +} + +// ---- compiling ---- + +#[derive(Clone, Copy)] +enum Kind { + Top, + Capture(u32), + Group, + Look(bool), +} + +/// A piece of code being compiled: one instruction, or a finished fragment placed whole, so that +/// closing a group or quantifying it moves one item however much code it holds. +#[derive(Clone, Copy)] +enum Item { + Inst(Inst), + Frag(usize), +} + +/// The finished fragments, with the number of instructions each one flattens to. +#[derive(Default)] +struct Frags { + items: Vec>, + lens: Vec, +} + +impl Frags { + fn len_of(&self, items: &[Item]) -> usize { + items + .iter() + .map(|i| match i { + Item::Inst(_) => 1, + Item::Frag(f) => self.lens.get(*f).copied().unwrap_or(0), + }) + .fold(0usize, usize::saturating_add) + } + + fn add(&mut self, items: Vec) -> Item { + let len = self.len_of(&items); + self.items.push(items); + self.lens.push(len); + Item::Frag(self.items.len() - 1) + } + + /// The instructions of `top` with every fragment in place, written with a loop. + fn flatten(&self, top: &[Item]) -> Vec { + let mut out = Vec::with_capacity(self.len_of(top)); + let mut stack: Vec<(&[Item], usize)> = vec![(top, 0)]; + while let Some((items, i)) = stack.pop() { + let Some(item) = items.get(i) else { continue }; + stack.push((items, i + 1)); + match item { + Item::Inst(inst) => out.push(*inst), + Item::Frag(f) => { + if let Some(child) = self.items.get(*f) { + stack.push((child, 0)); + } + } + } + } + out + } +} + +struct Ctx { + kind: Kind, + alts: Vec>, + cur: Vec, + /// The last term, when it may take a quantifier: where it starts in `cur` and how many groups + /// were opened before it. + atom: Option<(usize, u32)>, + /// Where the group starts in its parent's `cur` and how many groups were opened before it. + opened_at: (usize, u32), +} + +impl Ctx { + fn new(kind: Kind, opened_at: (usize, u32)) -> Self { + Self { kind, alts: Vec::new(), cur: Vec::new(), atom: None, opened_at } + } +} + +/// Adds `inst` to `ctx`, counting it in `emitted`. +fn emit(emitted: &mut usize, ctx: &mut Ctx, inst: Inst) { + *emitted += 1; + ctx.cur.push(Item::Inst(inst)); +} + +fn offset(n: usize) -> Result { + i32::try_from(n).map_err(|_| CompileFail::Syntax("regular expression too large".into())) +} + +/// The alternatives of a disjunction as one piece of code. +fn join(mut alts: Vec>, frags: &mut Frags) -> Result, CompileFail> { + if alts.len() == 1 { + return Ok(alts.pop().unwrap_or_default()); + } + let lens: Vec = alts.iter().map(|a| frags.len_of(a)).collect(); + let total = lens.iter().sum::() + 2 * alts.len().saturating_sub(1); + let last = alts.len().saturating_sub(1); + let mut out = Vec::with_capacity(2 * alts.len()); + let mut at = 0usize; + for (i, (a, len)) in alts.into_iter().zip(lens).enumerate() { + if i == last { + out.push(frags.add(a)); + } else { + out.push(Item::Inst(Inst::Split(1, offset(len + 2)?))); + out.push(frags.add(a)); + at += len + 1; + out.push(Item::Inst(Inst::Jump(offset(total - at)?))); + at += 1; + } + } + Ok(out) +} + +/// The number of capturing groups in `pattern`. +fn count_groups(pattern: &[u16]) -> usize { + let mut i = 0; + let mut n = 0; + let mut in_class = false; + while let Some(&u) = pattern.get(i) { + match u { + 0x5C => i += 1, + 0x5B => in_class = true, + 0x5D => in_class = false, + 0x28 if !in_class && pattern.get(i + 1) != Some(&0x3F) => n += 1, + _ => {} + } + i += 1; + } + n +} + +fn hex_digits(pattern: &[u16], at: usize, n: usize) -> Option { + let mut v: u32 = 0; + for k in 0..n { + let d = crate::chars::hex_value(u32::from(*pattern.get(at + k)?))?; + v = v * 16 + d; + } + u16::try_from(v).ok() +} + +/// A legacy octal escape (Annex B) whose first digit is at `at`: its value and the position after +/// it. +fn octal(pattern: &[u16], at: usize) -> (u16, usize) { + let digit = |i: usize| pattern.get(i).filter(|&&u| (0x30..=0x37).contains(&u)).map(|&u| u - 0x30); + let Some(first) = digit(at) else { return (0, at) }; + let max = if first <= 3 { 3 } else { 2 }; + let mut value = first; + let mut i = at + 1; + while i < at + max + && let Some(d) = digit(i) + { + value = value * 8 + d; + i += 1; + } + (value, i) +} + +/// A `{n}`, `{n,}` or `{n,m}` quantifier whose `{` is at `i`: its bounds and the position after it. +fn braces(pattern: &[u16], i: usize) -> Option<(u64, u64, usize)> { + let digits = |mut j: usize| -> Option<(u64, usize)> { + let start = j; + let mut v: u64 = 0; + while let Some(d) = pattern.get(j).filter(|&&u| (0x30..=0x39).contains(&u)) { + v = v.saturating_mul(10).saturating_add(u64::from(d - 0x30)); + j += 1; + } + (j > start).then_some((v, j)) + }; + let (min, mut j) = digits(i + 1)?; + let max = if pattern.get(j) == Some(&0x2C) { + j += 1; + match digits(j) { + Some((max, end)) => { + j = end; + max + } + None => u64::MAX, + } + } else { + min + }; + (pattern.get(j) == Some(&0x7D)).then_some((min, max, j + 1)) +} + +/// A class atom: one unit, or the ranges of a class escape. +enum ClassAtom { + Unit(u16), + Ranges(Vec<(u16, u16)>), +} + +/// The class atom at `i` and the position after it. +fn class_atom(pattern: &[u16], i: usize) -> Result<(ClassAtom, usize), CompileFail> { + let unit = *pattern.get(i).ok_or("unterminated character class")?; + if unit != 0x5C { + return Ok((ClassAtom::Unit(unit), i + 1)); + } + let next = *pattern.get(i + 1).ok_or("\\ at end of pattern")?; + if let Some(r) = escape_ranges(next) { + return Ok((ClassAtom::Ranges(r), i + 2)); + } + let unit = |u: u16, end: usize| Ok((ClassAtom::Unit(u), end)); + match next { + 0x62 => unit(0x08, i + 2), + 0x66 => unit(0x0C, i + 2), + 0x6E => unit(0x0A, i + 2), + 0x72 => unit(0x0D, i + 2), + 0x74 => unit(0x09, i + 2), + 0x76 => unit(0x0B, i + 2), + 0x78 => hex_digits(pattern, i + 2, 2).map_or(unit(next, i + 2), |v| unit(v, i + 4)), + 0x75 => hex_digits(pattern, i + 2, 4).map_or(unit(next, i + 2), |v| unit(v, i + 6)), + 0x63 => match pattern.get(i + 2) { + Some(&l) if matches!(l, 0x30..=0x39 | 0x41..=0x5A | 0x61..=0x7A | 0x5F) => unit(l % 32, i + 3), + _ => unit(0x5C, i + 1), + }, + 0x30..=0x37 => { + let (v, end) = octal(pattern, i + 1); + unit(v, end) + } + _ => unit(next, i + 2), + } +} + +/// The character class whose `[` is at `i`, and the position after its `]`. +fn class(pattern: &[u16], mut i: usize, room: usize) -> Result<(CharSet, usize), CompileFail> { + i += 1; + let negated = pattern.get(i) == Some(&0x5E); + if negated { + i += 1; + } + let mut ranges = Vec::new(); + loop { + // The ranges and the copy `normalize` makes of them stay within `room`. + if ranges.len().saturating_mul(2 * size_of::<(u16, u16)>()) > room { + return Err(CompileFail::Abrupt(Abrupt::Limit(LimitKind::Heap))); + } + let u = *pattern.get(i).ok_or("unterminated character class")?; + if u == 0x5D { + return Ok((CharSet { ranges: normalize(ranges), negated }, i + 1)); + } + let (atom, next) = class_atom(pattern, i)?; + i = next; + let dash = pattern.get(i) == Some(&0x2D) && pattern.get(i + 1).is_some_and(|&u| u != 0x5D); + if dash { + let (high, after) = class_atom(pattern, i + 1)?; + match (atom, high) { + (ClassAtom::Unit(lo), ClassAtom::Unit(hi)) => { + if lo > hi { + return Err("range out of order in character class".into()); + } + ranges.push((lo, hi)); + } + // Annex B: a range with a class escape at one end is its atoms and a `-`. + (lo, hi) => { + for a in [lo, ClassAtom::Unit(0x2D), hi] { + match a { + ClassAtom::Unit(u) => ranges.push((u, u)), + ClassAtom::Ranges(r) => ranges.extend(r), + } + } + } + } + i = after; + } else { + match atom { + ClassAtom::Unit(u) => ranges.push((u, u)), + ClassAtom::Ranges(r) => ranges.extend(r), + } + } + } +} + +/// The bytes a character class holds. +fn set_bytes(set: &CharSet) -> usize { + 32 + size_of::<(u16, u16)>() * set.ranges.len() +} + +/// The bytes compiling holds: each instruction as an item and in the flattened code, each +/// fragment with its item, and the classes. +pub(crate) fn compile_bytes(instructions: usize, fragments: usize, sets_size: usize) -> usize { + instructions + .saturating_mul(size_of::() + size_of::()) + .saturating_add(fragments.saturating_mul(size_of::() + 32)) + .saturating_add(sets_size) +} + +/// Compiles `pattern`. Groups may nest `max_depth` deep; `charge` takes fuel for the work done. +/// Compiling stops with the heap limit when what it holds (instructions, fragments and classes +/// made so far, with the two instructions each `|` adds) would pass `room` bytes. +pub(crate) fn compile( + pattern: &[u16], + flags: Flags, + max_depth: usize, + room: usize, + charge: &mut dyn FnMut(u64) -> JsResult<()>, +) -> Result { + let total_groups = count_groups(pattern); + let mut sets: Vec = Vec::new(); + let mut sets_size = 0usize; + let mut stack: Vec = vec![Ctx::new(Kind::Top, (0, 0))]; + let mut frags = Frags::default(); + let mut groups: u32 = 0; + let mut loops: u32 = 0; + let mut i = 0; + let mut work: u64 = 0; + // The instructions made so far, counting the `Split` and `Jump` each `|` adds when it is joined. + let mut emitted = 0usize; + // The fragments the joins of the groups still open will add, one for each `|` read in them. + let mut joined = 0usize; + let canon = |u: u16| if flags.ignore_case { canonicalize(u) } else { u }; + while let Some(&u) = pattern.get(i) { + work += 1; + if work >= FUEL_BATCH { + charge(work).map_err(CompileFail::Abrupt)?; + work = 0; + } + if compile_bytes(emitted, frags.items.len() + joined, sets_size) > room { + return Err(CompileFail::Abrupt(Abrupt::Limit(LimitKind::Heap))); + } + let ctx = stack.last_mut().ok_or("unmatched ')'")?; + let start = ctx.cur.len(); + let before = groups; + match u { + 0x5E | 0x24 => { + emit(&mut emitted, ctx, if u == 0x5E { Inst::LineStart } else { Inst::LineEnd }); + ctx.atom = None; + i += 1; + } + 0x7C => { + emitted += 2; + joined += 1; + let cur = std::mem::take(&mut ctx.cur); + ctx.alts.push(cur); + ctx.atom = None; + i += 1; + } + 0x2E => { + emit(&mut emitted, ctx, Inst::Any); + ctx.atom = Some((start, before)); + i += 1; + } + 0x5B => { + let (set, next) = class(pattern, i, room.saturating_sub(compile_bytes(emitted, frags.items.len() + joined, sets_size)))?; + emit(&mut emitted, ctx, Inst::Set(u32::try_from(sets.len()).map_err(|_| "too many classes")?)); + sets_size = sets_size.saturating_add(set_bytes(&set)); + sets.push(set); + ctx.atom = Some((start, before)); + i = next; + } + 0x28 => { + let kind = match (pattern.get(i + 1), pattern.get(i + 2)) { + (Some(0x3F), Some(0x3A)) => Kind::Group, + (Some(0x3F), Some(0x3D)) => Kind::Look(false), + (Some(0x3F), Some(0x21)) => Kind::Look(true), + (Some(0x3F), _) => return Err("invalid group".into()), + _ => { + groups += 1; + Kind::Capture(groups) + } + }; + i += if matches!(kind, Kind::Capture(_)) { 1 } else { 3 }; + if stack.len() > max_depth { + return Err(crate::error::TOO_DEEP.into()); + } + stack.push(Ctx::new(kind, (start, before))); + } + 0x29 => { + if stack.len() <= 1 { + return Err("unmatched ')'".into()); + } + let Some(mut done) = stack.pop() else { return Err("unmatched ')'".into()) }; + let cur = std::mem::take(&mut done.cur); + // The fragments counted for this group's `|`s become real ones in the join. + joined = joined.saturating_sub(done.alts.len()); + done.alts.push(cur); + let body = join(std::mem::take(&mut done.alts), &mut frags)?; + work += body.len() as u64; + let body_len = frags.len_of(&body); + let body = frags.add(body); + let (start, before) = done.opened_at; + let parent = stack.last_mut().ok_or("unmatched ')'")?; + match done.kind { + Kind::Capture(n) => { + emit(&mut emitted, parent, Inst::GroupStart(n)); + parent.cur.push(body); + emit(&mut emitted, parent, Inst::GroupEnd(n)); + } + Kind::Group | Kind::Top => parent.cur.push(body), + Kind::Look(negative) => { + let end = offset(body_len + 2)?; + emit(&mut emitted, parent, Inst::Look { negative, end }); + parent.cur.push(body); + emit(&mut emitted, parent, Inst::LookEnd); + } + } + parent.atom = Some((start, before)); + i += 1; + } + 0x2A | 0x2B | 0x3F | 0x7B => { + let (min, max, next) = match u { + 0x2A => (0, u64::MAX, i + 1), + 0x2B => (1, u64::MAX, i + 1), + 0x3F => (0, 1, i + 1), + _ => match braces(pattern, i) { + Some(b) => b, + None => { + // Annex B: a `{` that starts no quantifier is itself. + emit(&mut emitted, ctx, Inst::Char(canon(u))); + ctx.atom = Some((start, before)); + i += 1; + continue; + } + }, + }; + if max < min { + return Err("numbers out of order in {} quantifier".into()); + } + let Some((atom_start, atom_groups)) = ctx.atom.take() else { return Err("nothing to repeat".into()) }; + let greedy = pattern.get(next) != Some(&0x3F); + i = if greedy { next } else { next + 1 }; + if min == 1 && max == 1 { + continue; + } + let body: Vec = ctx.cur.drain(atom_start..).collect(); + work += body.len() as u64; + let reg = loops; + loops = loops.checked_add(1).ok_or("too many quantifiers")?; + let n = frags.len_of(&body); + let body = frags.add(body); + emit(&mut emitted, ctx, Inst::RepInit(reg)); + emit(&mut emitted, ctx, Inst::Rep { reg, min, max, greedy, caps: (atom_groups + 1, groups + 1), exit: offset(n + 2)? }); + ctx.cur.push(body); + emit(&mut emitted, ctx, Inst::RepEnd { reg, head: -offset(n + 1)? }); + } + 0x5C => { + let next = *pattern.get(i + 1).ok_or("\\ at end of pattern")?; + let mut atom = true; + match next { + 0x62 | 0x42 => { + emit(&mut emitted, ctx, Inst::WordBoundary(next == 0x62)); + atom = false; + i += 2; + } + 0x31..=0x39 => { + let mut j = i + 1; + let mut n: u64 = 0; + while let Some(d) = pattern.get(j).filter(|&&u| (0x30..=0x39).contains(&u)) { + n = n.saturating_mul(10).saturating_add(u64::from(d - 0x30)); + j += 1; + } + if n <= total_groups as u64 { + emit(&mut emitted, ctx, Inst::BackRef(u32::try_from(n).map_err(|_| "too many groups")?)); + i = j; + } else if next <= 0x37 { + let (v, end) = octal(pattern, i + 1); + emit(&mut emitted, ctx, Inst::Char(canon(v))); + i = end; + } else { + emit(&mut emitted, ctx, Inst::Char(canon(next))); + i += 2; + } + } + 0x30 => { + if pattern.get(i + 2).is_some_and(|&d| (0x30..=0x37).contains(&d)) { + let (v, end) = octal(pattern, i + 1); + emit(&mut emitted, ctx, Inst::Char(canon(v))); + i = end; + } else { + emit(&mut emitted, ctx, Inst::Char(0)); + i += 2; + } + } + _ => { + if let Some(r) = escape_ranges(next) { + emit(&mut emitted, ctx, Inst::Set(u32::try_from(sets.len()).map_err(|_| "too many classes")?)); + let set = CharSet { ranges: r, negated: false }; + sets_size = sets_size.saturating_add(set_bytes(&set)); + sets.push(set); + i += 2; + } else { + let (atom_unit, end) = match class_atom(pattern, i)? { + (ClassAtom::Unit(v), end) => (v, end), + (ClassAtom::Ranges(_), end) => (next, end), + }; + emit(&mut emitted, ctx, Inst::Char(canon(atom_unit))); + i = end; + } + } + } + ctx.atom = if atom { Some((start, before)) } else { None }; + } + _ => { + emit(&mut emitted, ctx, Inst::Char(canon(u))); + ctx.atom = Some((start, before)); + i += 1; + } + } + } + if stack.len() != 1 { + return Err("unterminated group".into()); + } + let Some(mut top) = stack.pop() else { return Err("unterminated group".into()) }; + let cur = std::mem::take(&mut top.cur); + top.alts.push(cur); + // Joining adds a fragment for each alternative, and flattening writes every instruction. + if compile_bytes(emitted + 1, frags.items.len() + joined + 1, sets_size) > room { + return Err(CompileFail::Abrupt(Abrupt::Limit(LimitKind::Heap))); + } + let top = join(top.alts, &mut frags)?; + let mut insts = frags.flatten(&top); + insts.push(Inst::Match); + charge(work + insts.len() as u64 / 16).map_err(CompileFail::Abrupt)?; + Ok(Regex { insts, sets, groups: groups as usize, loops: loops as usize, ignore_case: flags.ignore_case, multiline: flags.multiline }) +} + +// ---- matching ---- + +/// An entry on the matcher's stack: a choice point to go back to, or a value to restore when +/// going back past it. +#[derive(Clone, Copy, Debug)] +enum Bt { + Retry { + pc: usize, + pos: usize, + }, + /// Runs another iteration of the quantifier at `pc`. + EnterRep { + pc: usize, + pos: usize, + }, + Cap(usize, usize), + Open(usize, usize), + Count(usize, u64), + Mark(usize, usize), + Look { + pos: usize, + negative: bool, + resume: usize, + }, +} + +impl Bt { + fn is_undo(&self) -> bool { + matches!(self, Bt::Cap(..) | Bt::Open(..) | Bt::Count(..) | Bt::Mark(..)) + } +} + +/// Takes `n` units of fuel, checking the interrupt flag. +pub(crate) fn use_fuel_n(interp: &mut Interp, n: u64) -> JsResult<()> { + if n == 0 { + return Ok(()); + } + if interp.fuel < n { + interp.fuel = 0; + return Err(Abrupt::Limit(LimitKind::Fuel)); + } + interp.fuel -= n - 1; + interp.use_fuel() +} + +/// The state of one match attempt, kept between attempts to reuse its buffers. +pub(crate) struct Matcher<'r> { + re: &'r Regex, + /// Two positions per group, group 0 first: [`NONE`] where a group did not take part. + pub(crate) caps: Vec, + open: Vec, + counts: Vec, + marks: Vec, + stack: Vec, + max_stack: usize, + steps: u64, +} + +impl<'r> Matcher<'r> { + /// A matcher for `re`. Its capture and quantifier state, and its stack, fit in the heap room + /// left under the limit; a run without room for the state ends with the heap limit. When the + /// room would not hold the state and 64 stack entries, a collection runs first, so everything + /// the caller still needs must be reachable from the roots. + pub(crate) fn new(re: &'r Regex, interp: &mut Interp) -> JsResult { + let state = 8usize.saturating_mul(re.groups.saturating_add(1).saturating_mul(3).saturating_add(re.loops.saturating_mul(2))); + let wanted = state.saturating_add(64 * size_of::()); + if interp.heap.bytes.saturating_add(wanted) > interp.limits.heap_bytes { + interp.collect_for_room(); + } + let room = interp.limits.heap_bytes.saturating_sub(interp.heap.bytes); + if state > room { + return Err(Abrupt::Limit(LimitKind::Heap)); + } + Ok(Self { + re, + caps: vec![NONE; 2 * (re.groups + 1)], + open: vec![NONE; re.groups + 1], + counts: vec![0; re.loops], + marks: vec![NONE; re.loops], + stack: Vec::new(), + max_stack: (room - state) / size_of::(), + steps: 0, + }) + } + + fn push(&mut self, b: Bt) -> JsResult<()> { + if self.stack.len() >= self.max_stack { + return Err(Abrupt::Limit(LimitKind::Heap)); + } + self.stack.push(b); + Ok(()) + } + + fn step(&mut self, interp: &mut Interp) -> JsResult<()> { + self.steps += 1; + if self.steps >= FUEL_BATCH { + let n = std::mem::take(&mut self.steps); + use_fuel_n(interp, n)?; + } + Ok(()) + } + + /// Takes the fuel for the steps not yet charged. + fn settle(&mut self, interp: &mut Interp) -> JsResult<()> { + let n = std::mem::take(&mut self.steps); + use_fuel_n(interp, n) + } + + fn set_cap(&mut self, slot: usize, v: usize) -> JsResult<()> { + let Some(old) = self.caps.get(slot).copied() else { return Ok(()) }; + if old != v { + self.push(Bt::Cap(slot, old))?; + if let Some(c) = self.caps.get_mut(slot) { + *c = v; + } + } + Ok(()) + } + + fn enter_rep(&mut self, pc: usize, pos: usize) -> JsResult<()> { + let Some(Inst::Rep { reg, caps: (lo, hi), .. }) = self.re.insts.get(pc).copied() else { return Ok(()) }; + let reg = reg as usize; + let old = self.marks.get(reg).copied().unwrap_or(NONE); + self.push(Bt::Mark(reg, old))?; + if let Some(m) = self.marks.get_mut(reg) { + *m = pos; + } + for g in lo as usize..hi as usize { + self.set_cap(2 * g, NONE)?; + self.set_cap(2 * g + 1, NONE)?; + } + Ok(()) + } + + /// Restores the value an undo entry saved; other entries are ignored. + fn undo(&mut self, b: Bt) { + let (slot, old) = match b { + Bt::Cap(s, old) => (self.caps.get_mut(s), old), + Bt::Open(g, old) => (self.open.get_mut(g), old), + Bt::Mark(r, old) => (self.marks.get_mut(r), old), + Bt::Count(r, old) => { + if let Some(c) = self.counts.get_mut(r) { + *c = old; + } + return; + } + _ => return, + }; + if let Some(slot) = slot { + *slot = old; + } + } + + /// Goes back to the last choice point, restoring what was changed since. False when there is + /// none left. + fn backtrack(&mut self, interp: &mut Interp, pc: &mut usize, pos: &mut usize) -> JsResult { + while let Some(b) = self.stack.pop() { + self.step(interp)?; + match b { + Bt::Retry { pc: p, pos: q } => { + (*pc, *pos) = (p, q); + return Ok(true); + } + Bt::EnterRep { pc: p, pos: q } => { + *pos = q; + self.enter_rep(p, q)?; + *pc = p + 1; + return Ok(true); + } + Bt::Cap(..) | Bt::Open(..) | Bt::Count(..) | Bt::Mark(..) => self.undo(b), + // A negative lookahead whose pattern failed succeeds. + Bt::Look { pos: q, negative: true, resume } => { + (*pc, *pos) = (resume, q); + return Ok(true); + } + Bt::Look { negative: false, .. } => {} + } + } + Ok(false) + } + + /// Tries to match at `start` only. On success `caps` holds the match. + pub(crate) fn match_at(&mut self, interp: &mut Interp, input: &[u16], start: usize) -> JsResult { + let r = self.run(interp, input, start); + self.settle(interp)?; + r + } + + /// The first match at `from` or after. + pub(crate) fn search(&mut self, interp: &mut Interp, input: &[u16], from: usize) -> JsResult { + let mut start = from; + while start <= input.len() { + if self.match_at(interp, input, start)? { + return Ok(true); + } + start += 1; + } + Ok(false) + } + + fn reset(&mut self) { + self.caps.fill(NONE); + self.open.fill(NONE); + self.counts.fill(0); + self.marks.fill(NONE); + self.stack.clear(); + } + + fn run(&mut self, interp: &mut Interp, input: &[u16], start: usize) -> JsResult { + self.reset(); + let re = self.re; + let mut pc = 0usize; + let mut pos = start; + let at = |pc: usize, off: i32| -> usize { pc.wrapping_add_signed(off as isize) }; + loop { + self.step(interp)?; + let Some(inst) = re.insts.get(pc).copied() else { return Err(Abrupt::Internal("regular expression jumped out of its code".into())) }; + let ok = match inst { + Inst::Char(c) => { + let hit = input.get(pos).is_some_and(|&u| re.canon(u) == c); + if hit { + pos += 1; + pc += 1; + } + hit + } + Inst::Any => { + let hit = input.get(pos).is_some_and(|&u| !is_line_terminator(u)); + if hit { + pos += 1; + pc += 1; + } + hit + } + Inst::Set(k) => { + let hit = match (input.get(pos), re.sets.get(k as usize)) { + (Some(&u), Some(set)) => re.set_matches(set, u), + _ => false, + }; + if hit { + pos += 1; + pc += 1; + } + hit + } + Inst::LineStart => { + let hit = pos == 0 || (re.multiline && input.get(pos - 1).is_some_and(|&u| is_line_terminator(u))); + pc += 1; + hit + } + Inst::LineEnd => { + let hit = pos == input.len() || (re.multiline && input.get(pos).is_some_and(|&u| is_line_terminator(u))); + pc += 1; + hit + } + Inst::WordBoundary(want) => { + let a = pos > 0 && input.get(pos - 1).is_some_and(|&u| is_word_char(u)); + let b = input.get(pos).is_some_and(|&u| is_word_char(u)); + pc += 1; + (a != b) == want + } + Inst::Split(first, second) => { + self.push(Bt::Retry { pc: at(pc, second), pos })?; + pc = at(pc, first); + true + } + Inst::Jump(off) => { + pc = at(pc, off); + true + } + Inst::GroupStart(g) => { + let g = g as usize; + let old = self.open.get(g).copied().unwrap_or(NONE); + self.push(Bt::Open(g, old))?; + if let Some(o) = self.open.get_mut(g) { + *o = pos; + } + pc += 1; + true + } + Inst::GroupEnd(g) => { + let g = g as usize; + let s = self.open.get(g).copied().unwrap_or(NONE); + self.set_cap(2 * g, s)?; + self.set_cap(2 * g + 1, pos)?; + pc += 1; + true + } + Inst::RepInit(r) => { + let r = r as usize; + let old = self.counts.get(r).copied().unwrap_or(0); + self.push(Bt::Count(r, old))?; + if let Some(c) = self.counts.get_mut(r) { + *c = 0; + } + pc += 1; + true + } + Inst::Rep { reg, min, max, greedy, exit, .. } => { + let count = self.counts.get(reg as usize).copied().unwrap_or(0); + let exit_pc = at(pc, exit); + if count >= max { + pc = exit_pc; + } else if count < min { + self.enter_rep(pc, pos)?; + pc += 1; + } else if greedy { + self.push(Bt::Retry { pc: exit_pc, pos })?; + self.enter_rep(pc, pos)?; + pc += 1; + } else { + self.push(Bt::EnterRep { pc, pos })?; + pc = exit_pc; + } + true + } + Inst::RepEnd { reg, head } => { + let head_pc = at(pc, head); + let min = match re.insts.get(head_pc) { + Some(Inst::Rep { min, .. }) => *min, + _ => return Err(Abrupt::Internal("quantifier end without its head".into())), + }; + let r = reg as usize; + let count = self.counts.get(r).copied().unwrap_or(0); + // An iteration past the minimum that matched nothing fails (section + // 15.10.2.5, RepeatMatcher step 2.a). + if count >= min && self.marks.get(r).copied() == Some(pos) { + false + } else { + self.push(Bt::Count(r, count))?; + if let Some(c) = self.counts.get_mut(r) { + *c = count + 1; + } + pc = head_pc; + true + } + } + Inst::Look { negative, end } => { + self.push(Bt::Look { pos, negative, resume: at(pc, end) })?; + pc += 1; + true + } + Inst::LookEnd => { + let Some(b) = self.stack.iter().rposition(|e| matches!(e, Bt::Look { .. })) else { + return Err(Abrupt::Internal("lookahead end without its start".into())); + }; + let Some(Bt::Look { pos: q, negative, resume }) = self.stack.get(b).copied() else { + return Err(Abrupt::Internal("lookahead end without its start".into())); + }; + if negative { + // The pattern matched, so the negative lookahead fails: restore what it + // changed, drop its choice points and go back past it. + while self.stack.len() > b + 1 { + if let Some(e) = self.stack.pop() { + self.undo(e); + } + } + self.stack.truncate(b); + false + } else { + // A lookahead is atomic: its choice points are dropped, and what it set is + // kept, with the entries that restore it. + let kept: Vec = self.stack.drain(b..).skip(1).filter(Bt::is_undo).collect(); + self.stack.extend(kept); + pos = q; + pc = resume; + true + } + } + Inst::BackRef(g) => { + let g = g as usize; + let (s, e) = (self.caps.get(2 * g).copied().unwrap_or(NONE), self.caps.get(2 * g + 1).copied().unwrap_or(NONE)); + pc += 1; + if s == NONE || e == NONE || e < s { + true + } else { + let len = e - s; + let hit = pos + len <= input.len() + && (0..len).all(|k| match (input.get(s + k), input.get(pos + k)) { + (Some(&a), Some(&b)) => re.canon(a) == re.canon(b), + _ => false, + }); + self.steps += len as u64 / 16; + if hit { + pos += len; + } + hit + } + } + Inst::Match => { + self.set_cap(0, start)?; + self.set_cap(1, pos)?; + return Ok(true); + } + }; + if !ok && !self.backtrack(interp, &mut pc, &mut pos)? { + return Ok(false); + } + } + } +} diff --git a/crates/js/src/string_builtins.rs b/crates/js/src/string_builtins.rs new file mode 100644 index 000000000..7d9f56a25 --- /dev/null +++ b/crates/js/src/string_builtins.rs @@ -0,0 +1,699 @@ +//! `String` (ECMA-262 5.1 section 15.5): the constructor, `String.fromCharCode` and the methods of +//! `String.prototype`, with `substr` from Annex B. +//! +//! The methods read a string's code units once (flattening a rope), take a unit of fuel for every +//! [`UNITS_PER_FUEL`] code units they read or write, and reserve each new string's bytes on the +//! heap before building it, so a method that would make a string past the heap limit ends the +//! run instead of allocating it. + +use std::rc::Rc; + +use crate::LimitKind; +use crate::builtins::{arg, constructor, method, native}; +use crate::heap::{Class, NativeKind, OPEN, ObjId, Object, PROP_BYTES, Prop, string_bytes}; +use crate::interp::{Abrupt, Interp, JsResult}; +use crate::regexp_builtins::{exec_raw, new_regexp_from, regexp_of}; +use crate::regexp_vm::{Matcher, NONE, use_fuel_n}; +use crate::value::{Str, Value, index_key, is_str_space, to_uint32}; + +/// Code units a unit of fuel pays for when a built-in reads or writes text. +pub(crate) const UNITS_PER_FUEL: usize = 16; + +pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { + let proto = interp.intrinsics.string_proto; + let ctor = native(interp, "String", string_ctor, 1, NativeKind::Plain, true)?; + constructor(interp, "String", ctor, proto)?; + method(interp, ctor, "fromCharCode", from_char_code, 1)?; + let methods: [(&'static str, crate::heap::NativeFn, u32); 20] = [ + ("toString", to_string, 0), + ("valueOf", to_string, 0), + ("charAt", char_at, 1), + ("charCodeAt", char_code_at, 1), + ("concat", concat, 1), + ("indexOf", index_of, 1), + ("lastIndexOf", last_index_of, 1), + ("localeCompare", locale_compare, 1), + ("match", match_, 1), + ("replace", replace, 2), + ("search", search, 1), + ("slice", slice, 2), + ("split", split, 2), + ("substring", substring, 2), + ("substr", substr, 2), + ("toLowerCase", to_lower_case, 0), + ("toLocaleLowerCase", to_lower_case, 0), + ("toUpperCase", to_upper_case, 0), + ("toLocaleUpperCase", to_upper_case, 0), + ("trim", trim, 0), + ]; + for (name, f, len) in methods { + method(interp, proto, name, f, len)?; + } + Ok(()) +} + +// ---- helpers the other text built-ins share ---- + +/// ToInteger (section 9.4) applied to a number. +pub(crate) fn to_integer(n: f64) -> f64 { + if n.is_nan() { + 0.0 + } else if n.is_infinite() { + n + } else { + n.trunc() + 0.0 + } +} + +/// Takes the fuel for reading or writing `n` code units. +pub(crate) fn charge_units(interp: &mut Interp, n: usize) -> JsResult<()> { + use_fuel_n(interp, (n / UNITS_PER_FUEL) as u64 + 1) +} + +/// Reserves the heap bytes of a new string of `len` code units before it is built, ending the +/// run with the heap limit when it would not fit. Everything the caller still needs must be +/// reachable from the roots. +pub(crate) fn reserve_units(interp: &mut Interp, len: usize) -> JsResult<()> { + if len > interp.limits.heap_bytes / 4 { + return Err(Abrupt::Limit(LimitKind::Heap)); + } + let bytes = string_bytes(len); + interp.reserve_heap(bytes)?; + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + charge_units(interp, len) +} + +/// A new string holding `units[start..end]`. +pub(crate) fn substring_value(interp: &mut Interp, units: &[u16], start: usize, end: usize) -> JsResult { + let part = units.get(start..end.max(start)).unwrap_or_default(); + reserve_units(interp, part.len())?; + Ok(Value::String(Str::from_units(part.to_vec()))) +} + +/// Runs `f`, then drops the temporary roots it added. +pub(crate) fn rooted(interp: &mut Interp, f: impl FnOnce(&mut Interp) -> JsResult) -> JsResult { + let mark = interp.temp_roots.len(); + let r = f(interp); + interp.temp_roots.truncate(mark); + r +} + +/// A new, empty array, kept in the temporary roots: call it inside [`rooted`]. +pub(crate) fn new_array(interp: &mut Interp) -> ObjId { + let proto = interp.intrinsics.array_proto; + let a = interp.heap.alloc_object(Object::new(Class::Array { length: 0, length_writable: true }, Some(proto))); + interp.temp_roots.push(Value::Object(a)); + a +} + +/// Sets element `i` of an array made by [`new_array`], reserving its bytes first. +pub(crate) fn set_element(interp: &mut Interp, a: ObjId, i: usize, v: Value) -> JsResult<()> { + let i = u32::try_from(i).map_err(|_| Abrupt::Limit(LimitKind::Heap))?; + interp.temp_roots.push(v.clone()); + let reserved = interp.reserve_heap(PROP_BYTES + string_bytes(10)); + interp.temp_roots.pop(); + reserved?; + interp.use_fuel()?; + interp.define(a, index_key(i), Prop::Data { value: v, flags: OPEN }) +} + +/// CheckObjectCoercible on `this`, then ToString. +pub(crate) fn this_str(interp: &mut Interp, this: &Value, method: &str) -> JsResult { + if this.is_nullish() { + let message = format!("String.prototype.{method} was called on null or undefined"); + return Err(interp.type_error(&message)); + } + interp.to_str(this) +} + +/// ToInteger of an argument. +fn int_arg(interp: &mut Interp, args: &[Value], i: usize) -> JsResult { + let n = interp.to_number(&arg(args, i))?; + Ok(to_integer(n)) +} + +/// Clamps an integer to `0..=len`. +fn clamp(n: f64, len: usize) -> usize { + if n <= 0.0 { + 0 + } else if n >= len as f64 { + len + } else { + n as usize + } +} + +/// The first index at `from` or after where `needle` occurs in `hay`. Every position tried takes +/// fuel. +fn find(interp: &mut Interp, hay: &[u16], needle: &[u16], from: usize) -> JsResult> { + if needle.len() > hay.len() { + return Ok(None); + } + let last = hay.len() - needle.len(); + let per = (needle.len() / UNITS_PER_FUEL) as u64 + 1; + let mut steps = 0u64; + let mut i = from; + let mut found = None; + while i <= last { + steps += per; + if steps >= 1024 { + use_fuel_n(interp, std::mem::take(&mut steps))?; + } + if hay.get(i..i + needle.len()) == Some(needle) { + found = Some(i); + break; + } + i += 1; + } + use_fuel_n(interp, steps)?; + Ok(found) +} + +// ---- the constructor ---- + +/// `String(value)` and `new String(value)` (sections 15.5.1 and 15.5.2). +fn string_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) -> JsResult { + let s = match args.first() { + Some(v) => interp.to_str(v)?, + None => Str::from(""), + }; + if !construct { + return Ok(Value::String(s)); + } + let proto = interp.intrinsics.string_proto; + Ok(Value::Object(interp.heap.alloc_object(Object::new(Class::String(s), Some(proto))))) +} + +/// `String.fromCharCode(…)` (section 15.5.3.2). +fn from_char_code(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let mut units = Vec::with_capacity(args.len()); + for a in args { + let n = interp.to_number(a)?; + units.push(to_uint32(n) as u16); + } + reserve_units(interp, units.len())?; + Ok(Value::String(Str::from_units(units))) +} + +// ---- String.prototype ---- + +/// `toString` and `valueOf` (sections 15.5.4.2 and 15.5.4.3). +fn to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + match &this { + Value::String(_) => Ok(this), + Value::Object(o) => match interp.heap.object(*o).map(|o| &o.class) { + Some(Class::String(s)) => Ok(Value::String(s.clone())), + _ => Err(interp.type_error("String.prototype.toString was called on a value that is not a string")), + }, + _ => Err(interp.type_error("String.prototype.toString was called on a value that is not a string")), + } +} + +fn char_at(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let s = this_str(interp, &this, "charAt")?; + let pos = int_arg(interp, args, 0)?; + if pos < 0.0 || pos >= s.len() as f64 { + return Ok(Value::str("")); + } + let unit = s.flat().get(pos as usize).copied().unwrap_or(0); + Ok(Value::String(Str::from_units(vec![unit]))) +} + +fn char_code_at(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let s = this_str(interp, &this, "charCodeAt")?; + let pos = int_arg(interp, args, 0)?; + if pos < 0.0 || pos >= s.len() as f64 { + return Ok(Value::Number(f64::NAN)); + } + Ok(Value::Number(f64::from(s.flat().get(pos as usize).copied().unwrap_or(0)))) +} + +fn concat(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let mut s = this_str(interp, &this, "concat")?; + for a in args { + let r = interp.to_str(a)?; + s = interp.concat(&s, &r)?; + } + Ok(Value::String(s)) +} + +fn index_of(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let s = this_str(interp, &this, "indexOf")?; + let search = interp.to_str(&arg(args, 0))?; + let pos = int_arg(interp, args, 1)?; + let (hay, needle) = (s.flat(), search.flat()); + let start = clamp(pos, hay.len()); + let found = find(interp, &hay, &needle, start)?; + Ok(Value::Number(found.map_or(-1.0, |i| i as f64))) +} + +fn last_index_of(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let s = this_str(interp, &this, "lastIndexOf")?; + let search = interp.to_str(&arg(args, 0))?; + let n = interp.to_number(&arg(args, 1))?; + let pos = if n.is_nan() { f64::INFINITY } else { to_integer(n) }; + let (hay, needle) = (s.flat(), search.flat()); + let start = clamp(pos, hay.len()); + if needle.len() > hay.len() { + return Ok(Value::Number(-1.0)); + } + let mut k = start.min(hay.len() - needle.len()); + let per = (needle.len() / UNITS_PER_FUEL) as u64 + 1; + loop { + use_fuel_n(interp, per)?; + if hay.get(k..k + needle.len()) == Some(&needle[..]) { + return Ok(Value::Number(k as f64)); + } + if k == 0 { + return Ok(Value::Number(-1.0)); + } + k -= 1; + } +} + +/// `localeCompare` (section 15.5.4.9): code unit order. +fn locale_compare(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let s = this_str(interp, &this, "localeCompare")?; + let that = interp.to_str(&arg(args, 0))?; + let (a, b) = (s.flat(), that.flat()); + charge_units(interp, a.len().min(b.len()))?; + Ok(Value::Number(match a.cmp(&b) { + std::cmp::Ordering::Less => -1.0, + std::cmp::Ordering::Equal => 0.0, + std::cmp::Ordering::Greater => 1.0, + })) +} + +fn slice(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let s = this_str(interp, &this, "slice")?; + let len = s.len() as f64; + let start = int_arg(interp, args, 0)?; + let end = if matches!(arg(args, 1), Value::Undefined) { len } else { int_arg(interp, args, 1)? }; + let from = if start < 0.0 { (len + start).max(0.0) } else { start.min(len) }; + let to = if end < 0.0 { (len + end).max(0.0) } else { end.min(len) }; + if to <= from { + return Ok(Value::str("")); + } + substring_value(interp, &s.flat(), from as usize, to as usize) +} + +fn substring(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let s = this_str(interp, &this, "substring")?; + let len = s.len(); + let start = int_arg(interp, args, 0)?; + let end = if matches!(arg(args, 1), Value::Undefined) { len as f64 } else { int_arg(interp, args, 1)? }; + let (a, b) = (clamp(start, len), clamp(end, len)); + substring_value(interp, &s.flat(), a.min(b), a.max(b)) +} + +/// `substr` (Annex B.2.3). Its first step applies ToString to `this` with no object-coercible +/// check, so `null` and `undefined` give substrings of `"null"` and `"undefined"`. +fn substr(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let s = interp.to_str(&this)?; + let len = s.len() as f64; + let start = int_arg(interp, args, 0)?; + let length = if matches!(arg(args, 1), Value::Undefined) { f64::INFINITY } else { int_arg(interp, args, 1)? }; + let start = if start >= 0.0 { start } else { (len + start).max(0.0) }; + let count = length.max(0.0).min(len - start); + if count <= 0.0 { + return Ok(Value::str("")); + } + substring_value(interp, &s.flat(), start as usize, (start + count) as usize) +} + +/// The UTF-16 length of `c` mapped to lower or upper case. +fn mapped_len(c: char, upper: bool) -> usize { + if upper { c.to_uppercase().map(char::len_utf16).sum() } else { c.to_lowercase().map(char::len_utf16).sum() } +} + +/// Maps the characters of `units` to lower or upper case (Unicode's full mappings, final sigma +/// included), passing lone surrogates through. The result's length is reserved before it is built. +fn map_case(interp: &mut Interp, units: &[u16], upper: bool) -> JsResult> { + let len: usize = char::decode_utf16(units.iter().copied()).map(|r| r.map_or(1, |c| mapped_len(c, upper))).sum(); + reserve_units(interp, len)?; + let mut out = Vec::with_capacity(len); + let mut run = String::new(); + let flush = |run: &mut String, out: &mut Vec| { + let mapped = if upper { run.to_uppercase() } else { run.to_lowercase() }; + out.extend(mapped.encode_utf16()); + run.clear(); + }; + for r in char::decode_utf16(units.iter().copied()) { + match r { + Ok(c) => run.push(c), + Err(e) => { + flush(&mut run, &mut out); + out.push(e.unpaired_surrogate()); + } + } + } + flush(&mut run, &mut out); + Ok(out) +} + +fn to_lower_case(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let s = this_str(interp, &this, "toLowerCase")?; + let out = map_case(interp, &s.flat(), false)?; + Ok(Value::String(Str::from_units(out))) +} + +fn to_upper_case(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let s = this_str(interp, &this, "toUpperCase")?; + let out = map_case(interp, &s.flat(), true)?; + Ok(Value::String(Str::from_units(out))) +} + +fn trim(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + let s = this_str(interp, &this, "trim")?; + let units = s.flat(); + // The scan reads every unit once at most. + charge_units(interp, units.len())?; + let start = units.iter().position(|&u| !is_str_space(u)).unwrap_or(units.len()); + let end = units.iter().rposition(|&u| !is_str_space(u)).map_or(start, |i| i + 1); + if start == 0 && end == units.len() { + return Ok(Value::String(s)); + } + substring_value(interp, &units, start, end) +} + +// ---- the methods that take a regular expression ---- + +/// The regular expression argument of `match` and `search`: a RegExp object, or a new one made +/// from the value as `new RegExp(value)` would. +fn regexp_arg(interp: &mut Interp, v: &Value) -> JsResult { + if let Some((o, _)) = regexp_of(interp, v) { + return Ok(o); + } + let o = new_regexp_from(interp, v, &Value::Undefined)?; + interp.temp_roots.push(Value::Object(o)); + Ok(o) +} + +/// `match` (section 15.5.4.10). +fn match_(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + rooted(interp, |interp| { + let s = this_str(interp, &this, "match")?; + let rx = regexp_arg(interp, &arg(args, 0))?; + let global = interp.get_value(&Value::Object(rx), &crate::heap::key("global"))?.truthy(); + if !global { + return crate::regexp_builtins::exec_value(interp, rx, &s); + } + let last_index = crate::heap::key("lastIndex"); + interp.put(rx, last_index.clone(), Value::Number(0.0), true)?; + let a = new_array(interp); + let mut n = 0usize; + let mut previous = 0.0; + let units = s.flat(); + while let Some(caps) = exec_raw(interp, rx, &s)? { + let this_index = interp.get_value(&Value::Object(rx), &last_index)?; + let this_index = interp.to_number(&this_index)?; + if this_index == previous { + previous = this_index + 1.0; + interp.put(rx, last_index.clone(), Value::Number(previous), true)?; + } else { + previous = this_index; + } + let m = substring_value(interp, &units, caps.first().copied().unwrap_or(0), caps.get(1).copied().unwrap_or(0))?; + set_element(interp, a, n, m)?; + n += 1; + } + Ok(if n == 0 { Value::Null } else { Value::Object(a) }) + }) +} + +/// `search` (section 15.5.4.12): the index of the first match from the start, ignoring +/// `lastIndex` and `global`. +fn search(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + rooted(interp, |interp| { + let s = this_str(interp, &this, "search")?; + let rx = regexp_arg(interp, &arg(args, 0))?; + let Some((_, re)) = regexp_of(interp, &Value::Object(rx)) else { return Err(interp.type_error("not a regular expression")) }; + let units = s.flat(); + let mut m = Matcher::new(&re, interp)?; + let found = m.search(interp, &units, 0)?; + Ok(Value::Number(if found { m.caps.first().copied().unwrap_or(0) as f64 } else { -1.0 })) + }) +} + +/// A piece of a result string: a range of the input, or other text. +enum Piece { + Range(usize, usize), + Text(Str), +} + +impl Piece { + fn len(&self) -> usize { + match self { + Piece::Range(a, b) => b.saturating_sub(*a), + Piece::Text(s) => s.len(), + } + } +} + +/// Joins pieces into a new string, reserving its length first. +fn build(interp: &mut Interp, units: &[u16], pieces: &[Piece]) -> JsResult { + let total = pieces.iter().map(Piece::len).fold(0usize, usize::saturating_add); + reserve_units(interp, total)?; + let mut out = Vec::with_capacity(total); + for p in pieces { + match p { + Piece::Range(a, b) => out.extend_from_slice(units.get(*a..*b).unwrap_or_default()), + Piece::Text(s) => out.extend_from_slice(&s.flat()), + } + } + Ok(Value::String(Str::from_units(out))) +} + +/// The pieces a replacement pattern (section 15.5.4.11, Table 22) gives for one match: `caps` +/// holds the match and its groups as pairs of positions in `units`. +fn expand(template: &[u16], units: &[u16], caps: &[usize], out: &mut Vec) { + let groups = caps.len() / 2 - 1; + let (start, end) = (caps.first().copied().unwrap_or(0), caps.get(1).copied().unwrap_or(0)); + let digit = |i: usize| template.get(i).filter(|&&u| (0x30..=0x39).contains(&u)).map(|&u| usize::from(u - 0x30)); + let group = |n: usize, out: &mut Vec| { + let (s, e) = (caps.get(2 * n).copied().unwrap_or(NONE), caps.get(2 * n + 1).copied().unwrap_or(NONE)); + if s != NONE && e != NONE { + out.push(Piece::Range(s, e)); + } + }; + let mut lit = 0; + let mut i = 0; + while let Some(&u) = template.get(i) { + if u != 0x24 { + i += 1; + continue; + } + let flush = |out: &mut Vec, to: usize| { + if to > lit { + out.push(Piece::Text(Str::from_units(template.get(lit..to).unwrap_or_default().to_vec()))); + } + }; + let taken = match template.get(i + 1) { + Some(0x24) => { + flush(out, i + 1); + lit = i + 2; + i += 2; + continue; + } + Some(0x26) => { + flush(out, i); + out.push(Piece::Range(start, end)); + 2 + } + Some(0x60) => { + flush(out, i); + out.push(Piece::Range(0, start)); + 2 + } + Some(0x27) => { + flush(out, i); + out.push(Piece::Range(end, units.len())); + 2 + } + _ => match (digit(i + 1), digit(i + 2)) { + (Some(a), Some(b)) if (1..=groups).contains(&(a * 10 + b)) => { + flush(out, i); + group(a * 10 + b, out); + 3 + } + (Some(a), _) if (1..=groups).contains(&a) => { + flush(out, i); + group(a, out); + 2 + } + _ => { + i += 1; + continue; + } + }, + }; + i += taken; + lit = i; + } + if template.len() > lit { + out.push(Piece::Text(Str::from_units(template.get(lit..).unwrap_or_default().to_vec()))); + } +} + +/// `replace` (section 15.5.4.11). +fn replace(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + rooted(interp, |interp| { + let s = this_str(interp, &this, "replace")?; + let search_value = arg(args, 0); + let replace_value = arg(args, 1); + let units = s.flat(); + // Each match as the positions of the match and its groups. + let mut matches: Vec> = Vec::new(); + if let Some((rx, _)) = regexp_of(interp, &search_value) { + let global = interp.get_value(&search_value, &crate::heap::key("global"))?.truthy(); + if global { + let last_index = crate::heap::key("lastIndex"); + interp.put(rx, last_index.clone(), Value::Number(0.0), true)?; + let mut previous = 0.0; + // The bytes the stored matches and the pieces of the result made from them hold, + // kept within the heap room left. + let mut stored = 0usize; + while let Some(caps) = exec_raw(interp, rx, &s)? { + stored = stored.saturating_add(24 + size_of::() * caps.len() + 2 * size_of::()); + interp.reserve_heap(stored)?; + let this_index = interp.get_value(&search_value, &last_index)?; + let this_index = interp.to_number(&this_index)?; + if this_index == previous { + previous = this_index + 1.0; + interp.put(rx, last_index.clone(), Value::Number(previous), true)?; + } else { + previous = this_index; + } + matches.push(caps); + } + } else if let Some(caps) = exec_raw(interp, rx, &s)? { + matches.push(caps); + } + } else { + let search = interp.to_str(&search_value)?; + let needle = search.flat(); + if let Some(i) = find(interp, &units, &needle, 0)? { + matches.push(vec![i, i + needle.len()]); + } + } + let replacer = interp.is_callable(&replace_value).then(|| replace_value.clone()); + let template = match &replacer { + Some(_) => None, + None => Some(interp.to_str(&replace_value)?.flat()), + }; + if matches.is_empty() { + return Ok(Value::String(s)); + } + let mut pieces = Vec::new(); + let mut last = 0usize; + for caps in &matches { + let (start, end) = (caps.first().copied().unwrap_or(0), caps.get(1).copied().unwrap_or(0)); + pieces.push(Piece::Range(last, start)); + match (&replacer, &template) { + (Some(f), _) => { + let mut call_args = Vec::with_capacity(caps.len() / 2 + 2); + for pair in caps.chunks(2) { + let v = match pair { + [a, b] if *a != NONE && *b != NONE => substring_value(interp, &units, *a, *b)?, + _ => Value::Undefined, + }; + call_args.push(v); + } + call_args.push(Value::Number(start as f64)); + call_args.push(Value::String(s.clone())); + let r = interp.call(f, &Value::Undefined, &call_args)?; + let text = interp.to_str(&r)?; + pieces.push(Piece::Text(text)); + } + (None, Some(t)) => expand(t, &units, caps, &mut pieces), + (None, None) => {} + } + use_fuel_n(interp, 1)?; + last = end; + } + pieces.push(Piece::Range(last, units.len())); + build(interp, &units, &pieces) + }) +} + +/// `split` (section 15.5.4.14). +fn split(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + rooted(interp, |interp| { + let s = this_str(interp, &this, "split")?; + let separator = arg(args, 0); + let limit = arg(args, 1); + let a = new_array(interp); + let lim = if matches!(limit, Value::Undefined) { u32::MAX } else { to_uint32(interp.to_number(&limit)?) }; + let rx = regexp_of(interp, &separator); + let sep = match &rx { + Some(_) => None, + None => Some(interp.to_str(&separator)?.flat()), + }; + if lim == 0 { + return Ok(Value::Object(a)); + } + if matches!(separator, Value::Undefined) { + set_element(interp, a, 0, Value::String(s))?; + return Ok(Value::Object(a)); + } + let units = s.flat(); + let size = units.len(); + let re: Option> = rx.map(|(_, re)| re); + let mut matcher = re.as_deref().map(|re| Matcher::new(re, interp)).transpose()?; + // SplitMatch (section 15.5.4.14): the end of a separator match at `q` and the groups. + let mut split_match = |interp: &mut Interp, q: usize| -> JsResult)>> { + match (&mut matcher, &sep) { + (Some(m), _) => { + if m.match_at(interp, &units, q)? { + Ok(Some((m.caps.get(1).copied().unwrap_or(q), m.caps.get(2..).map(<[usize]>::to_vec).unwrap_or_default()))) + } else { + Ok(None) + } + } + (None, Some(r)) => { + use_fuel_n(interp, (r.len() / UNITS_PER_FUEL) as u64 + 1)?; + if units.get(q..q + r.len()) == Some(&r[..]) { Ok(Some((q + r.len(), Vec::new()))) } else { Ok(None) } + } + (None, None) => Ok(None), + } + }; + if size == 0 { + if split_match(interp, 0)?.is_none() { + set_element(interp, a, 0, Value::String(s.clone()))?; + } + return Ok(Value::Object(a)); + } + let mut n = 0usize; + let (mut p, mut q) = (0usize, 0usize); + while q != size { + match split_match(interp, q)? { + None => q += 1, + Some((e, _)) if e == p => q += 1, + Some((e, caps)) => { + let t = substring_value(interp, &units, p, q)?; + set_element(interp, a, n, t)?; + n += 1; + if n as u64 == u64::from(lim) { + return Ok(Value::Object(a)); + } + p = e; + for pair in caps.chunks(2) { + let v = match pair { + [x, y] if *x != NONE && *y != NONE => substring_value(interp, &units, *x, *y)?, + _ => Value::Undefined, + }; + set_element(interp, a, n, v)?; + n += 1; + if n as u64 == u64::from(lim) { + return Ok(Value::Object(a)); + } + } + q = p; + } + } + } + let t = substring_value(interp, &units, p, size)?; + set_element(interp, a, n, t)?; + Ok(Value::Object(a)) + }) +} diff --git a/crates/js/src/text_tests.rs b/crates/js/src/text_tests.rs new file mode 100644 index 000000000..39fec2dc9 --- /dev/null +++ b/crates/js/src/text_tests.rs @@ -0,0 +1,441 @@ +//! Tests of `String`, `Number`, `RegExp` and `Date` run through a realm. + +use proptest::prelude::*; + +use crate::{JsValue, LimitKind, Limits, Options, Realm, RunError, RunErrorKind}; + +fn run_in(realm: &mut Realm, src: &str) -> JsValue { + match realm.run(src) { + Ok(v) => v, + Err(e) => panic!("{src}\n{e:?}"), + } +} + +fn run(src: &str) -> JsValue { + run_in(&mut Realm::new(), src) +} + +fn string(src: &str) -> String { + match run(src) { + JsValue::String(s) => s, + other => panic!("{src}: {other:?}"), + } +} + +/// Script helpers that need no `Array.prototype` method: `show(a, sep)` joins an array (`null` +/// shows as `null`, `undefined` elements as nothing), `nan(n)` tells NaN apart. +const HELPERS: &str = "function show(a, sep) { if (a === null) return 'null'; var s = ''; \ + for (var i = 0; i < a.length; i++) s += (i ? (sep === undefined ? ',' : sep) : '') + (a[i] === undefined ? '' : a[i]); return s; } \ + function nan(n) { return n !== n ? 'NaN' : 'num'; }\n"; + +/// Runs `src` after [`HELPERS`] and checks it gives the string `want`. +fn shows(src: &str, want: &str) { + match run(&format!("{HELPERS}{src}")) { + JsValue::String(s) => assert_eq!(s, want, "{src}"), + other => panic!("{src}: {other:?}"), + } +} + +fn truth(src: &str) { + assert_eq!(run(src), JsValue::Bool(true), "{src}"); +} + +fn fail_with(mut realm: Realm, src: &str) -> RunError { + match realm.run(src) { + Ok(v) => panic!("{src} completed with {v:?}"), + Err(e) => e, + } +} + +fn thrown(src: &str) -> String { + let e = fail_with(Realm::new(), src); + assert_eq!(e.kind, RunErrorKind::Thrown, "{src}: {e:?}"); + e.message +} + +// ---- String ---- + +#[test] +fn string_converts_and_wraps() { + assert_eq!(string("String(12.5) + String() + String(null)"), "12.5null"); + truth("var s = new String('ab'); typeof s === 'object' && s.length === 2 && s[1] === 'b' && s.valueOf() === 'ab'"); + assert_eq!(string("String.fromCharCode(104, 105, 65536 + 33)"), "hi!"); + assert!(thrown("String.prototype.toString.call({})").starts_with("TypeError")); + assert!(thrown("String.prototype.trim.call(null)").starts_with("TypeError")); +} + +#[test] +fn string_methods_follow_section_15_5_4() { + let cases: &[(&str, &str)] = &[ + ("'abc'.charAt(1) + 'abc'.charAt(5) + 'abc'.charAt(-1)", "b"), + ("'' + 'abc'.charCodeAt(0) + nan('abc'.charCodeAt(3))", "97NaN"), + ("'a'.concat('b', 1, null)", "ab1null"), + ("'' + 'abcabc'.indexOf('c') + 'abcabc'.indexOf('c', 3) + 'abc'.indexOf('') + 'abc'.indexOf('', 9)", "2503"), + ("'' + 'abcabc'.lastIndexOf('c') + 'abcabc'.lastIndexOf('c', 4) + 'abc'.lastIndexOf('x')", "52-1"), + ("'' + 'a'.localeCompare('b') + 'b'.localeCompare('a') + 'a'.localeCompare('a')", "-110"), + ("'abcdef'.slice(1, -1) + '|' + 'abcdef'.slice(-2) + '|' + 'abc'.slice(2, 1)", "bcde|ef|"), + ("'abcdef'.substring(4, 1) + '|' + 'abcdef'.substring(-3, 2) + '|' + 'abc'.substring(1)", "bcd|ab|bc"), + ("'abcdef'.substr(-3, 2) + '|' + 'abcdef'.substr(1) + '|' + 'abc'.substr(1, -1)", "de|bcdef|"), + ("'\\u00dfa\\u03a3'.toUpperCase() + '|' + 'A\\u03a3'.toLowerCase() + '|' + 'A\\u03a3b'.toLowerCase()", "SSA\u{3a3}|a\u{3c2}|a\u{3c3}b"), + ("'[' + ' \\t\\n\\u00a0\\ufeffx y\\u2028 '.trim() + ']'", "[x y]"), + ("show('a,b,,c'.split(','), '|')", "a|b||c"), + ("show('abc'.split(''), '|') + ';' + 'abc'.split().length + ';' + show('abc'.split('', 2), '|')", "a|b|c;1;a|b"), + ("''.split('').length + ';' + ''.split('x').length", "0;1"), + ("show('a1b22c'.split(/(\\d)+/), '|')", "a|1|b|2|c"), + ("show('ab'.split(/(?:)/), '|') + ';' + show('ab'.split(/a*?/), '|')", "a|b;a|b"), + ("'aaa'.replace('a', '$&$&') + ';' + 'abc'.replace('b', '[$`|$\\'|$$]')", "aaaa;a[a|c|$]c"), + ("'John Smith'.replace(/(\\w+)\\s(\\w+)/, '$2, $1') + ';' + 'x'.replace(/x/, '$1$01$10')", "Smith, John;$1$01$10"), + ("'a-b-c'.replace(/-/g, function (m, i, s) { return '[' + i + s.length + ']'; })", "a[15]b[35]c"), + ("'aaa'.replace(/a*?/g, '-')", "-a-a-a-"), + ("'abc'.match(/b/).index + ';' + show('abab'.match(/a(b)/g), '|') + ';' + ('x'.match(/y/g) === null)", "1;ab|ab;true"), + ("'' + 'abc'.search(/c/) + 'abc'.search('b') + 'abc'.search(/x/)", "21-1"), + ]; + for (src, want) in cases { + shows(src, want); + } +} + +#[test] +fn string_methods_count_against_the_heap_before_allocating() { + // Doubling with `concat` and `replace` stops at the heap limit, not at an allocation failure. + let limits = Limits { heap_bytes: 8 << 20, ..Limits::default() }; + for src in [ + "var s = 'x'; for (;;) s = s.concat(s);", + "var s = 'xx'; for (;;) s = s.replace(/x/g, 'xx');", + "var s = 'x'; for (;;) s = (s + s).toUpperCase();", + "var s = 'x'; for (;;) s = (s + s + s).slice(0) + s.substr(0);", + ] { + let e = fail_with(Realm::with_options(Options::default(), limits.clone()), src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{src}: {e:?}"); + } + // A replacement that would build a string past the heap limit is refused before it is built. + let limits = Limits { heap_bytes: 4 << 20, ..Limits::default() }; + let e = fail_with( + Realm::with_options(Options::default(), limits), + "var big = 'yyyyyyyyyyyyyyyy'; for (var i = 0; i < 15; i++) big = big + big; 'xxxxxxxxxx'.replace(/x/g, big)", + ); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{e:?}"); +} + +// ---- Number ---- + +#[test] +fn numbers_format_as_section_15_7_4_says() { + let cases: &[(&str, &str)] = &[ + ("(255).toString(16) + ' ' + (-255).toString(2) + ' ' + (0.5).toString(2) + ' ' + (1e21).toString(36)", "ff -11111111 0.1 5v1j4f4ds7c000"), + ("(123.456).toString() + ' ' + (1e21).toString(10) + ' ' + (0.000001).toString()", "123.456 1e+21 0.000001"), + ("(0.5).toFixed(0) + ' ' + (1.255).toFixed(2) + ' ' + (0.1).toFixed(20) + ' ' + (1e21).toFixed(2)", "1 1.25 0.10000000000000000555 1e+21"), + ("(123456).toExponential(2) + ' ' + (123456).toExponential() + ' ' + (0).toExponential(2)", "1.23e+5 1.23456e+5 0.00e+0"), + ( + "(123.456).toPrecision(4) + ' ' + (0.0000001234).toPrecision(2) + ' ' + (123456).toPrecision(2) + ' ' + (5).toPrecision()", + "123.5 1.2e-7 1.2e+5 5", + ), + ( + "(-1.5).toFixed(0) + ' ' + (NaN).toFixed(2) + ' ' + (Infinity).toExponential(50) + ' ' + (-Infinity).toPrecision(50)", + "-2 NaN Infinity -Infinity", + ), + ("new Number(5).toString(2) + ' ' + Number('0x10') + ' ' + Number() + ' ' + Number.MIN_VALUE", "101 16 0 5e-324"), + ]; + for (src, want) in cases { + assert_eq!(string(src), *want, "{src}"); + } + truth("Number.MAX_VALUE === 1.7976931348623157e308 && Number.POSITIVE_INFINITY === 1/0 && Number.NaN !== Number.NaN"); + truth( + "typeof new Number(1) === 'object' && new Number(7).valueOf() === 7 && Object.prototype.toString.call(Number.prototype) === '[object Number]'", + ); + for src in [ + "(1).toFixed(21)", + "(1).toFixed(-1)", + "(1).toExponential(21)", + "(1).toPrecision(0)", + "(1).toPrecision(22)", + "(1).toString(1)", + "(1).toString(37)", + ] { + assert!(thrown(src).starts_with("RangeError"), "{src}"); + } + assert!(thrown("Number.prototype.toFixed.call('1', 2)").starts_with("TypeError")); +} + +// ---- RegExp ---- + +#[test] +fn regular_expressions_match_as_section_15_10_2_says() { + let cases: &[(&str, &str)] = &[ + ("show(/a(b)?c/.exec('ac'))", "ac,"), + ("show(/(z)((a+)?(b+)?(c))*/.exec('zaacbbbcac'))", "zaacbbbcac,z,ac,a,,c"), + ("show(/(a*)*/.exec('b'))", ","), + ("show(/(a*)b\\1+/.exec('baaaac'))", "b,"), + ("show(/(?=(a+))/.exec('baaabac'))", ",aaa"), + ("show(/(?=(a+))a*b\\1/.exec('baaabac'))", "aba,a"), + ("show(/(.*?)a(?!(a+)b\\2c)\\2(.*)/.exec('baaabaac'))", "baaabaac,ba,,abaac"), + ("show(/((a)|b)+/.exec('ab'))", "ab,b,"), + ("show(/a[a-z]{2,4}/.exec('abcdefghi'))", "abcde"), + ("show(/a[a-z]{2,4}?/.exec('abcdefghi'))", "abc"), + ("show(/(aa|aabaac|ba|b|c)*/.exec('aabaac'))", "aaba,ba"), + ("show(/[^\\d\\s]+/.exec('12 ab3'))", "ab"), + ("show(/\\bfoo\\B/.exec('a foox'))", "foo"), + ("show(/^b/m.exec('a\\nb')) + /^b/.test('a\\nb')", "bfalse"), + ("/ß/i.test('SS') + ' ' + show(/[a-z]+/i.exec('HeLLo')) + ' ' + /\\u017f/i.test('s')", "false HeLLo false"), + ("/[\\u017f]/i.test('S') + ' ' + /k/i.test('\\u212a')", "false false"), + ("show(/\\1(a)/.exec('aa')) + ';' + show(/(a\\1)/.exec('aa'))", "a,a;a,a"), + ("/\\0\\x41\\u0042\\cJ\\101/.test('\\0AB\\nA') + ' ' + /[\\b]/.test('\\b') + ' ' + /a{,2}/.test('a{,2}')", "true true true"), + ("/]/.test(']') + ' ' + show(/[\\d-z]+/.exec('5-z')) + ' ' + /\\8/.test('8')", "true 5-z true"), + ("/(?:a|b)*c/.exec('ababc').index + ' ' + /x*/.exec('yyy').index", "0 0"), + ("/[]/.test('a') + ' ' + /[^]/.test('\\n')", "false true"), + ]; + for (src, want) in cases { + shows(src, want); + } +} + +#[test] +fn regexp_objects_follow_sections_15_10_4_to_15_10_7() { + truth("var r = /a/g; r.lastIndex === 0 && r.global && !r.ignoreCase && !r.multiline && r.source === 'a'"); + truth( + "var r = /a/g; r.exec('aa'); var i = r.lastIndex; r.exec('aa'); var j = r.lastIndex; r.exec('aa'); i === 1 && j === 2 && r.lastIndex === 0", + ); + truth("var r = /a/; r.lastIndex = 5; r.test('a') && r.lastIndex === 5"); + truth("var r = new RegExp('a/b', 'im'); r.source === 'a\\\\/b' && r.ignoreCase && r.multiline && String(r) === '/a\\\\/b/im'"); + truth("String(new RegExp()) === '/(?:)/' && new RegExp('').test('x')"); + truth("var r = new RegExp('[/]'); String(r) === '/[/]/' && eval(String(r)).test('/') && eval(String(new RegExp('a/'))).source === 'a\\\\/'"); + truth("var r = /x/g; RegExp(r) === r && new RegExp(r) !== r && new RegExp(r).global"); + truth("var m = /(a)|(b)/.exec('b'); m.length === 3 && m[1] === undefined && m[2] === 'b' && m.index === 0 && m.input === 'b'"); + truth("Object.prototype.toString.call(RegExp.prototype) === '[object RegExp]' && Object.prototype.toString.call(/x/) === '[object RegExp]'"); + truth("var p = RegExp.prototype; p.hasOwnProperty('global') && p.hasOwnProperty('lastIndex') && p.source === '(?:)' && p.global === false"); + for src in ["new RegExp('(')", "new RegExp('a', 'gg')", "new RegExp('a', 'x')", "new RegExp('a**')", "new RegExp('[b-a]')"] { + assert!(thrown(src).starts_with("SyntaxError"), "{src}"); + } + assert!(thrown("new RegExp(/a/, 'g')").starts_with("TypeError")); + assert!(thrown("RegExp.prototype.exec.call({}, 'a')").starts_with("TypeError")); +} + +#[test] +fn a_catastrophic_regular_expression_ends_by_fuel() { + let limits = Limits { fuel: 2_000_000, ..Limits::default() }; + let src = "var s = ''; for (var i = 0; i < 40; i++) s += 'a'; /(a+)+$/.test(s + 'b')"; + let e = fail_with(Realm::with_options(Options::default(), limits.clone()), src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel), "{e:?}"); + // The interrupt flag stops it too. + let realm = Realm::with_options(Options::default(), Limits { fuel: u64::MAX, ..Limits::default() }); + realm.interrupt().set(); + let e = fail_with(realm, src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Interrupted), "{e:?}"); +} + +#[test] +fn trim_and_date_parsing_take_fuel_for_the_units_they_scan() { + let limits = Limits { fuel: 40_000, ..Limits::default() }; + let build = "var s = ' '; for (var i = 0; i < 20; i++) s = s + s; s.charAt(0)"; + assert!(Realm::with_options(Options::default(), limits.clone()).run(build).is_ok()); + for scan in ["s.trim()", "Date.parse(s)", "new Date(s)"] { + let e = fail_with(Realm::with_options(Options::default(), limits.clone()), &format!("{build}; {scan}")); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel), "{scan}: {e:?}"); + } +} + +#[test] +fn global_replace_counts_its_matches_against_the_heap() { + let src = "var s = 'ab'; for (var i = 0; i < 17; i++) s = s + s; s.replace(/./g, '').length"; + let e = fail_with(Realm::with_options(Options::default(), Limits { heap_bytes: 4 << 20, ..Limits::default() }), src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{e:?}"); + assert_eq!(run(src), JsValue::Number(0.0)); +} + +#[test] +fn compiling_a_pattern_counts_against_the_heap() { + let src = "var s = '[\\\\s\\\\S]a*'; for (var i = 0; i < 14; i++) s = s + s; new RegExp(s).source.length"; + let e = fail_with(Realm::with_options(Options::default(), Limits { heap_bytes: 2 << 20, ..Limits::default() }), src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{e:?}"); + assert_eq!(run(src), JsValue::Number(131_072.0)); + // The compiler stops while it reads the pattern, before the code it builds passes the room. + let pattern: Vec = "[\\s\\S]a*".repeat(1000).encode_utf16().collect(); + let flags = crate::regexp_vm::parse_flags(&[]).unwrap(); + let room = crate::regexp_vm::compile_bytes(400, 0, 0); + let result = crate::regexp_vm::compile(&pattern, flags, 200, room, &mut |_| Ok(())); + assert!(matches!(result, Err(crate::regexp_vm::CompileFail::Abrupt(crate::interp::Abrupt::Limit(LimitKind::Heap))))); + assert!(crate::regexp_vm::compile(&pattern, flags, 200, usize::MAX, &mut |_| Ok(())).is_ok()); + // A literal pattern takes what its instructions hold, not the most a unit can take. + let literal: Vec = "a".repeat(10_000).encode_utf16().collect(); + let room = crate::regexp_vm::compile_bytes(20_000, 0, 0); + assert!(crate::regexp_vm::compile(&literal, flags, 200, room, &mut |_| Ok(())).is_ok()); + // A class's ranges count while they are read, even when they collapse into one range. + let class: Vec = format!("[{}]", "a".repeat(10_000)).encode_utf16().collect(); + let result = crate::regexp_vm::compile(&class, flags, 200, 1000, &mut |_| Ok(())); + assert!(matches!(result, Err(crate::regexp_vm::CompileFail::Abrupt(crate::interp::Abrupt::Limit(LimitKind::Heap))))); + // The joining and flattening after the last unit count too. + let ab: Vec = "ab".encode_utf16().collect(); + let room = crate::regexp_vm::compile_bytes(1, 0, 0); + let result = crate::regexp_vm::compile(&ab, flags, 200, room, &mut |_| Ok(())); + assert!(matches!(result, Err(crate::regexp_vm::CompileFail::Abrupt(crate::interp::Abrupt::Limit(LimitKind::Heap))))); +} + +#[test] +fn compiling_a_pattern_collects_garbage_when_the_heap_is_full() { + let mut realm = Realm::with_options(Options::default(), Limits { heap_bytes: 256 << 10, ..Limits::default() }); + let src = "var n = 0; for (var i = 0; i < 5000; i++) { var junk = 'x' + i + 'y' + i; new RegExp('(a|b)c[d-f]'); n++; } n"; + assert_eq!(run_in(&mut realm, src), JsValue::Number(5000.0)); +} + +#[test] +fn a_constructed_source_counts_against_the_heap_before_it_is_built() { + let mut realm = Realm::new(); + let interp = realm.interp(); + interp.collect_for_room(); + let live = interp.heap.bytes; + let pattern = vec![0x2028u16; 10_000]; + interp.limits.heap_bytes = live + 50_000; + assert!(matches!(crate::regexp_builtins::source_text(interp, &pattern), Err(crate::interp::Abrupt::Limit(LimitKind::Heap)))); + interp.limits.heap_bytes = live + (1 << 20); + let Ok(source) = crate::regexp_builtins::source_text(interp, &pattern) else { panic!("the source fits") }; + assert_eq!(source.flat().len(), 60_000); + assert_eq!(string("String(new RegExp('a/\\\\/[/]\\n\\r\\u2028\\u2029'))"), "/a\\/\\/[/]\\n\\r\\u2028\\u2029/"); +} + +#[test] +fn the_matcher_state_counts_against_the_heap() { + let mut realm = Realm::with_options(Options::default(), Limits { heap_bytes: 1 << 20, ..Limits::default() }); + let pattern: Vec = "(a)".repeat(1000).encode_utf16().collect(); + let flags = crate::regexp_vm::parse_flags(&[]).unwrap(); + let Ok(re) = crate::regexp_vm::compile(&pattern, flags, 200, usize::MAX, &mut |_| Ok(())) else { panic!("the pattern compiles") }; + let interp = realm.interp(); + interp.collect_for_room(); + let live = interp.heap.bytes; + interp.limits.heap_bytes = live + 1000; + assert!(matches!(crate::regexp_vm::Matcher::new(&re, interp), Err(crate::interp::Abrupt::Limit(LimitKind::Heap)))); + interp.limits.heap_bytes = 1 << 20; + assert!(crate::regexp_vm::Matcher::new(&re, interp).is_ok()); + // The stack takes only the room left: with room for the state and no more, backtracking ends + // with the heap limit. + let pattern: Vec = "a*b".encode_utf16().collect(); + let Ok(re) = crate::regexp_vm::compile(&pattern, flags, 200, usize::MAX, &mut |_| Ok(())) else { panic!("the pattern compiles") }; + let input: Vec = "aaaaaaaaaa".encode_utf16().collect(); + interp.limits.heap_bytes = live + 64; + let Ok(mut m) = crate::regexp_vm::Matcher::new(&re, interp) else { panic!("the state fits") }; + assert!(matches!(m.search(interp, &input, 0), Err(crate::interp::Abrupt::Limit(LimitKind::Heap)))); + interp.limits.heap_bytes = 1 << 20; + let Ok(mut m) = crate::regexp_vm::Matcher::new(&re, interp) else { panic!("the state fits") }; + assert!(matches!(m.search(interp, &input, 0), Ok(false))); +} + +#[test] +fn deeply_nested_groups_give_a_syntax_error_and_never_overflow_the_stack() { + let deep = |n: usize| format!("{}a{}", "(".repeat(n), ")".repeat(n)); + let src = format!("try {{ new RegExp('{}'); 'compiled' }} catch (e) {{ e.name }}", deep(50_000)); + assert_eq!(run(&src), JsValue::String("SyntaxError".into())); + // With the depth limit raised, the same nesting compiles and matches on a small stack: the + // compiler and the matcher keep their state on the heap. + let handle = std::thread::Builder::new() + .stack_size(256 << 10) + .spawn(move || { + let options = Options { max_depth: 60_000, ..Options::default() }; + let mut realm = Realm::with_options(options, Limits::default()); + let src = format!("var m = new RegExp('{}').exec('xa'); m.length + ':' + m[50000]", deep(50_000)); + realm.run(&src) + }) + .unwrap(); + assert_eq!(handle.join().unwrap(), Ok(JsValue::String("50001:a".into()))); +} + +#[test] +fn the_matcher_stack_counts_against_the_heap() { + let limits = Limits { heap_bytes: 4 << 20, ..Limits::default() }; + let src = "var s = 'ab'; for (var i = 0; i < 18; i++) s = s + s; /(?:a|b)*c/.test(s)"; + let e = fail_with(Realm::with_options(Options::default(), limits), src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{e:?}"); +} + +proptest! { + #[test] + fn arbitrary_patterns_and_inputs_never_panic(pattern in "[ab()|*+?{}\\[\\]^$.\\\\dws0-9,:=!-]{0,24}", input in "[ab\\n0-9 ]{0,24}", flags in "[gim]{0,2}") { + let mut realm = Realm::with_options(Options::default(), Limits { fuel: 200_000, ..Limits::default() }); + let quote = |s: &str| s.replace('\\', "\\\\").replace('\'', "\\'").replace('\n', "\\n"); + let src = format!( + "try {{ var r = new RegExp('{}', '{}'); var s = '{}'; [r.exec(s), s.replace(r, '$1'), s.split(r), s.match(r), s.search(r)].length }} catch (e) {{ e.name }}", + quote(&pattern), flags, quote(&input) + ); + match realm.run(&src) { + Ok(_) => {} + Err(e) => prop_assert!(matches!(e.kind, RunErrorKind::Limit(_)), "{src}: {e:?}"), + } + } +} + +// ---- Date ---- + +proptest! { + #[test] + fn arbitrary_date_strings_never_panic(text in "[0-9TZ:.+ ,()a-zA-Z-]{0,32}") { + let mut realm = Realm::new(); + let src = format!("var t = Date.parse('{text}'); new Date('{text}').toString(); t"); + prop_assert!(realm.run(&src).is_ok(), "{src}"); + } +} + +fn date_realm(now: f64, offset_minutes: f64) -> Realm { + let mut realm = Realm::new(); + realm.set_clock(move || now); + realm.set_time_zone(move |_| offset_minutes * 60_000.0); + realm +} + +#[test] +fn dates_read_the_host_clock_and_time_zone() { + // 2026-11-07T12:34:56.789Z. + let now = 1_794_054_896_789.0; + let mut utc = date_realm(now, 0.0); + assert_eq!(run_in(&mut utc, "Date.now()"), JsValue::Number(now)); + assert_eq!(run_in(&mut utc, "new Date().getTime()"), JsValue::Number(now)); + assert_eq!(run_in(&mut utc, "new Date().toISOString()"), JsValue::String("2026-11-07T12:34:56.789Z".into())); + assert_eq!(run_in(&mut utc, "String(new Date())"), JsValue::String("Sat Nov 07 2026 12:34:56 GMT+0000".into())); + assert_eq!(run_in(&mut utc, "Date()"), JsValue::String("Sat Nov 07 2026 12:34:56 GMT+0000".into())); + assert_eq!(run_in(&mut utc, "new Date().toUTCString()"), JsValue::String("Sat, 07 Nov 2026 12:34:56 GMT".into())); + let mut east = date_realm(now, 90.0); + assert_eq!(run_in(&mut east, "String(new Date())"), JsValue::String("Sat Nov 07 2026 14:04:56 GMT+0130".into())); + assert_eq!( + run_in(&mut east, "var d = new Date(); d.getHours() + ',' + d.getUTCHours() + ',' + d.getMinutes() + ',' + d.getTimezoneOffset()"), + JsValue::String("14,12,4,-90".into()) + ); + assert_eq!(run_in(&mut east, "new Date(2026, 10, 7, 14, 4, 56, 789).getTime()"), JsValue::Number(now)); + // An offset of a day or more, or one that is not finite, counts as 0. + for offset in [f64::MAX, 86_400_000.0, f64::NAN] { + let mut odd = Realm::new(); + odd.set_clock(move || now); + odd.set_time_zone(move |_| offset); + assert_eq!(run_in(&mut odd, "var d = new Date(); d.getHours() + ',' + d.getTimezoneOffset()"), JsValue::String("12,0".into()), "{offset}"); + } + let mut west = date_realm(now, -300.0); + assert_eq!(run_in(&mut west, "new Date(2026, 0, 31).toDateString()"), JsValue::String("Sat Jan 31 2026".into())); +} + +#[test] +fn dates_round_trip_through_their_strings() { + let mut realm = date_realm(1_794_054_896_789.0, -420.0); + for src in [ + "var d = new Date(); Date.parse(d.toString()) === Math_floor(d.getTime())", + "var d = new Date(); Date.parse(d.toUTCString()) === Math_floor(d.getTime())", + "var d = new Date(); Date.parse(d.toISOString()) === d.getTime()", + "var d = new Date(-1e14); Date.parse(d.toISOString()) === d.getTime() && Date.parse(d.toString()) === d.getTime()", + "new Date(new Date(5).toISOString()).getTime() === 5", + "Date.UTC(2000, 1, 29) === Date.parse('2000-02-29') && Date.UTC(99, 0) === Date.UTC(1999, 0)", + "Date.UTC(2000) === Date.UTC(2000, 0) && Date.UTC(1970) === 0 && isNaN_(Date.UTC())", + "isNaN_(Date.parse('nonsense')) && isNaN_(new Date(8.64e15 + 1).getTime()) && new Date(8.64e15).getTime() === 8.64e15", + "var d = new Date(2000, 0, 31); d.setMonth(1); d.getMonth() === 2 && d.getDate() === 2", + "var d = new Date(NaN); d.setFullYear(2001); d.getFullYear() === 2001 && d.getMonth() === 0", + "var d = new Date(0); d.setUTCHours(25, 61, 61, 1001); d.toISOString() === '1970-01-02T02:02:02.001Z'", + "var d = new Date(0); d.setMinutes(); isNaN_(d.getTime())", + "var d = new Date(2000, 0, 1); d.setYear(99); d.getFullYear() === 1999 && d.getYear() === 99", + "Date.prototype.toGMTString === Date.prototype.toUTCString && String(new Date(NaN)) === 'Invalid Date'", + "new Date(0).toJSON() === '1970-01-01T00:00:00.000Z' && new Date(NaN).toJSON() === null", + "Object.prototype.toString.call(Date.prototype) === '[object Date]' && isNaN_(Date.prototype.getTime())", + "var d = new Date(0); d + d === d.toString() + d.toString() && d - d === 0 && d == d.toString()", + "isNaN_(Date.parse('1970-00-01')) && isNaN_(Date.parse('1970-13-01')) && isNaN_(Date.parse('1970-02-30'))", + ] { + let full = format!("function Math_floor(t) {{ return t - t % 1000; }} function isNaN_(n) {{ return n !== n; }} {src}"); + assert_eq!(run_in(&mut realm, &full), JsValue::Bool(true), "{src}"); + } + let e = fail_with(date_realm(0.0, 0.0), "new Date(NaN).toISOString()"); + assert!(e.message.starts_with("RangeError"), "{e:?}"); + let e = fail_with(date_realm(0.0, 0.0), "Date.prototype.getTime.call({})"); + assert!(e.message.starts_with("TypeError"), "{e:?}"); +} diff --git a/crates/js/tests/test262.rs b/crates/js/tests/test262.rs index 8c87bb19d..f090e7273 100644 --- a/crates/js/tests/test262.rs +++ b/crates/js/tests/test262.rs @@ -19,6 +19,11 @@ //! passes when it completes, or, for `negative: phase: runtime`, when it throws an object whose //! `constructor` is the global its `type:` names. Its counts are printed apart from the parse //! counts, and its failures go to `run-failures.txt`. +//! +//! A third test runs the tests under `test/built-ins/String`, `Number`, `RegExp` and `Date` the same +//! way, counting the tests that name an ES5 section apart from the other tests without a feature +//! tag; its failures go to `builtins-failures.txt`. The interpreter tests fail only when the +//! interpreter panics. use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -203,8 +208,20 @@ const MISSING_BUILTINS: &str = "Array String Number Boolean Math JSON Date RegEx fromCharCode substring substr split replace match search exec test toUpperCase toLowerCase trim toFixed toPrecision toExponential \ bind now isArray compareArray verifyProperty fnGlobalObject"; -/// The stand-in for `String` that the harness's failure messages call. -const HARNESS_STRING: &str = "function String(value) { return '' + value; }"; +/// The names in [`MISSING_BUILTINS`] that `String`, `Number`, `RegExp` and `Date` provide. +const TEXT_BUILTINS: &str = "String Number Date RegExp charAt charCodeAt fromCharCode substring substr split replace match search exec test \ + toUpperCase toLowerCase trim toFixed toPrecision toExponential now"; + +/// String methods whose names `Array.prototype` also uses: provided for the tests under +/// `built-ins/String/`, and still missing elsewhere, where the name usually means the array method. +const STRING_METHODS_SHARED_WITH_ARRAY: &str = "indexOf lastIndexOf concat slice"; + +/// The names in [`MISSING_BUILTINS`] the interpreter still lacks, without those in each of +/// `provided`. +fn missing_builtins<'a>(provided: &[&str]) -> HashSet<&'a str> { + let provided: HashSet<&str> = provided.iter().flat_map(|p| p.split_whitespace()).collect(); + MISSING_BUILTINS.split_whitespace().filter(|name| !provided.contains(name)).collect() +} /// The source names a missing built-in, as an identifier, a property name or a word inside a /// string literal (code passed to `eval` or `Function`), or does not parse as ES5 (the tests that @@ -249,32 +266,104 @@ fn es5_language_subset_runs() { handle.join().unwrap(); } +/// The counts of one run of a set of tests through the interpreter. +#[derive(Default)] +struct RunCounts { + tests: usize, + skipped: usize, + passed: usize, + failed: usize, + panicked: usize, + failures: String, +} + +impl RunCounts { + fn summary(&self) -> String { + format!( + "{} tests that need only the built-ins present, {} runs passed, {} failed ({} panicked), {} skipped", + self.tests, self.passed, self.failed, self.panicked, self.skipped + ) + } +} + fn run_language_subset(dir: &Path) { let language = dir.join("test").join("language"); + let missing = missing_builtins(&[TEXT_BUILTINS]); + let (es5, _) = run_tests(dir, &language, &missing, false); + let report = dir.join("run-failures.txt"); + if let Err(e) = std::fs::write(&report, &es5.failures) { + println!("could not write {}: {e}", report.display()); + } + println!( + "test262 ES5 language subset through the interpreter: {} tests that need only the built-ins present, {} runs passed, \ + {} failed ({} panicked). Skipped {} tests that need other built-ins, use syntax after ES5 or are asynchronous. Failures: {}", + es5.tests, + es5.passed, + es5.failed, + es5.panicked, + es5.skipped, + report.display() + ); + assert_eq!(es5.panicked, 0, "the interpreter panicked; see {}", report.display()); +} + +#[test] +fn es5_text_builtins_run() { + let Some(dir) = suite_dir() else { + println!("test262 built-ins: SKIPPED (no suite; run cargo xtask test262)"); + return; + }; + let handle = std::thread::Builder::new().stack_size(64 << 20).spawn(move || run_text_builtins(&dir)).unwrap(); + handle.join().unwrap(); +} + +/// Runs the tests under `test/built-ins/` for `String`, `Number`, `RegExp` and `Date`: those that +/// name an ES5 section and the other tests without a feature tag, counted apart. +fn run_text_builtins(dir: &Path) { + let mut report = String::new(); + let mut panicked = 0; + for name in ["String", "Number", "RegExp", "Date"] { + let root = dir.join("test").join("built-ins").join(name); + assert!(root.is_dir(), "{} is not a test262 checkout", dir.display()); + let missing = + if name == "String" { missing_builtins(&[TEXT_BUILTINS, STRING_METHODS_SHARED_WITH_ARRAY]) } else { missing_builtins(&[TEXT_BUILTINS]) }; + let (es5, later) = run_tests(dir, &root, &missing, true); + println!("test262 built-ins/{name}, ES5 tests: {}. Other tests without a feature tag: {}.", es5.summary(), later.summary()); + report.push_str(&format!("# built-ins/{name}, ES5 tests\n{}\n# built-ins/{name}, other tests\n{}\n", es5.failures, later.failures)); + panicked += es5.panicked + later.panicked; + } + let path = dir.join("builtins-failures.txt"); + if let Err(e) = std::fs::write(&path, &report) { + println!("could not write {}: {e}", path.display()); + } + println!("test262 built-ins failures: {}", path.display()); + assert_eq!(panicked, 0, "the interpreter panicked; see {}", path.display()); +} + +/// Runs the tests under `root` that have no feature tag and are not module, asynchronous or +/// negative parse tests, each in a new realm after the harness. Returns the counts for the tests +/// that name an ES5 section and, when `later` is set, for the others (otherwise those are left out). +fn run_tests(dir: &Path, root: &Path, missing: &HashSet<&str>, later: bool) -> (RunCounts, RunCounts) { let harness_dir = dir.join("harness"); // A file that cannot be read gives an empty source. let read = |p: &Path| String::from_utf8_lossy(&std::fs::read(p).unwrap_or_default()).into_owned(); - // `assert.js` formats a failed assertion's message with the global `String`, which the - // interpreter does not provide yet; the stand-in converts with `+` so a failure reports the - // assertion's message instead of a ReferenceError. - let prelude = format!("{HARNESS_STRING}\n{}\n{}\n", read(&harness_dir.join("assert.js")), read(&harness_dir.join("sta.js"))); - let missing: HashSet<&str> = MISSING_BUILTINS.split_whitespace().collect(); + let prelude = format!("{}\n{}\n", read(&harness_dir.join("assert.js")), read(&harness_dir.join("sta.js"))); let verbose = std::env::var_os("VECTORCRAFT_TEST262_VERBOSE").is_some(); - let (mut tests, mut skipped, mut passed, mut failed, mut panicked) = (0usize, 0usize, 0usize, 0usize, 0usize); - let mut failures = String::new(); - for path in test_files(&language) { + let (mut es5_counts, mut later_counts) = (RunCounts::default(), RunCounts::default()); + for path in test_files(root) { let source = read(&path); let meta = meta(&source); let negative_parse = matches!(meta.negative_phase.as_deref(), Some("parse" | "resolution")); - if !meta.es5 || !meta.features.is_empty() || negative_parse || meta.flags.iter().any(|f| f == "module") { + if (!meta.es5 && !later) || !meta.features.is_empty() || negative_parse || meta.flags.iter().any(|f| f == "module") { continue; } + let counts = if meta.es5 { &mut es5_counts } else { &mut later_counts }; let includes: String = meta.includes.iter().map(|i| read(&harness_dir.join(i)) + "\n").collect(); - if meta.flags.iter().any(|f| f == "async") || needs_missing_builtin(&source, &missing) || needs_missing_builtin(&includes, &missing) { - skipped += 1; + if meta.flags.iter().any(|f| f == "async") || needs_missing_builtin(&source, missing) || needs_missing_builtin(&includes, missing) { + counts.skipped += 1; continue; } - tests += 1; + counts.tests += 1; let has = |flag: &str| meta.flags.iter().any(|f| f == flag); let modes: &[bool] = if has("raw") || has("noStrict") { &[false] @@ -294,27 +383,18 @@ fn run_language_subset(dir: &Path) { let negative_type = meta.negative_type.clone().filter(|_| meta.negative_phase.as_deref() == Some("runtime")); let outcome = std::panic::catch_unwind(|| run_one(&script, negative_type.as_deref())); match outcome { - Ok(Ok(())) => passed += 1, + Ok(Ok(())) => counts.passed += 1, Ok(Err(why)) => { - failed += 1; - failures.push_str(&format!("{rel} ({mode}): {why}\n")); + counts.failed += 1; + counts.failures.push_str(&format!("{rel} ({mode}): {why}\n")); } Err(_) => { - failed += 1; - panicked += 1; - failures.push_str(&format!("{rel} ({mode}): PANIC\n")); + counts.failed += 1; + counts.panicked += 1; + counts.failures.push_str(&format!("{rel} ({mode}): PANIC\n")); } } } } - let report = dir.join("run-failures.txt"); - if let Err(e) = std::fs::write(&report, &failures) { - println!("could not write {}: {e}", report.display()); - } - println!( - "test262 ES5 language subset through the interpreter: {tests} tests that need only the built-ins present, {passed} runs passed, \ - {failed} failed ({panicked} panicked). Skipped {skipped} tests that need other built-ins, use syntax after ES5 or are asynchronous. Failures: {}", - report.display() - ); - assert_eq!(panicked, 0, "the interpreter panicked; see {}", report.display()); + (es5_counts, later_counts) } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 27e8b3d79..6f64db618 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -655,6 +655,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). Nothing runs scripts yet. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap. Built-ins are limited to `Object`, `Function` with `call` and `apply`, `eval` and the error types. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only these built-ins: 3,074 of 3,078 runs pass (the 4 others expect the completion values of later editions). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. + - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential`, `toPrecision` and `toString(radix)` format a number from its exact decimal value. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher that keeps its choice points on the heap and takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,113 of 1,169 pass for `String`, 262 of 262 for `Number`, 878 of 908 for `RegExp` and 6 of 6 for `Date`. The others need `Array` and `Boolean` methods, or expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object). The ES5 language tests that can now run give 3,742 of 3,746 passing runs. Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index bfd6172ac..0324964c5 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -44,7 +44,8 @@ commands: stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) test262 fetch the pinned ECMAScript conformance suite into corpus/test262 (sha256 verified), parse its ES5 subset with vectorcraft-js and run the ES5 language - tests its interpreter supports, printing the counts + tests its interpreter supports and the String, Number, RegExp and Date + built-ins tests, printing the counts web-fonts copy the craft-fonts faces in crates/text/web-fonts.txt into the web site (the Trunk post_build hook; a no-op without CRAFT_FONTS_DIR) "; diff --git a/xtask/src/test262.rs b/xtask/src/test262.rs index 30f6f2472..88cbef395 100644 --- a/xtask/src/test262.rs +++ b/xtask/src/test262.rs @@ -2,7 +2,8 @@ //! BSD-3-Clause) at a pinned commit, fetched as GitHub's archive of that commit, checked against //! its SHA-256 and size, and unpacked into `corpus/test262/` (gitignored, never committed). It then //! runs `vectorcraft-js`'s `test262` tests, which parse the ES5 parse subset, run the ES5 -//! `test/language` tests that need only the interpreter's built-ins, and print each one's counts. +//! `test/language` tests that need only the interpreter's built-ins, run the `test/built-ins` tests +//! for `String`, `Number`, `RegExp` and `Date`, and print each one's counts. //! //! Moving the pin: change [`COMMIT`], run the command once with the new commit to see the //! archive's SHA-256 in the error, check the archive, and update [`SHA256`].