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
7 changes: 7 additions & 0 deletions crates/perry-codegen/src/expr/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,11 @@ pub(crate) fn is_global_this_builtin_name(name: &str) -> bool {
| "decodeURI"
| "encodeURIComponent"
| "decodeURIComponent"
// #4511: legacy escape/unescape (ES Annex B).
| "escape"
| "unescape"
// #4511: Node's `global` alias (typeof === "object").
| "global"
// Test262 installs this dynamically on globalThis. Route reads
// through the singleton so bare `print(...)` can call it once the
// harness assigns the property.
Expand Down Expand Up @@ -403,6 +408,8 @@ pub(crate) fn is_global_this_builtin_function_name(name: &str) -> bool {
| "crypto"
| "localStorage"
| "sessionStorage"
// #4511: `typeof global === "object"`.
| "global"
)
}

Expand Down
9 changes: 9 additions & 0 deletions crates/perry-hir/src/analysis/builtins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,15 @@ pub(crate) fn is_builtin_global_value_name(name: &str) -> bool {
| "decodeURI"
| "encodeURIComponent"
| "decodeURIComponent"
// #4511: legacy escape/unescape (ES Annex B). Bare calls and value
// reads both resolve through `globalThis.<name>` → the runtime
// thunk (no dedicated HIR variant). Used by `qs` (→ `stripe`).
| "escape"
| "unescape"
// #4511: Node's `global` alias for the global object. Resolves to
// `globalThis.global`, a self-reference installed in
// `populate_global_this_builtins`.
| "global"
)
}

Expand Down
84 changes: 84 additions & 0 deletions crates/perry-runtime/src/builtins/globals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,90 @@ pub extern "C" fn js_decode_uri_component(value: f64) -> i64 {
ptr as i64
}

// ============================================================
// escape / unescape (ECMAScript Annex B B.2.1)
// ============================================================

/// Characters `escape()` leaves unencoded (ES Annex B B.2.1.1): ASCII
/// letters, digits, and `@ * _ + - . /`. Unlike `encodeURIComponent`, the
/// escape set keeps `+` and `@` and encodes everything else — code units
/// < 256 as `%XX`, the rest as `%uXXXX`.
const ESCAPE_UNESCAPED: &[u8] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@*_+-./";

/// escape(string) -> string (legacy, ES Annex B B.2.1.1)
#[no_mangle]
pub extern "C" fn js_escape(value: f64) -> i64 {
let input = extract_str_from_nanbox(value);
let mut result = String::with_capacity(input.len() * 3);
let mut buf = [0u16; 2];
for c in input.chars() {
let cp = c as u32;
if cp < 0x80 && ESCAPE_UNESCAPED.contains(&(cp as u8)) {
result.push(c);
} else {
// Encode per UTF-16 code unit so astral code points emit the two
// `%uXXXX` escapes for their surrogate pair, matching Node.
for unit in c.encode_utf16(&mut buf) {
if *unit < 0x100 {
result.push_str(&format!("%{:02X}", *unit));
} else {
result.push_str(&format!("%u{:04X}", *unit));
}
}
}
}
let ptr = js_string_from_bytes(result.as_ptr(), result.len() as u32);
ptr as i64
}

/// unescape(string) -> string (legacy, ES Annex B B.2.1.2)
#[no_mangle]
pub extern "C" fn js_unescape(value: f64) -> i64 {
let input = extract_str_from_nanbox(value);
let chars: Vec<char> = input.chars().collect();
// Reassemble into UTF-16 code units, then decode, so `%uD835%uDFD8`-style
// surrogate pairs recombine into a single astral scalar.
let mut units: Vec<u16> = Vec::with_capacity(chars.len());
let mut i = 0;
while i < chars.len() {
if chars[i] == '%' {
// %uXXXX
if i + 5 < chars.len() && chars[i + 1] == 'u' {
if let Some(u) = hex4_chars(&chars[i + 2..i + 6]) {
units.push(u);
i += 6;
continue;
}
}
// %XX
if i + 2 < chars.len() {
if let (Some(h), Some(l)) = (chars[i + 1].to_digit(16), chars[i + 2].to_digit(16)) {
units.push((h * 16 + l) as u16);
i += 3;
continue;
}
}
}
let mut buf = [0u16; 2];
for unit in chars[i].encode_utf16(&mut buf) {
units.push(*unit);
}
i += 1;
}
let decoded = String::from_utf16_lossy(&units);
let ptr = js_string_from_bytes(decoded.as_ptr(), decoded.len() as u32);
ptr as i64
}

fn hex4_chars(cs: &[char]) -> Option<u16> {
let mut v: u16 = 0;
for &c in cs {
v = v.checked_mul(16)?.checked_add(c.to_digit(16)? as u16)?;
}
Some(v)
}

