Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/perry-codegen/src/expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1629,6 +1629,7 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
| Expr::RegExpExecGroups => misc_methods::lower(ctx, expr),
Expr::SetClear(..)
| Expr::StringFromCodePoint(..)
| Expr::StringRaw { .. }
| Expr::StringAt { .. }
| Expr::StringCodePointAt { .. }
| Expr::RegExpSource(..)
Expand Down
15 changes: 15 additions & 0 deletions crates/perry-codegen/src/expr/string_regex_proc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,21 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
let handle = blk.call(I64, "js_string_from_code_point", &[(DOUBLE, &v)]);
Ok(nanbox_string_inline(blk, &handle))
}
// -------- Callable String.raw(callSite, ...substitutions) (#2789) --------
Expr::StringRaw {
call_site,
substitutions,
} => {
// callSite as a NaN-boxed value; substitutions collected into a
// NaN-boxed array. The runtime reads `callSite.raw` (array-like),
// interleaves the substitutions, and throws TypeError on nullish
// callSite / raw.
let cs = lower_expr(ctx, call_site)?;
let subs_arr = lower_array_literal(ctx, substitutions)?;
let blk = ctx.block();
let handle = blk.call(I64, "js_string_raw", &[(DOUBLE, &cs), (DOUBLE, &subs_arr)]);
Ok(nanbox_string_inline(blk, &handle))
}
// -------- str.at(i) — returns single-char string or undefined --------
Expr::StringAt { string, index } => {
let s_box = lower_expr(ctx, string)?;
Expand Down
15 changes: 7 additions & 8 deletions crates/perry-codegen/src/lower_string_method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -469,27 +469,26 @@ pub(crate) fn lower_string_method(
Ok(nanbox_string_inline(blk, &result))
}
"normalize" => {
// 0 or 1 string arg. Empty arg → default ("NFC" handled by
// the runtime when form is null).
// 0 or 1 arg. The runtime applies ToString + form validation:
// omitted (undefined) → NFC default; explicit null/""/"BAD" →
// RangeError. Pass the raw NaN-boxed form value (#2782).
if args.len() > 1 {
bail!(
"perry-codegen: String.normalize expects 0 or 1 args, got {}",
args.len()
);
}
let form_handle = if args.is_empty() {
"0".to_string()
let form_box = if args.is_empty() {
crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))
} else {
let form_box = lower_expr(ctx, &args[0])?;
let blk = ctx.block();
unbox_str_handle(blk, &form_box)
lower_expr(ctx, &args[0])?
};
let blk = ctx.block();
let recv_handle = unbox_str_handle(blk, &recv_box);
let result = blk.call(
I64,
"js_string_normalize",
&[(I64, &recv_handle), (I64, &form_handle)],
&[(I64, &recv_handle), (DOUBLE, &form_box)],
);
Ok(nanbox_string_inline(blk, &result))
}
Expand Down
4 changes: 3 additions & 1 deletion crates/perry-codegen/src/runtime_decls/strings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1225,6 +1225,8 @@ pub fn declare_phase_b_strings(module: &mut LlModule) {
// (fromCharCode) / RangeError validation (fromCodePoint) — a prior fptosi
// truncated fractional/non-finite inputs before they could be observed.
module.declare_function("js_string_from_code_point", I64, &[DOUBLE]);
// Callable String.raw(callSite, substitutionsArray) -> string (#2789)
module.declare_function("js_string_raw", I64, &[DOUBLE, DOUBLE]);
module.declare_function("js_string_from_char_code", I64, &[DOUBLE]);
module.declare_function("js_string_char_code_at", DOUBLE, &[I64, I32]);
module.declare_function("js_string_last_index_of", I32, &[I64, I64]);
Expand All @@ -1235,7 +1237,7 @@ pub fn declare_phase_b_strings(module: &mut LlModule) {
);
module.declare_function("js_string_locale_compare", DOUBLE, &[I64, I64]);
module.declare_function("js_string_locale_compare_opts", DOUBLE, &[I64, I64, DOUBLE]);
module.declare_function("js_string_normalize", I64, &[I64, I64]);
module.declare_function("js_string_normalize", I64, &[I64, DOUBLE]);
module.declare_function("js_string_pad_start", I64, &[I64, DOUBLE, I64]);
module.declare_function("js_string_pad_end", I64, &[I64, DOUBLE, I64]);
module.declare_function("js_string_is_well_formed", DOUBLE, &[I64]);
Expand Down
3 changes: 3 additions & 0 deletions crates/perry-codegen/src/type_analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ pub(crate) fn refine_type_from_init(ctx: &FnCtx<'_>, init: &Expr) -> Option<HirT
| Expr::StringCoerce(_)
| Expr::StringFromCodePoint(_)
| Expr::StringFromCharCode(_)
| Expr::StringRaw { .. }
| Expr::StringAt { .. }
| Expr::RegExpSource(_)
| Expr::RegExpFlags(_)
Expand Down Expand Up @@ -958,6 +959,7 @@ pub(crate) fn is_definitely_string_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool {
| Expr::JsonStringifyFull(..)
| Expr::StringFromCodePoint(_)
| Expr::StringFromCharCode(_)
| Expr::StringRaw { .. }
| Expr::FsReadFileSync(_)
| Expr::FsReadFileBinary(_)
| Expr::PathSep
Expand Down Expand Up @@ -1166,6 +1168,7 @@ pub(crate) fn is_string_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool {
// / RegExp.source|flags — all produce string handles.
Expr::StringFromCodePoint(_)
| Expr::StringFromCharCode(_)
| Expr::StringRaw { .. }
| Expr::StringAt { .. }
| Expr::RegExpSource(_)
| Expr::RegExpFlags(_)
Expand Down
7 changes: 7 additions & 0 deletions crates/perry-hir/src/ir/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1349,6 +1349,13 @@ pub enum Expr {
StringSplit(Box<Expr>, Box<Expr>), // string.split(delimiter) -> string[]
StringFromCharCode(Box<Expr>), // String.fromCharCode(code) -> single-char string
StringFromCodePoint(Box<Expr>), // String.fromCodePoint(code) -> string
StringRaw {
// Callable String.raw(callSite, ...substitutions) — the non-tagged
// form. `call_site` is the `{ raw: [...] }` (array-like) object;
// `substitutions` are the interpolated values. (#2789)
call_site: Box<Expr>,
substitutions: Vec<Expr>,
},
StringAt {
string: Box<Expr>,
index: Box<Expr>,
Expand Down
13 changes: 13 additions & 0 deletions crates/perry-hir/src/lower/expr_call/module_static.rs
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,19 @@ pub(super) fn try_module_static_methods(
return Ok(Ok(acc));
}
}
// Callable String.raw(callSite, ...subs) — the
// non-tagged form. The tagged ``String.raw`...` ``
// form is handled at the TaggedTpl lowering site.
// (#2789)
"raw" => {
let mut iter = args.into_iter();
let call_site = iter.next().unwrap_or(Expr::Undefined);
let substitutions: Vec<Expr> = iter.collect();
return Ok(Ok(Expr::StringRaw {
call_site: Box::new(call_site),
substitutions,
}));
}
_ => {} // Fall through to generic handling
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/perry-hir/src/stable_hash/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@ impl SH for Expr {
Expr::StringSplit(a, b) => { tag(h, 282); a.as_ref().hash(h); b.as_ref().hash(h); }
Expr::StringFromCharCode(e) => { tag(h, 283); e.as_ref().hash(h); }
Expr::StringFromCodePoint(e) => { tag(h, 284); e.as_ref().hash(h); }
Expr::StringRaw { call_site, substitutions } => { tag(h, 12043); call_site.as_ref().hash(h); substitutions.hash(h); }
Expr::StringAt { string, index } => { tag(h, 285); string.as_ref().hash(h); index.as_ref().hash(h); }
Expr::StringCodePointAt { string, index } => { tag(h, 286); string.as_ref().hash(h); index.as_ref().hash(h); }
Expr::MapNew => tag(h, 287),
Expand Down
9 changes: 9 additions & 0 deletions crates/perry-hir/src/walker/expr_mut.rs
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,15 @@ where
Expr::StringFromCharCode(v) | Expr::StringFromCodePoint(v) => {
f(v);
}
Expr::StringRaw {
call_site,
substitutions,
} => {
f(call_site);
for s in substitutions {
f(s);
}
}
Expr::StringAt { string, index } | Expr::StringCodePointAt { string, index } => {
f(string);
f(index);
Expand Down
9 changes: 9 additions & 0 deletions crates/perry-hir/src/walker/expr_ref.rs
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,15 @@ where
Expr::StringFromCharCode(v) | Expr::StringFromCodePoint(v) => {
f(v);
}
Expr::StringRaw {
call_site,
substitutions,
} => {
f(call_site);
for s in substitutions {
f(s);
}
}
Expr::StringAt { string, index } | Expr::StringCodePointAt { string, index } => {
f(string);
f(index);
Expand Down
38 changes: 31 additions & 7 deletions crates/perry-runtime/src/string/compare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -381,33 +381,57 @@ pub extern "C" fn js_string_ends_with_at(
}

/// String.prototype.normalize(form) — Unicode normalization.
/// `form` is one of: NFC (default), NFD, NFKC, NFKD. Pass null/empty for default NFC.
///
/// `form_value` is the raw NaN-boxed argument (or NaN-boxed `undefined`
/// when the call site omitted it). Per ECMA-262 §22.1.3.13: when `form` is
/// `undefined` the form defaults to `"NFC"`; otherwise the form is coerced
/// with `ToString` and must be exactly one of `"NFC"`, `"NFD"`, `"NFKC"`,
/// `"NFKD"` — anything else (including explicit `null` → `"null"`, the empty
/// string, or `"BAD"`) throws a `RangeError`. (#2782)
#[no_mangle]
pub extern "C" fn js_string_normalize(
s: *const StringHeader,
form: *const StringHeader,
form_value: f64,
) -> *mut StringHeader {
if !is_valid_string_ptr(s) {
return js_string_from_bytes(std::ptr::null(), 0);
}
let str_data = string_as_str(s);
let form_str = if is_valid_string_ptr(form) {
string_as_str(form)

// `undefined` (omitted argument) → default NFC. Note: explicit `null`
// is NOT undefined — it stringifies to "null" and falls through to the
// invalid-form error path below.
let form_jsval = crate::value::JSValue::from_bits(form_value.to_bits());
let form_owned: String = if form_jsval.is_undefined() {
"NFC".to_string()
} else {
"NFC"
let form_ptr = crate::value::js_jsvalue_to_string(form_value);
if is_valid_string_ptr(form_ptr) {
string_as_str(form_ptr).to_string()
} else {
String::new()
}
};

use unicode_normalization::UnicodeNormalization;
let normalized: String = match form_str {
let normalized: String = match form_owned.as_str() {
"NFC" => str_data.nfc().collect(),
"NFD" => str_data.nfd().collect(),
"NFKC" => str_data.nfkc().collect(),
"NFKD" => str_data.nfkd().collect(),
_ => str_data.nfc().collect(),
_ => throw_invalid_normalize_form(),
};
let bytes = normalized.as_bytes();
js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32)
}

fn throw_invalid_normalize_form() -> ! {
let message = "The normalization form should be one of NFC, NFD, NFKC, NFKD.";
let msg = js_string_from_bytes(message.as_ptr(), message.len() as u32);
let err = crate::error::js_rangeerror_new(msg);
crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64))
}

/// String.prototype.localeCompare(other) — returns negative/zero/positive number.
/// We don't ship a true ICU collator. We approximate the Unicode default
/// collation with a two-pass comparison: first case-insensitive (so the
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-runtime/src/string/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ mod format;
mod intern;
mod io;
mod pad;
mod raw;
mod slice_ops;
mod split;

Expand Down Expand Up @@ -69,6 +70,7 @@ pub use format::{
pub use intern::{js_string_intern, scan_intern_table_roots, scan_intern_table_roots_mut};
pub use io::{js_string_error, js_string_print, js_string_warn};
pub use pad::{js_string_alloc_space, js_string_pad_end, js_string_pad_start, js_string_repeat};
pub use raw::js_string_raw;
pub use slice_ops::{
js_string_index_of, js_string_index_of_from, js_string_last_index_of,
js_string_last_index_of_from, js_string_slice, js_string_substring, js_string_to_lower_case,
Expand Down
95 changes: 80 additions & 15 deletions crates/perry-runtime/src/string/pad.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,46 @@ pub extern "C" fn js_string_alloc_space() -> *mut StringHeader {
js_string_from_bytes(" ".as_ptr(), 1)
}

/// ToLength coercion (ECMA-262 §7.1.21): NaN/negative → 0, +Infinity →
/// `2^53 - 1` (capped here at a sane runtime maximum so callers like
/// `padStart` can't allocate gigabytes from a single bad input). Used by
/// `js_string_pad_start` / `_pad_end` where the codegen passes the raw
/// `f64` length argument. Pre-fix the codegen `fptosi(NaN)`-then-`u32`-
/// cast path produced `0xFFFFFFFF` from a literal `-1` and filled 4 GiB
/// of padding before OOM; literal `NaN` similarly miscompiled via
/// LLVM's undefined `fptosi(NaN)`.
fn to_length_clamped(target_length: f64) -> usize {
const MAX_PAD_LEN: usize = 1 << 20; // 1 MiB cap — saner than the spec's 2^53-1.
/// Maximum string length Perry/V8 supports as a single `String`. This
/// mirrors the value Node v25 reports via `buffer.constants.MAX_STRING_LENGTH`
/// (536_870_888 = `(1 << 29) - 24` on this V8 build). `padStart`/`padEnd`
/// throw `RangeError: Invalid string length` when the requested length
/// exceeds this, instead of silently capping. (#2786 / #2880)
const MAX_STRING_LENGTH: usize = 536_870_888;

/// ToLength coercion (ECMA-262 §7.1.21) for `padStart`/`padEnd`'s target
/// length: NaN/negative → 0, fractional values truncate, `+Infinity` →
/// `2^53 - 1`. Per the spec's `StringPad`, ToLength itself never throws —
/// the `RangeError: Invalid string length` is raised later (at allocation
/// time) only when a result string longer than `MAX_STRING_LENGTH` would
/// actually be produced. That means `"x".padStart(Infinity, "")` (empty
/// filler) and `"hi".padStart(Infinity)` (already long enough) return the
/// receiver unchanged, while `"x".padStart(Infinity, "0")` throws. See
/// `js_string_pad_start` / `_pad_end` for the deferred-throw call order.
///
/// The NaN/negative → 0 branch also preserves the pre-#2786 protection
/// against the codegen `fptosi(NaN)`-then-`u32`-cast path that produced
/// `0xFFFFFFFF` from a literal `-1` / `NaN`.
fn to_length(target_length: f64) -> usize {
if target_length.is_nan() || target_length <= 0.0 {
0
} else if target_length >= MAX_PAD_LEN as f64 {
MAX_PAD_LEN
} else if target_length.is_infinite() {
// 2^53 - 1, the spec ToLength maximum. Stored as usize so the
// later `> MAX_STRING_LENGTH` allocation guard fires.
(1u64 << 53).wrapping_sub(1) as usize
} else {
target_length as usize
// ToLength truncates the fractional part (e.g. 5.9 → 5).
target_length.trunc() as usize
}
}

fn throw_invalid_string_length() -> ! {
let message = "Invalid string length";
let msg = js_string_from_bytes(message.as_ptr(), message.len() as u32);
let err = crate::error::js_rangeerror_new(msg);
crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64))
}

/// Pad the start of a string to reach target length (in UTF-16 code units).
/// str.padStart(targetLength, padString)
#[no_mangle]
Expand All @@ -47,12 +68,21 @@ pub extern "C" fn js_string_pad_start(
};

let current_len = unsafe { (*s).utf16_len } as usize;
let target_len = to_length_clamped(target_length);
let target_len = to_length(target_length);

// ToLength itself never throws; the receiver is returned unchanged when
// it's already long enough or the filler is empty — even for an
// unrepresentable target like Infinity (Node parity, #2786/#2880).
if current_len >= target_len || pad_data.is_empty() {
return js_string_from_bytes(str_data.as_ptr(), str_data.len() as u32);
}

// Only now, when a longer string must actually be produced, reject
// lengths beyond the engine's max string length with a RangeError.
if target_len > MAX_STRING_LENGTH {
throw_invalid_string_length();
}

let pad_needed = target_len - current_len;
let _pad_u16: Vec<u16> = pad_data.encode_utf16().collect();
let mut result = String::with_capacity(target_len * 4);
Expand Down Expand Up @@ -98,12 +128,21 @@ pub extern "C" fn js_string_pad_end(
};

let current_len = unsafe { (*s).utf16_len } as usize;
let target_len = to_length_clamped(target_length);
let target_len = to_length(target_length);

// ToLength itself never throws; the receiver is returned unchanged when
// it's already long enough or the filler is empty — even for an
// unrepresentable target like Infinity (Node parity, #2786/#2880).
if current_len >= target_len || pad_data.is_empty() {
return js_string_from_bytes(str_data.as_ptr(), str_data.len() as u32);
}

// Only now, when a longer string must actually be produced, reject
// lengths beyond the engine's max string length with a RangeError.
if target_len > MAX_STRING_LENGTH {
throw_invalid_string_length();
}

let pad_needed = target_len - current_len;
let mut result = String::with_capacity(target_len * 4);

Expand Down Expand Up @@ -181,3 +220,29 @@ fn throw_repeat_range_error(count: f64) -> ! {
let err = crate::error::js_rangeerror_new(msg);
crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64))
}

#[cfg(test)]
mod pad_length_tests {
use super::{to_length, MAX_STRING_LENGTH};

/// #2786/#2880: ToLength for pad targets — NaN/negative → 0, fractional
/// truncates, +Infinity maps to the spec maximum (2^53 - 1) which the
/// caller then rejects at allocation time.
#[test]
fn to_length_matches_node_coercion() {
assert_eq!(to_length(0.0), 0);
assert_eq!(to_length(-1.0), 0);
assert_eq!(to_length(f64::NAN), 0);
assert_eq!(to_length(5.0), 5);
assert_eq!(to_length(5.9), 5); // truncates, not rounds
assert_eq!(to_length(1_048_577.0), 1_048_577);
// +Infinity → the ToLength maximum, which exceeds MAX_STRING_LENGTH
// so the pad helpers raise RangeError when a longer string is needed.
assert_eq!(to_length(f64::INFINITY), (1u64 << 53) as usize - 1);
assert!(to_length(f64::INFINITY) > MAX_STRING_LENGTH);
// MAX is representable; MAX+1 exceeds the engine limit.
assert_eq!(to_length(MAX_STRING_LENGTH as f64), MAX_STRING_LENGTH);
assert!(to_length((MAX_STRING_LENGTH + 1) as f64) > MAX_STRING_LENGTH);
assert!(to_length(4_294_967_296.0) > MAX_STRING_LENGTH); // 2^32
}
}
Loading