diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b5afa7a0f..a515115763 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,39 @@ Detailed changelog for Perry. See CLAUDE.md for concise summaries. +## v0.5.984 — feat(runtime): `.constructor` on Date / Array / Object instances + `new (...)` dispatch + +**Symptom.** date-fns 4.x throws `RangeError: Invalid time value` on the very first call to `format(new Date(2024, 0, 15), 'yyyy-MM-dd')`. Root cause is `constructFrom(date, value)` — the helper every other date-fns export funnels through: + +```js +if (date instanceof Date) return new date.constructor(value); +``` + +Perry returned `undefined` from `date.constructor` (Date stores as a raw f64 timestamp, not an ObjectHeader, so the codegen receiver-tag guard at `pget.recv_bad` rejected it as non-pointer and short-circuited to `TAG_UNDEFINED` without ever calling the runtime). The downstream `new undefined(value)` then constructed an empty `ObjectHeader` placeholder; `cloned.getTime()` read garbage; the formatter threw. + +Same defect affected every duck-type test of the shape `inst.constructor === Date` / `arr.constructor === Array` / `obj.constructor === Object` (drizzle's `is(value, ctor)`, lodash-style `value.constructor === Array` checks, ramda’s `R.type` fast paths). + +**Fix.** Three coordinated changes so both `inst.constructor` reads and bare `Date` / `Array` / `Object` identifiers resolve to the same closure-pointer value, and so `new (...)` dispatches into the real factory. + +1. `crates/perry-hir/src/lower.rs` — bare built-in identifiers (`Date`, `Array`, `Object`, `Map`, `Set`, `Error`, …) now lower to `Expr::PropertyGet { GlobalGet(0), }` instead of the bare `GlobalGet(0)` sentinel. Routes through the existing codegen `is_global_this_builtin_name` arm which materializes the singleton closure via `js_get_global_this` + `js_object_get_field_by_name_f64`. Added a parallel `is_builtin_global_value_name` helper in `crates/perry-hir/src/analysis.rs` that mirrors the codegen / runtime built-in lists. `lower_types.rs::lower_decorator_arg` extended to also recognise the new `PropertyGet { GlobalGet, name }` shape as a class ref for `@Decorator(Date)`-style use. Callable surfaces (`Date()`, `new Date(...)`, `Date.now()`, `Math.PI`) are intercepted by their dedicated HIR variants before this arm fires, so the change doesn’t disturb those. + +2. `crates/perry-runtime/src/object.rs::js_object_get_field_by_name` — new `"constructor"` short-circuit arms: + - `GC_TYPE_ARRAY` and `GC_TYPE_LAZY_ARRAY` → return `globalThis.Array` closure (was: returned `undefined`). + - Generic object path: `class_id == 0` → return `globalThis.Object`; `is_anon_shape_class_id(class_id)` → also `globalThis.Object` so `({ x: 1 }).constructor === Object` holds. + - Plus a new entry-level arm in `js_object_get_field_by_name_f64` that recognises raw-f64 Date receivers via `is_registered_date_bits` and returns `globalThis.Date` — needed because the codegen receiver-tag check filters Date out of the dispatch path otherwise. + - The codegen invalid-receiver fall-through (`pget.recv_bad`’s non-nullish branch) now routes through `js_object_get_field_by_name_f64` instead of emitting `TAG_UNDEFINED` directly, so the Date arm above actually fires for raw-f64 receivers. + +3. `crates/perry-runtime/src/object.rs::js_new_function_construct` — new `identify_global_builtin_constructor` short-circuit. Walks the singleton’s built-in entries, matches the receiver’s `ClosureHeader.func_ptr` against `global_this_builtin_noop_thunk` (GC-stable identifier — the singleton closure header itself gets evacuated and pointer-rewritten, but the embedded func ptr is read-only), and dispatches to the real factory: + - `Date` → `js_date_new` / `js_date_new_from_value` / `js_date_new_local_components` + - `Array` → `js_array_alloc` (single-finite-arg → empty array of that length; otherwise positional fill) + - `Object` → `js_object_alloc(0, 0)` + + Plus a parallel codegen change in `crates/perry-codegen/src/expr.rs::NewDynamic`: callees of shape `PropertyGet { … }` (the date-fns `new date.constructor(value)` shape) now also route through `js_new_function_construct`. A statically-typed Date receiver short-circuits earlier to `Expr::DateNew(args)` so the hot path stays inline. + +4. `crates/perry-codegen/src/codegen.rs` — emit `js_register_anon_shape_class_id` at module init for every `__AnonShape_` class so the new constructor arm above can distinguish object-literal instances from user classes. The runtime exposes a matching `js_register_anon_shape_class_id` FFI declared in `crates/perry-codegen/src/runtime_decls.rs`, alongside the new `js_get_global_this_builtin_value` helper. + +**Validation.** `test-files/test_constructor_property.ts` covers Date / Array / Object `.constructor` reads, identity-equality with the bare global, and `new (ts)` cloning — all 7 assertions match Node byte-for-byte. date-fns `format(new Date(...), 'yyyy-MM-dd')` (under `compilePackages: ["date-fns"]`) no longer trips `RangeError: Invalid time value`; the next blocker is a `Cannot read properties of undefined (reading 'map')` inside date-fns’s `normalizeDates` chain (downstream of `constructFrom` — separate gap). + ## v0.5.983 — feat(runtime): `Function.prototype.apply` + `Function.prototype.call` dispatch **Symptom.** `add.apply(null, [2, 3])` and `add.call(null, 2, 3)` both returned an opaque `[object Object]` stub instead of `5`. The dynamic method-dispatch path (`js_native_call_method` in `crates/perry-runtime/src/object.rs`) had a `bind` arm and the closure dynamic-prop lookup, but no `apply` / `call` arms — so any `fn.apply(thisArg, argsArr)` / `fn.call(thisArg, …)` on a closure receiver fell through the `is_closure` branch and returned the `NULL_OBJECT_BYTES` stub. This is what blocked ramda end-to-end: `_curry1`, `_curry2`, `_curry3` all wrap their bodies in `fn.apply(this, arguments)`, so the very first call to any curried export (`R.sum`, `R.add`, etc.) returned a stub instead of the dispatch result. diff --git a/CLAUDE.md b/CLAUDE.md index 4cda55b8f2..aff98fbae7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.983 +**Current Version:** 0.5.984 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 97838646d9..3467067187 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4905,7 +4905,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.983" +version = "0.5.984" dependencies = [ "anyhow", "base64", @@ -4960,14 +4960,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.983" +version = "0.5.984" dependencies = [ "serde", ] [[package]] name = "perry-codegen" -version = "0.5.983" +version = "0.5.984" dependencies = [ "anyhow", "log", @@ -4980,7 +4980,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.983" +version = "0.5.984" dependencies = [ "anyhow", "perry-hir", @@ -4989,7 +4989,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.983" +version = "0.5.984" dependencies = [ "anyhow", "perry-hir", @@ -4997,7 +4997,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.983" +version = "0.5.984" dependencies = [ "anyhow", "perry-dispatch", @@ -5007,7 +5007,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.983" +version = "0.5.984" dependencies = [ "anyhow", "perry-hir", @@ -5016,7 +5016,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.983" +version = "0.5.984" dependencies = [ "anyhow", "base64", @@ -5029,7 +5029,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.983" +version = "0.5.984" dependencies = [ "anyhow", "perry-hir", @@ -5037,7 +5037,7 @@ dependencies = [ [[package]] name = "perry-diagnostics" -version = "0.5.983" +version = "0.5.984" dependencies = [ "serde", "serde_json", @@ -5045,7 +5045,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.983" +version = "0.5.984" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5056,7 +5056,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.983" +version = "0.5.984" dependencies = [ "anyhow", "clap", @@ -5071,7 +5071,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.983" +version = "0.5.984" dependencies = [ "argon2", "perry-ffi", @@ -5079,7 +5079,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.983" +version = "0.5.984" dependencies = [ "perry-ffi", "reqwest", @@ -5088,7 +5088,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.983" +version = "0.5.984" dependencies = [ "bcrypt", "perry-ffi", @@ -5096,7 +5096,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.983" +version = "0.5.984" dependencies = [ "perry-ffi", "rusqlite", @@ -5104,7 +5104,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.983" +version = "0.5.984" dependencies = [ "perry-ffi", "scraper", @@ -5112,14 +5112,14 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.983" +version = "0.5.984" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-cron" -version = "0.5.983" +version = "0.5.984" dependencies = [ "chrono", "cron", @@ -5128,7 +5128,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.983" +version = "0.5.984" dependencies = [ "chrono", "perry-ffi", @@ -5136,7 +5136,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.983" +version = "0.5.984" dependencies = [ "perry-ffi", "rust_decimal", @@ -5144,7 +5144,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.983" +version = "0.5.984" dependencies = [ "perry-ffi", "serde_json", @@ -5152,7 +5152,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.983" +version = "0.5.984" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -5160,21 +5160,21 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.983" +version = "0.5.984" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.983" +version = "0.5.984" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.983" +version = "0.5.984" dependencies = [ "bytes", "http-body-util", @@ -5188,7 +5188,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.983" +version = "0.5.984" dependencies = [ "lazy_static", "perry-ffi", @@ -5199,7 +5199,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.983" +version = "0.5.984" dependencies = [ "lazy_static", "perry-ext-http-server", @@ -5211,7 +5211,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.983" +version = "0.5.984" dependencies = [ "bytes", "http-body-util", @@ -5230,7 +5230,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.983" +version = "0.5.984" dependencies = [ "lazy_static", "perry-ffi", @@ -5240,7 +5240,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.983" +version = "0.5.984" dependencies = [ "base64", "jsonwebtoken", @@ -5251,7 +5251,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.983" +version = "0.5.984" dependencies = [ "lru", "perry-ffi", @@ -5259,7 +5259,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.983" +version = "0.5.984" dependencies = [ "chrono", "perry-ffi", @@ -5267,7 +5267,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.983" +version = "0.5.984" dependencies = [ "bson", "futures-util", @@ -5279,7 +5279,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.983" +version = "0.5.984" dependencies = [ "chrono", "perry-ffi", @@ -5289,7 +5289,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.983" +version = "0.5.984" dependencies = [ "nanoid", "perry-ffi", @@ -5298,7 +5298,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.983" +version = "0.5.984" dependencies = [ "perry-ffi", "rustls", @@ -5309,7 +5309,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.983" +version = "0.5.984" dependencies = [ "lettre", "perry-ffi", @@ -5319,7 +5319,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.983" +version = "0.5.984" dependencies = [ "perry-ffi", "sqlx", @@ -5328,7 +5328,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.983" +version = "0.5.984" dependencies = [ "governor", "perry-ffi", @@ -5336,7 +5336,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.983" +version = "0.5.984" dependencies = [ "base64", "image", @@ -5345,14 +5345,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.983" +version = "0.5.984" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.983" +version = "0.5.984" dependencies = [ "lazy_static", "perry-ffi", @@ -5360,7 +5360,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.983" +version = "0.5.984" dependencies = [ "perry-ffi", "uuid", @@ -5368,7 +5368,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.983" +version = "0.5.984" dependencies = [ "perry-ffi", "regex", @@ -5378,7 +5378,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.983" +version = "0.5.984" dependencies = [ "futures-util", "lazy_static", @@ -5389,7 +5389,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.983" +version = "0.5.984" dependencies = [ "flate2", "perry-ffi", @@ -5397,7 +5397,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.983" +version = "0.5.984" dependencies = [ "dashmap 6.1.0", "once_cell", @@ -5406,7 +5406,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.983" +version = "0.5.984" dependencies = [ "anyhow", "perry-api-manifest", @@ -5420,7 +5420,7 @@ dependencies = [ [[package]] name = "perry-jsruntime" -version = "0.5.983" +version = "0.5.984" dependencies = [ "anyhow", "deno_core", @@ -5440,7 +5440,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.983" +version = "0.5.984" dependencies = [ "anyhow", "perry-diagnostics", @@ -5452,7 +5452,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.983" +version = "0.5.984" dependencies = [ "anyhow", "base64", @@ -5476,7 +5476,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.983" +version = "0.5.984" dependencies = [ "aes 0.8.4", "aes-gcm", @@ -5546,7 +5546,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.983" +version = "0.5.984" dependencies = [ "anyhow", "perry-hir", @@ -5556,7 +5556,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.983" +version = "0.5.984" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -5564,11 +5564,11 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.983" +version = "0.5.984" [[package]] name = "perry-ui-android" -version = "0.5.983" +version = "0.5.984" dependencies = [ "itoa", "jni", @@ -5583,7 +5583,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.983" +version = "0.5.984" dependencies = [ "rand 0.8.6", "serde", @@ -5593,7 +5593,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.983" +version = "0.5.984" dependencies = [ "cairo-rs", "dirs 5.0.1", @@ -5612,7 +5612,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.983" +version = "0.5.984" dependencies = [ "block2", "libc", @@ -5627,7 +5627,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.983" +version = "0.5.984" dependencies = [ "block2", "libc", @@ -5645,11 +5645,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.983" +version = "0.5.984" [[package]] name = "perry-ui-tvos" -version = "0.5.983" +version = "0.5.984" dependencies = [ "block2", "libc", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.983" +version = "0.5.984" dependencies = [ "block2", "libc", @@ -5679,7 +5679,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.983" +version = "0.5.984" dependencies = [ "block2", "libc", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.983" +version = "0.5.984" dependencies = [ "libc", "perry-runtime", @@ -5706,7 +5706,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.983" +version = "0.5.984" dependencies = [ "base64", "ed25519-dalek", @@ -5720,7 +5720,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.983" +version = "0.5.984" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index be87877e91..f5767a9bc6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -190,7 +190,7 @@ opt-level = "s" # Optimize for size in stdlib opt-level = 3 [workspace.package] -version = "0.5.983" +version = "0.5.984" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/crates/perry-codegen/src/codegen.rs b/crates/perry-codegen/src/codegen.rs index 0851036fe7..096742274f 100644 --- a/crates/perry-codegen/src/codegen.rs +++ b/crates/perry-codegen/src/codegen.rs @@ -5638,6 +5638,7 @@ fn emit_string_pool( // and similar method-less marker classes hit this. { let mut all_class_ids: Vec = Vec::new(); + let mut anon_shape_ids: Vec = Vec::new(); for (class_name, class) in classes.iter() { if *class_name != class.name { continue; @@ -5647,6 +5648,16 @@ fn emit_string_pool( _ => continue, }; all_class_ids.push(cid); + // date-fns / drizzle / lodash plain-object duck-checks need + // `({ x: 1 }).constructor === Object` to hold. The HIR + // synthesizes an `__AnonShape_` class per literal + // shape; mark each such class id so the runtime + // `js_object_get_field_by_name` resolves `.constructor` to + // the global `Object` constructor instead of the synthetic + // class ref. + if class_name.starts_with("__AnonShape_") { + anon_shape_ids.push(cid); + } } all_class_ids.sort_unstable(); all_class_ids.dedup(); @@ -5656,6 +5667,14 @@ fn emit_string_pool( &[(crate::types::I32, &cid.to_string())], ); } + anon_shape_ids.sort_unstable(); + anon_shape_ids.dedup(); + for cid in anon_shape_ids { + blk.call_void( + "js_register_anon_shape_class_id", + &[(crate::types::I32, &cid.to_string())], + ); + } } // Refs #486 (hono logger middleware): also register every class diff --git a/crates/perry-codegen/src/expr.rs b/crates/perry-codegen/src/expr.rs index 78f8437459..858caf1715 100644 --- a/crates/perry-codegen/src/expr.rs +++ b/crates/perry-codegen/src/expr.rs @@ -3670,6 +3670,34 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // (which IS the NaN-boxed value for non-number fields — same bit // pattern, runtime callers re-interpret based on context). Expr::PropertyGet { object, property } => { + // date-fns `constructFrom(date, value)` reads `date.constructor` + // to clone Dates without naming Date directly. Perry stores + // Date as a raw f64 timestamp (no ObjectHeader), so the + // generic `js_object_get_field_by_name_f64` path would treat + // the bit pattern as an invalid pointer and return undefined. + // For statically-Date-typed receivers, short-circuit + // `.constructor` to the global Date constructor closure — + // same value as the bare `Date` identifier resolves to via + // `js_get_global_this_builtin_value`. + if property == "constructor" { + if let Expr::LocalGet(id) = object.as_ref() { + let is_date = matches!( + ctx.local_types.get(id), + Some(HirType::Named(name)) if name == "Date" + ); + if is_date { + let name = "Date"; + let idx = ctx.strings.intern(name); + let bytes_global = format!("@{}", ctx.strings.entry(idx).bytes_global); + let len_str = name.len().to_string(); + return Ok(ctx.block().call( + DOUBLE, + "js_get_global_this_builtin_value", + &[(PTR, &bytes_global), (I64, &len_str)], + )); + } + } + } // Issue #649: PropertyGet on a native-module reference (`fs`, // `os`, `crypto`, `path`, ...). `NativeModuleRef` lowers to a // literal `0.0`, so the generic PropertyGet path can't see the @@ -4456,10 +4484,21 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ctx.block().unreachable(); // Undef-return path: existing fall-through for non-nullish - // invalid receivers. + // invalid receivers. Route through the runtime helper first + // so non-pointer typed shapes can still report a sensible + // value when the runtime knows what they are. Today this + // unblocks Date `.constructor` (Date stores as a raw f64 + // timestamp, so the codegen receiver-tag check at line ~4212 + // rejects it as non-pointer — yet the runtime's + // `js_object_get_field_by_name_f64` recognizes the bit + // pattern via `DATE_REGISTRY` and returns the global Date + // constructor closure). Date-fns `constructFrom` blocker. ctx.current_block = undef_idx; - let undef_bits = crate::nanbox::i64_literal(crate::nanbox::TAG_UNDEFINED); - let undef_val = ctx.block().bitcast_i64_to_double(&undef_bits); + let undef_val = ctx.block().call( + DOUBLE, + "js_object_get_field_by_name_f64", + &[(I64, &obj_bits), (I64, &key_handle)], + ); let invalid_end_label = ctx.block().label.clone(); ctx.block().br(&final_merge_label); @@ -5104,6 +5143,31 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { return lower_new(ctx, name, args); } + // date-fns `constructFrom(date, value)`: + // return new date.constructor(value); + // The callee is `PropertyGet { LocalGet(date), "constructor" }` + // where `date` is statically Date-typed. Lower through the + // dedicated `Expr::DateNew` path so the call routes to + // `js_date_new_from_value` / `js_date_new_local_components` + // and the result is a real Date timestamp, not an empty + // ObjectHeader. Pre-fix the NewDynamic fallback returned a + // placeholder object — `cloned.getTime()` then read garbage + // and the equality failed. Refs date-fns blocker. + if let Expr::PropertyGet { object, property } = callee.as_ref() { + if property == "constructor" { + if let Expr::LocalGet(id) = object.as_ref() { + let is_date = matches!( + ctx.local_types.get(id), + Some(HirType::Named(name)) if name == "Date" + ); + if is_date { + let synth = Expr::DateNew(args.to_vec()); + return lower_expr(ctx, &synth); + } + } + } + } + // Refs #740: `new O.Inner(args)` where `O` is an object // literal whose `Inner` field was initialized from a class // expression. The Stmt::Let lowering populates @@ -5175,8 +5239,18 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // also supported because the helper falls back to a // class_id=0 empty-object allocation when no synthetic id // exists (preserves the pre-fix baseline). - let routes_through_function_construct = - matches!(callee.as_ref(), Expr::FuncRef(_) | Expr::LocalGet(_)); + // Also route PropertyGet callees through `js_new_function_construct`: + // covers `new date.constructor(value)` (date-fns + // `constructFrom`) and generic `new obj.factory(...)` shapes + // where `obj.factory` resolves to a closure pointer at + // runtime. The runtime helper detects the global Date / + // Array / Object thunks and dispatches into the matching + // real factory; non-matching closures still get the + // class_id=0 empty-object baseline. + let routes_through_function_construct = matches!( + callee.as_ref(), + Expr::FuncRef(_) | Expr::LocalGet(_) | Expr::PropertyGet { .. } + ); if routes_through_function_construct { let func_double = lower_expr(ctx, callee)?; let lowered_args: Vec = args diff --git a/crates/perry-codegen/src/runtime_decls.rs b/crates/perry-codegen/src/runtime_decls.rs index e7f66e7322..edbd3c0f0b 100644 --- a/crates/perry-codegen/src/runtime_decls.rs +++ b/crates/perry-codegen/src/runtime_decls.rs @@ -696,6 +696,17 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function("js_instanceof_dynamic", DOUBLE, &[DOUBLE, DOUBLE]); module.declare_function("js_register_class_extends_error", VOID, &[I32]); module.declare_function("js_register_class_id", VOID, &[I32]); + // Anon-shape class registration so `.constructor` reads on object + // literals (`{ x: 1 }`) return the global `Object` constructor + // instead of the synthetic class ref. Refs #968 / date-fns + // `constructFrom` blocker. + module.declare_function("js_register_anon_shape_class_id", VOID, &[I32]); + // Built-in constructor / namespace value-lookup on the globalThis + // singleton. Used to wire `instance.constructor` and bare + // `Date`/`Array`/`Object` identifiers to the same closure pointer + // so `inst.constructor === Date` (date-fns / drizzle / lodash duck + // checks) holds. + module.declare_function("js_get_global_this_builtin_value", DOUBLE, &[PTR, I64]); // Inline-allocator class registration: emitted once per class // with a parent in the entry-block init prelude. The runtime // allocators register on every alloc; the inline allocator skips diff --git a/crates/perry-hir/src/analysis.rs b/crates/perry-hir/src/analysis.rs index f03a87e5de..f17d46c56a 100644 --- a/crates/perry-hir/src/analysis.rs +++ b/crates/perry-hir/src/analysis.rs @@ -1317,6 +1317,60 @@ pub(crate) fn is_builtin_function(name: &str) -> bool { ) } +/// Built-in constructor / namespace identifiers that should resolve to +/// a real `globalThis.` closure pointer when used as a bare +/// expression value (e.g. `inst.constructor === Date`, drizzle's +/// `value.constructor === Object`, lodash's `var A = context.Array`). +/// Mirrors `populate_global_this_builtins` in +/// `crates/perry-runtime/src/object.rs` and +/// `is_global_this_builtin_name` in `crates/perry-codegen/src/expr.rs`. +/// +/// Callable surfaces — `Date()`/`new Date()`/`Date.now()`/`Math.PI` — +/// are intercepted by dedicated HIR variants (`Expr::DateNow`, +/// `Expr::DateNew`, `Expr::DateGet*`, `Expr::MathPow`, …) before the +/// ident lowering reaches this point, so converting bare names to +/// `PropertyGet { GlobalGet, name }` doesn't disturb those paths. +pub(crate) fn is_builtin_global_value_name(name: &str) -> bool { + matches!( + name, + "Array" + | "Object" + | "String" + | "Number" + | "Boolean" + | "Function" + | "RegExp" + | "Date" + | "Error" + | "TypeError" + | "RangeError" + | "SyntaxError" + | "ReferenceError" + | "EvalError" + | "URIError" + | "Symbol" + | "Promise" + | "Map" + | "Set" + | "WeakMap" + | "WeakSet" + | "WeakRef" + | "Proxy" + | "BigInt" + | "Uint8Array" + | "Int8Array" + | "Uint16Array" + | "Int16Array" + | "Uint32Array" + | "Int32Array" + | "Float32Array" + | "Float64Array" + | "BigInt64Array" + | "BigUint64Array" + | "Uint8ClampedArray" + ) +} + /// Rewrite all `Expr::This` references inside a block of statements to /// `Expr::LocalGet(this_id)`. Used to lift class generator methods /// (`*[Symbol.iterator]()`) to a top-level function with `this` as an diff --git a/crates/perry-hir/src/lower.rs b/crates/perry-hir/src/lower.rs index c111736cac..b2ef87334d 100644 --- a/crates/perry-hir/src/lower.rs +++ b/crates/perry-hir/src/lower.rs @@ -8216,6 +8216,25 @@ pub(crate) fn lower_expr(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result< name ); } + // Bare built-in constructor identifiers (`Date`, `Array`, + // `Object`, ...) used as VALUES (not method receivers / + // `new` callees) need a real closure pointer so identity + // comparisons like `inst.constructor === Date` hold — + // both sides must resolve to the same `populate_global_this_builtins`- + // installed closure. Reuse the existing + // `PropertyGet { GlobalGet, }` codegen path that + // dispatches through `js_get_global_this` for builtin + // names. Bare-callee shapes (e.g. `Date.now()`, `new + // Date()`) are picked off earlier by their dedicated HIR + // variants — `Expr::DateNow`, `Expr::DateNew(...)`, + // `Expr::Date*Get(...)` — so they don't reach this arm. + // date-fns / drizzle / lodash duck-typing path. + if is_builtin_global_value_name(&name) { + return Ok(Expr::PropertyGet { + object: Box::new(Expr::GlobalGet(0)), + property: name, + }); + } Ok(Expr::GlobalGet(0)) } } diff --git a/crates/perry-hir/src/lower_types.rs b/crates/perry-hir/src/lower_types.rs index 3789694b51..e57ccc97c7 100644 --- a/crates/perry-hir/src/lower_types.rs +++ b/crates/perry-hir/src/lower_types.rs @@ -1232,6 +1232,16 @@ fn lower_decorator_arg(ctx: &mut LoweringContext, expr: &ast::Expr) -> Option lower_lit(lit).ok(), ast::Expr::Ident(ident) => match lower_expr(ctx, expr).ok() { Some(Expr::GlobalGet(0)) => Some(Expr::ClassRef(ident.sym.to_string())), + // Bare built-in name `Date`/`Array`/`Object`/... now lowers + // to `PropertyGet { GlobalGet(0), name }` (so the value-side + // identity comparison `inst.constructor === Date` matches). + // For decorator-arg use it's still a class ref. + Some(Expr::PropertyGet { + object: ref obj, + property: _, + }) if matches!(obj.as_ref(), Expr::GlobalGet(0)) => { + Some(Expr::ClassRef(ident.sym.to_string())) + } other => other, }, ast::Expr::Array(arr) => { diff --git a/crates/perry-runtime/src/object.rs b/crates/perry-runtime/src/object.rs index 7509f111c0..4265eb40ef 100644 --- a/crates/perry-runtime/src/object.rs +++ b/crates/perry-runtime/src/object.rs @@ -1236,6 +1236,99 @@ pub unsafe extern "C" fn js_register_class_id(class_id: u32) { guard.as_mut().unwrap().insert(class_id); } +/// Resolve a closure-typed JSValue back to a built-in constructor name +/// (`"Date"`/`"Array"`/`"Object"`/...) when it matches one of the +/// singleton-installed thunks. Returns `None` for closures that aren't +/// the globalThis built-in constructors. Used by +/// `js_new_function_construct` to dispatch `new (...)` +/// shapes (date-fns `constructFrom`, lodash-style `Array` cloning, ...) +/// to the right runtime factory. +fn identify_global_builtin_constructor(func_value: f64) -> Option<&'static str> { + use crate::value::JSValue; + let jv = JSValue::from_bits(func_value.to_bits()); + if !jv.is_pointer() { + return None; + } + let ptr = jv.as_pointer() as *const crate::closure::ClosureHeader; + if ptr.is_null() { + return None; + } + if !is_valid_obj_ptr(ptr as *const u8) { + return None; + } + // Identify by the closure's read-only `func_ptr` rather than the + // GC-movable ClosureHeader address. Both the date-fns ctor closure + // and the (later-evacuated) ctor closure carry the same + // `global_this_builtin_noop_thunk` function pointer, so this match + // survives GC moves. The per-name lookup must then walk the + // globalThis singleton's keys to recover the constructor name — + // accept the extra hop only when the func_ptr matches. + unsafe { + if (*ptr).type_tag != crate::closure::CLOSURE_MAGIC { + return None; + } + let func_ptr = (*ptr).func_ptr as usize; + let noop_thunk = global_this_builtin_noop_thunk as *const u8 as usize; + if func_ptr != noop_thunk { + return None; + } + } + // Find which builtin name maps to this exact closure header on the + // singleton. Walk via the existing + // `js_get_global_this_builtin_value` helper — short loop (≤ ~50 + // entries), only fires on the constructFrom hot path. + let global_this_f64 = js_get_global_this(); + let global_obj = crate::value::js_nanbox_get_pointer(global_this_f64) as *const ObjectHeader; + if global_obj.is_null() { + return None; + } + for name in GLOBAL_THIS_BUILTIN_CONSTRUCTORS.iter().copied() { + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let v = unsafe { js_object_get_field_by_name(global_obj, key) }; + if v.bits() == jv.bits() { + return Some(name); + } + } + None +} + +/// Synthetic-anonymous-shape class IDs: classes the HIR generates for +/// bare object literals (`{ x: 1 }` → `__AnonShape_`). Instances +/// of these shapes should report `Object` from `.constructor`, not the +/// synthetic class itself, so date-fns's `new value.constructor(...)`, +/// drizzle's `value.constructor === Object` duck checks, and the standard +/// `({}).constructor === Object` semantics all match Node. The HIR +/// lowering registers each anon shape's id here at module init. +pub static ANON_SHAPE_CLASS_IDS: RwLock>> = RwLock::new(None); + +/// Mark `class_id` as a synthetic anon-shape class so `.constructor` +/// reads on instances of that class return the global `Object` +/// constructor rather than the synthetic class ref. +#[no_mangle] +pub unsafe extern "C" fn js_register_anon_shape_class_id(class_id: u32) { + if class_id == 0 { + return; + } + let mut guard = ANON_SHAPE_CLASS_IDS.write().unwrap(); + if guard.is_none() { + *guard = Some(std::collections::HashSet::new()); + } + guard.as_mut().unwrap().insert(class_id); +} + +/// True if `class_id` was registered via `js_register_anon_shape_class_id`. +pub fn is_anon_shape_class_id(class_id: u32) -> bool { + if class_id == 0 { + return false; + } + if let Ok(guard) = ANON_SHAPE_CLASS_IDS.read() { + if let Some(set) = guard.as_ref() { + return set.contains(&class_id); + } + } + false +} + /// Register a static field value on a class so `Cls.field` (when `Cls` is /// accessed via dynamic dispatch — e.g. through an Any-typed local) finds /// the value via the runtime path. Codegen calls this at module init for @@ -1429,6 +1522,62 @@ pub unsafe extern "C" fn js_new_function_construct( args_ptr: *const f64, args_len: usize, ) -> f64 { + // date-fns `constructFrom` clones a Date via + // `new date.constructor(value)`. `date.constructor` resolves to + // the global `Date` closure pointer (the noop thunk installed by + // `populate_global_this_builtins`). Without this intercept the + // call falls through to the generic empty-object path and + // `cloned.getTime()` reads garbage. Detect the global Date / + // Array / Object constructor pointers and dispatch into the + // matching real factory. Refs date-fns blocker. + if let Some(name) = identify_global_builtin_constructor(func_value) { + let args = if args_ptr.is_null() { + &[][..] + } else { + std::slice::from_raw_parts(args_ptr, args_len) + }; + match name { + "Date" => { + if args.is_empty() { + return crate::date::js_date_new(); + } + if args.len() == 1 { + return crate::date::js_date_new_from_value(args[0]); + } + let mut vals = [f64::from_bits(crate::value::TAG_UNDEFINED); 7]; + for (i, slot) in vals.iter_mut().enumerate() { + if i < args.len() { + *slot = args[i]; + } + } + return crate::date::js_date_new_local_components( + vals[0], vals[1], vals[2], vals[3], vals[4], vals[5], vals[6], + ); + } + "Array" => { + // `new Array(n)`: empty array of length n. + // `new Array(a, b, c)`: array filled with the args. + let single_len = args.len() == 1 && args[0].is_finite() && args[0] >= 0.0; + let len = if single_len { + args[0] as u32 + } else { + args.len() as u32 + }; + let arr = crate::array::js_array_alloc(len); + if !single_len { + for (i, &v) in args.iter().enumerate() { + crate::array::js_array_set_f64(arr, i as u32, v); + } + } + return crate::value::js_nanbox_pointer(arr as i64); + } + "Object" => { + let obj = js_object_alloc(0, 0); + return crate::value::js_nanbox_pointer(obj as i64); + } + _ => {} + } + } let cid = synthetic_class_id_for_function(func_value); // Allocate the instance with the synthetic class id (or 0 if the // value isn't callable). The object starts with no own props; the @@ -3803,6 +3952,16 @@ pub extern "C" fn js_object_get_field_by_name( let arr = obj as *const crate::array::ArrayHeader; return JSValue::number(crate::array::js_array_length(arr) as f64); } + // date-fns / drizzle / lodash duck-typing path: + // `arr.constructor === Array`, `new arr.constructor(...)`, + // etc. expect a non-undefined function-typed value that + // refers back to the global `Array` constructor. Resolve + // through the singleton so this returns the same closure + // pointer as the bare `Array` identifier. + if key_bytes == b"constructor" { + let v = js_get_global_this_builtin_value(b"Array".as_ptr(), 5); + return JSValue::from_bits(v.to_bits()); + } } return JSValue::undefined(); } @@ -3820,6 +3979,10 @@ pub extern "C" fn js_object_get_field_by_name( let arr = obj as *const crate::array::ArrayHeader; return JSValue::number(crate::array::js_array_length(arr) as f64); } + if key_bytes == b"constructor" { + let v = js_get_global_this_builtin_value(b"Array".as_ptr(), 5); + return JSValue::from_bits(v.to_bits()); + } } // Any other property access force-materializes, then // re-enters via the materialized ArrayHeader pointer. @@ -3982,10 +4145,29 @@ pub extern "C" fn js_object_get_field_by_name( let key_bytes = std::slice::from_raw_parts(key_ptr, key_len); if key_bytes == b"constructor" { let class_id = (*obj).class_id; + // Object-literal instances (`{ x: 1 }`) carry a synthetic + // `__AnonShape_*` class id. Spec says their `.constructor` + // is the global `Object`, not the synthetic class — so + // resolve through the globalThis singleton so the value + // matches the bare `Object` identifier (`x.constructor + // === Object`, date-fns `constructFrom`, drizzle's + // `isPlainObject` duck check). + if class_id != 0 && is_anon_shape_class_id(class_id) { + let v = js_get_global_this_builtin_value(b"Object".as_ptr(), 6); + return JSValue::from_bits(v.to_bits()); + } if class_id != 0 && is_class_id_registered(class_id) { let bits = 0x7FFE_0000_0000_0000u64 | (class_id as u64); return JSValue::from_bits(bits); } + // class_id == 0 fallback: plain ObjectHeader allocated + // without an HIR shape (Object.create(null) hybrids, raw + // empty `{}` produced by JSON.parse, etc.). Report + // `Object` so duck-type tests don't trip undefined. + if class_id == 0 { + let v = js_get_global_this_builtin_value(b"Object".as_ptr(), 6); + return JSValue::from_bits(v.to_bits()); + } } } @@ -4275,6 +4457,26 @@ pub extern "C" fn js_object_get_field_by_name_f64( obj: *const ObjectHeader, key: *const crate::StringHeader, ) -> f64 { + // date-fns `constructFrom`: the parameter is statically Any so the + // codegen Date intercept doesn't fire here. Detect Date instances + // by their registered f64 bit pattern and route `.constructor` to + // the global Date constructor closure — same value the bare `Date` + // identifier produces, so `date instanceof Date` followed by `new + // date.constructor(value)` lands on the right factory inside + // `js_new_function_construct`. + if !key.is_null() { + let obj_bits = obj as u64; + if crate::date::is_registered_date_bits(obj_bits) { + unsafe { + let key_ptr = (key as *const u8).add(std::mem::size_of::()); + let key_len = (*key).byte_len as usize; + let key_bytes = std::slice::from_raw_parts(key_ptr, key_len); + if key_bytes == b"constructor" { + return js_get_global_this_builtin_value(b"Date".as_ptr(), 4); + } + } + } + } let value = js_object_get_field_by_name(obj, key); f64::from_bits(value.bits()) } @@ -10010,6 +10212,39 @@ fn populate_global_this_builtins(singleton: *mut ObjectHeader) { } } +/// Look up the canonical NaN-boxed value of a built-in constructor / +/// namespace stored on `globalThis` (the singleton populated by +/// `populate_global_this_builtins`). Used by `instance.constructor` +/// reads and by bare `Date`/`Array`/`Object` identifier resolution so +/// both forms produce the same closure-pointer value — that's what +/// `instance.constructor === Date` (date-fns's `constructFrom`, +/// drizzle's `is(value, ctor)` duck checks, ...) hinges on. +/// +/// Returns NaN-boxed undefined if the name isn't one of the populated +/// built-ins or the singleton hasn't been initialized yet. +#[no_mangle] +pub extern "C" fn js_get_global_this_builtin_value(name_ptr: *const u8, name_len: usize) -> f64 { + if name_ptr.is_null() || name_len == 0 { + return f64::from_bits(crate::value::TAG_UNDEFINED); + } + let name_bytes = unsafe { std::slice::from_raw_parts(name_ptr, name_len) }; + let name = match std::str::from_utf8(name_bytes) { + Ok(s) => s, + Err(_) => return f64::from_bits(crate::value::TAG_UNDEFINED), + }; + // Force the singleton init the first time so the lookup below has + // a populated field bag. + let global_this_f64 = js_get_global_this(); + let global_obj = crate::value::js_nanbox_get_pointer(global_this_f64) as *const ObjectHeader; + if global_obj.is_null() { + return f64::from_bits(crate::value::TAG_UNDEFINED); + } + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let value = js_object_get_field_by_name(global_obj, key); + let bits = value.bits(); + f64::from_bits(bits) +} + /// Object.getOwnPropertyNames(obj) — returns all own property names (including non-enumerable). /// Takes a NaN-boxed f64 object pointer, returns a NaN-boxed f64 array pointer. #[no_mangle] diff --git a/test-files/test_constructor_property.ts b/test-files/test_constructor_property.ts new file mode 100644 index 0000000000..a1b657f0ed --- /dev/null +++ b/test-files/test_constructor_property.ts @@ -0,0 +1,13 @@ +const d = new Date(2024, 0, 15); +console.log(typeof d.constructor); +console.log(d.constructor === Date); +const cloned = new (d.constructor as any)(d.getTime()); +console.log(cloned.getTime() === d.getTime()); + +const arr = [1, 2, 3]; +console.log(typeof arr.constructor); +console.log(arr.constructor === Array); + +const obj = { x: 1 }; +console.log(typeof obj.constructor); +console.log(obj.constructor === Object);