// ============================================================
// structuredClone
// ============================================================
Expand Down
6 changes: 3 additions & 3 deletions crates/perry-runtime/src/builtins/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,9 @@ pub(crate) use formatting::{

pub use globals::{
js_decode_uri, js_decode_uri_component, js_drain_queued_microtasks, js_encode_uri,
js_encode_uri_component, js_queue_microtask, js_queue_next_tick, js_queue_next_tick_args,
js_structured_clone, js_text_decoder_decode, js_text_encoder_encode,
restore_queued_microtask_contexts, scan_queued_microtask_roots,
js_encode_uri_component, js_escape, js_queue_microtask, js_queue_next_tick,
js_queue_next_tick_args, js_structured_clone, js_text_decoder_decode, js_text_encoder_encode,
js_unescape, restore_queued_microtask_contexts, scan_queued_microtask_roots,
scan_queued_microtask_roots_mut,
};

Expand Down
30 changes: 30 additions & 0 deletions crates/perry-runtime/src/object/global_this.rs
Original file line number Diff line number Diff line change
Expand Up @@ -866,6 +866,23 @@ extern "C" fn global_this_decode_uri_component_thunk(
crate::value::js_nanbox_string(crate::builtins::js_decode_uri_component(value))
}

// #4511: legacy `escape()` / `unescape()` (ES Annex B). Used in the wild by
// `qs` for `%uXXXX` decoding, so any app pulling in `qs` (e.g. via `stripe`)
// needs them as real callable globalThis function values.
extern "C" fn global_this_escape_thunk(
_closure: *const crate::closure::ClosureHeader,
value: f64,
) -> f64 {
crate::value::js_nanbox_string(crate::builtins::js_escape(value))
}

extern "C" fn global_this_unescape_thunk(
_closure: *const crate::closure::ClosureHeader,
value: f64,
) -> f64 {
crate::value::js_nanbox_string(crate::builtins::js_unescape(value))
}

// #2889: call-form thunks for `Number`/`Boolean` global constructor values.
// `Object`/`String` already have dedicated thunks above; these mirror the
// bare-call HIR lowering (`Expr::NumberCoerce` / `Expr::BooleanCoerce`) so
Expand Down Expand Up @@ -2266,6 +2283,16 @@ pub(crate) fn populate_global_this_builtins(singleton: *mut ObjectHeader) {
let value = crate::value::js_nanbox_pointer(singleton as i64);
js_object_set_field_by_name(singleton, key, value);
}
{
// #4511: Node exposes the global object as `global` too
// (`global === globalThis`). Install the same self-reference so bare
// `global` / `(global as any).x` reads resolve to the real singleton
// instead of the unknown-identifier sentinel.
let name = b"global";
let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32);
let value = crate::value::js_nanbox_pointer(singleton as i64);
js_object_set_field_by_name(singleton, key, value);
}
// #2145: pre-allocate the shared `%TypedArray%` intrinsic so per-kind
// typed-array constructors can link their `__proto__` to it as they're
// built below, and the per-kind `.prototype` objects can be flagged with
Expand Down Expand Up @@ -2593,6 +2620,9 @@ pub(crate) fn populate_global_this_builtins(singleton: *mut ObjectHeader) {
1,
false,
),
// #4511: legacy escape/unescape (ES Annex B).
"escape" => (global_this_escape_thunk as *const u8, 1, false),
"unescape" => (global_this_unescape_thunk as *const u8, 1, false),
_ => continue,
};
let closure_ptr = crate::closure::js_closure_alloc(func_ptr, 0);
Expand Down
3 changes: 3 additions & 0 deletions crates/perry-runtime/src/object/global_this_tables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@ pub(crate) const GLOBAL_THIS_BUILTIN_FUNCTIONS: &[&str] = &[
"decodeURI",
"encodeURIComponent",
"decodeURIComponent",
// #4511: legacy escape/unescape (ES Annex B), used by `qs`.
"escape",
"unescape",
];

pub(crate) fn is_web_fetch_constructor(name: &str) -> bool {
Expand Down
27 changes: 27 additions & 0 deletions test-files/test_gap_escape_unescape_global_4511.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// #4511 — legacy escape()/unescape() (ES Annex B) + Node `global` alias

// Bare calls
console.log(escape("a b+c"), unescape("a%20b"));
// Non-ASCII: code units < 256 -> %XX, BMP -> %uXXXX, astral -> surrogate pair
console.log(escape("café ☃ 𝟘"));
console.log(unescape("caf%E9 %u2603"));

// typeof — both are functions
console.log(typeof escape, typeof unescape);
console.log(typeof globalThis.escape, typeof globalThis.unescape);

// Rebound as values
const e = escape;
const u = unescape;
console.log(e("100%"), u("%41%42"));

// Through globalThis explicitly
console.log(globalThis.escape("@*_+-./"), globalThis.unescape("%7E"));

// Malformed / partial escapes pass through unchanged
console.log(unescape("%"), unescape("%2"), unescape("%zz"), unescape("100%"));

// Node `global` object: alias for globalThis, typeof "object"
console.log(typeof global, global === globalThis);
(global as any).__perry_4511 = 7;
console.log((global as any).__perry_4511, (globalThis as any).__perry_4511);
Loading