diff --git a/changelog.d/10675-dyn-eval-class-expr.md b/changelog.d/10675-dyn-eval-class-expr.md new file mode 100644 index 0000000000..d759df2289 --- /dev/null +++ b/changelog.d/10675-dyn-eval-class-expr.md @@ -0,0 +1,11 @@ +Fixed `new Function`-string interpreter (#6559) to support **class expressions** in a +deliberately narrow subset: a constructor plus regular instance/`static` methods +(identifier/string/numeric keys), no `extends`/decorators/getters-setters/fields/private +members/computed keys/static blocks. This was the sole runtime blocker for `mysql2`, whose +`generate-function`-built row parsers (`text_parser.js`/`binary_parser.js`) return a class +expression from a `Function.apply(...).apply(...)` call; `generate-function` is used well beyond +mysql2, so this likely unblocks other packages too. Desugars onto machinery the interpreter +already had (closure expando writes + the generic `new ` path that already reads a +`"prototype"` dynamic prop), so no new runtime mechanism was added. mysql2 now runs an +end-to-end `CREATE`/`INSERT`/`SELECT`/`DROP` round trip against a real server; the hand-written +native mysql2 binding now looks deletable as a follow-up. See #10661. diff --git a/crates/perry-runtime/src/dyn_eval/expr.rs b/crates/perry-runtime/src/dyn_eval/expr.rs index 0a582d9f3b..c2b77405b5 100644 --- a/crates/perry-runtime/src/dyn_eval/expr.rs +++ b/crates/perry-runtime/src/dyn_eval/expr.rs @@ -7,8 +7,12 @@ //! sequence operators, `typeof`/`instanceof`/`in`/`delete`, assignments //! (plain, compound, logical, destructuring), member + computed access, //! optional chaining, calls (host functions, host methods, interpreted -//! closures — with `this` bound like the runtime binds it), and `new` on -//! host constructors / builtin error types / RegExp. +//! closures — with `this` bound like the runtime binds it), `new` on +//! host constructors / builtin error types / RegExp, and #10661 class +//! expressions restricted to: a constructor plus regular (non-getter/setter, +//! non-generator/async) instance/static methods with identifier, string, or +//! numeric keys — see `interp::eval_class_expr` for exactly what is and +//! isn't covered. //! //! Everything else throws the #6559 diagnostic naming the construct. @@ -94,7 +98,7 @@ pub(crate) fn eval_expr(ctx: &Ctx, expr: &ast::Expr, env_idx: usize) -> f64 { OptChain(o) => eval_opt_chain(ctx, o, env_idx), Await(_) => throw_unsupported("await (async interpreted code)"), Yield(_) => throw_unsupported("yield (generator interpreted code)"), - Class(_) => throw_unsupported("class expression"), + Class(c) => super::interp::eval_class_expr(ctx, c, env_idx), TaggedTpl(_) => throw_unsupported("tagged template literal"), SuperProp(_) => throw_unsupported("super property access"), MetaProp(_) => throw_unsupported("new.target / import.meta"), diff --git a/crates/perry-runtime/src/dyn_eval/interp.rs b/crates/perry-runtime/src/dyn_eval/interp.rs index b3fc1819d2..e4f4788485 100644 --- a/crates/perry-runtime/src/dyn_eval/interp.rs +++ b/crates/perry-runtime/src/dyn_eval/interp.rs @@ -1089,3 +1089,200 @@ fn exec_try_catch(ctx: &Ctx, t: &ast::TryStmt, env_idx: usize) -> Flow { fn protected_block(ctx: &Ctx, t: &ast::TryStmt, env_idx: usize) -> Flow { exec_block_scope(ctx, &t.block, env_idx) } + +// ── class expressions (#10661) ────────────────────────────────────────────── + +/// `class [Name] { constructor(...) { ... } method(...) { ... } ... }` as a +/// standalone expression — the shape `generate-function` emits (mysql2's row +/// parsers: `return class TextRow { constructor(fields) {...} next(...) {...} }`). +/// +/// **Supported subset, deliberately narrow** (matches what the schema/codegen +/// corpus behind #6559 actually emits, not general ES2022 class syntax): +/// * an optional `constructor`; missing one synthesizes an empty no-op +/// constructor (there is no `extends`, so there is nothing to forward to +/// a super constructor); +/// * regular (non-getter/setter, non-generator/async) methods, instance or +/// `static`, keyed by identifier / string / numeric literal; +/// * a named class expression sees its own name inside its body, exactly +/// like a named function expression. +/// +/// **Explicitly unsupported** (throws the #6559 diagnostic naming the +/// construct, same as every other out-of-subset form in this interpreter): +/// `extends` (no superclass chain — no `super()`/`super.foo` machinery +/// exists here), decorators, getters/setters, generator/async methods, +/// class fields (public or private), private methods, static blocks, +/// auto-accessors, TS index signatures, TS parameter properties, and +/// computed member keys. +/// +/// **Why this is sugar, not a new mechanism.** The interpreter already +/// supports the ES5 pattern this desugars to — `function Foo(){}` plus +/// `Foo.prototype.bar = function(){}` plus `new Foo()` — because ordinary +/// property writes on an interpreted closure already land in its dynamic +/// expando table (ajv's `validate.errors = ...` already exercises that path), +/// and `new` on ANY closure (host or interpreted) already goes through +/// `js_new_function_construct`'s generic path, which specifically looks for a +/// `"prototype"` dynamic prop to link the new instance's `[[Prototype]]` +/// (`crates/perry-runtime/src/object/class_registry/construct.rs`). So this +/// function does nothing runtime-side that wasn't already reachable from +/// interpreted code — it just builds a constructor closure, a plain prototype +/// object, and wires them together the same way hand-written ES5 would. +/// Nothing new is added to `js_new_function_construct`, method dispatch, or +/// `instanceof` — an instance built this way is an ordinary object whose +/// `[[Prototype]]` happens to be the class's prototype object, found by the +/// same prototype-chain walk any plain object uses. +pub(crate) fn eval_class_expr(ctx: &Ctx, class_expr: &ast::ClassExpr, env_idx: usize) -> f64 { + let class = class_expr.class.as_ref(); + if class.super_class.is_some() { + throw_unsupported("class expression with `extends`"); + } + if !class.decorators.is_empty() { + throw_unsupported("class decorator"); + } + + let base = roots_len(); + + // Named class expressions see their own name inside constructor AND + // method bodies — same pattern `make_function_value` uses for named + // function expressions: chain a one-binding scope, alloc the closure + // over it, then backfill the binding once the closure value exists. + let name = class_expr.ident.as_ref().map(|i| i.sym.to_string()); + let body_env_idx = if name.is_some() { + let name_env = env::env_new(root_get(env_idx)); + root_push(name_env) + } else { + env_idx + }; + + let ctor_member = class.body.iter().find_map(|m| match m { + ast::ClassMember::Constructor(c) => Some(c), + _ => None, + }); + let ctor_fn_id = match ctor_member { + Some(c) => { + let mut params = Vec::with_capacity(c.params.len()); + for p in &c.params { + match p { + ast::ParamOrTsParamProp::Param(p) => params.push(p.pat.clone()), + ast::ParamOrTsParamProp::TsParamProp(_) => { + throw_unsupported("TypeScript parameter property in class constructor") + } + } + } + let body = + InterpBody::Block(c.body.as_ref().map(|b| b.stmts.clone()).unwrap_or_default()); + fn_id_for_node(c as *const ast::Constructor as usize, || { + build_interp_fn(params, body, ctx.strict) + }) + } + None => { + // No constructor written: synthesize an empty one. Keyed on the + // `Class` node itself (there is no dedicated AST node for a + // synthesized constructor) — only used as a cache key, stable + // for the same reason every other node-address key here is: + // `FN_REGISTRY` keeps the owning `InterpFn` (and therefore this + // address) alive for the program's lifetime. + fn_id_for_node(class as *const ast::Class as usize, || { + build_interp_fn(Vec::new(), InterpBody::Block(Vec::new()), ctx.strict) + }) + } + }; + + let ctor_closure = alloc_interp_closure( + ctor_fn_id, + root_get(body_env_idx), + None, + root_get(ctx.global_idx), + root_get(ctx.intrinsics_idx), + ctx.strings_allowed, + ctx.wasm_allowed, + ); + let ctor_idx = root_push(ctor_closure); + + if let Some(name) = &name { + env::define(root_get(body_env_idx), name, root_get(ctor_idx)); + } + + // Plain object, `Object.prototype`-rooted — same as any object literal. + let prototype = bridge::attach_intrinsic_prototype( + bridge::object_new(), + root_get(ctx.intrinsics_idx), + "Object", + ); + let proto_idx = root_push(prototype); + bridge::set_member(root_get(proto_idx), "constructor", root_get(ctor_idx)); + + for member in &class.body { + match member { + ast::ClassMember::Constructor(_) => {} + ast::ClassMember::Method(m) => { + if m.kind != ast::MethodKind::Method { + throw_unsupported("getter/setter in class body"); + } + if m.function.is_generator || m.function.is_async { + throw_unsupported("generator/async method in class body"); + } + let value = make_function_value( + ctx, + m.function.params.iter().map(|p| p.pat.clone()).collect(), + InterpBody::Block( + m.function + .body + .as_ref() + .map(|b| b.stmts.clone()) + .unwrap_or_default(), + ), + false, + None, + m.function.as_ref() as *const ast::Function as usize, + body_env_idx, + ); + let target_idx = if m.is_static { ctor_idx } else { proto_idx }; + set_class_member(target_idx, &m.key, value); + } + ast::ClassMember::PrivateMethod(_) => throw_unsupported("private method (#field)"), + ast::ClassMember::ClassProp(_) => throw_unsupported("class field"), + ast::ClassMember::PrivateProp(_) => throw_unsupported("private class field (#field)"), + ast::ClassMember::TsIndexSignature(_) => { + throw_unsupported("TypeScript index signature in class body") + } + ast::ClassMember::Empty(_) => {} + ast::ClassMember::StaticBlock(_) => throw_unsupported("static initialization block"), + ast::ClassMember::AutoAccessor(_) => throw_unsupported("auto-accessor class member"), + } + } + + // Wire the two together last: `Ctor.prototype = proto` is the dynamic + // expando write `js_new_function_construct` specifically looks for + // (`closure_get_dynamic_prop(fp, "prototype")`) to link a `new`-built + // instance's `[[Prototype]]` to `proto` instead of the closure's default + // (empty, per-function) prototype object. + bridge::set_member(root_get(ctor_idx), "prototype", root_get(proto_idx)); + + let result = root_get(ctor_idx); + roots_truncate(base); + result +} + +/// Set a class member (method) by its `PropName` onto the target (prototype +/// or constructor, for instance vs. `static`). Rejects computed and bigint +/// keys — see `eval_class_expr`'s documented subset. +fn set_class_member(target_idx: usize, key: &ast::PropName, value: f64) { + let value_idx = root_push(value); + match key { + ast::PropName::Ident(i) => { + bridge::set_member(root_get(target_idx), &i.sym, root_get(value_idx)) + } + ast::PropName::Str(s) => bridge::set_member( + root_get(target_idx), + &String::from_utf8_lossy(s.value.as_bytes()), + root_get(value_idx), + ), + ast::PropName::Num(n) => { + let k = bridge::make_number(n.value); + bridge::set_index(root_get(target_idx), k, root_get(value_idx), false); + } + ast::PropName::Computed(_) => throw_unsupported("computed method name in class body"), + ast::PropName::BigInt(_) => throw_unsupported("bigint method name in class body"), + } + roots_truncate(value_idx); +} diff --git a/crates/perry-runtime/src/dyn_eval/mod.rs b/crates/perry-runtime/src/dyn_eval/mod.rs index 6edcca6d22..7515dae07f 100644 --- a/crates/perry-runtime/src/dyn_eval/mod.rs +++ b/crates/perry-runtime/src/dyn_eval/mod.rs @@ -15,7 +15,12 @@ //! covers the pragmatic subset those code generators emit (see `interp.rs` / //! `expr.rs`); anything outside the subset throws a diagnostic TypeError //! naming the unsupported construct, so real-world gaps surface as clear -//! errors instead of silent miscomputation. +//! errors instead of silent miscomputation. #10661: `generate-function` +//! (mysql2's row parsers, and others beyond mysql2) emits a **class +//! expression** as the returned value — `interp::eval_class_expr` supports a +//! deliberately narrow subset of that (constructor + plain methods, no +//! `extends`/decorators/getters/setters/fields/private members/computed +//! keys); see its doc comment for the exact boundary. //! //! Bridging is the crux and it is bidirectional: //! * interpreted code calls REAL runtime values (schema refs, format diff --git a/crates/perry-runtime/src/dyn_eval/tests.rs b/crates/perry-runtime/src/dyn_eval/tests.rs index 7696d0dd12..0fe12e05da 100644 --- a/crates/perry-runtime/src/dyn_eval/tests.rs +++ b/crates/perry-runtime/src/dyn_eval/tests.rs @@ -397,11 +397,16 @@ fn parse_error_throws_syntax_error() { #[test] fn unsupported_construct_diagnostic_names_the_construct() { + // #10661 narrowed what counts as "unsupported" here: a plain class + // expression is now interpreted (see the `class_expression_*` tests + // above). `extends` stays out of the supported subset (no superclass + // chain / `super()` machinery exists in this interpreter), so it is + // still the representative "diagnostic names the construct" case. let result = catch_throw(|| { - let f = dyn_fn(&["return class {}"]); + let f = dyn_fn(&["return class extends Array {}"]); call(f, &[]) }); - let exc = result.expect_err("class expression must be rejected"); + let exc = result.expect_err("class expression with extends must be rejected"); let msg = error_message(exc); assert!( msg.contains("unsupported construct") && msg.contains("class"), @@ -1076,3 +1081,179 @@ fn promise_static_result_retains_intrinsic_prototype() { buys nothing and costs a process-wide fast-path invalidation" ); } + +// ── class expressions (#10661) ────────────────────────────────────────────── +// +// mysql2's row parsers (`lib/parsers/text_parser.js` / +// `binary_parser.js`, via `generate-function`) build EXACTLY this shape at +// runtime — captured verbatim from a live `mysql2` `SELECT` against a real +// server (`Function.apply(null, keys.concat(src)).apply(null, vals)`, +// `generate-function/index.js:172`): +// +// (function anonymous() { +// return ((function () { +// return class TextRow { +// constructor(fields) {} +// next(packet, fields, options) { +// this.packet = packet; +// const result = {}; +// result["val"] = packet.readLengthCodedString(fields[0].encoding); +// return result; +// } +// }; +// })()) +// }) +// +// The tests below exercise that shape (minus the host `packet` receiver, +// which is out of unit-test scope the same way +// `interpreted_code_constructs_host_class_parameter` above notes) plus the +// rest of the documented subset, and confirm the documented boundary +// (`extends`, getters/setters, private members, computed keys, class fields, +// static blocks) still throws the #6559 diagnostic. + +#[test] +fn class_expression_mysql2_row_parser_shape() { + let f = dyn_fn(&[r#" + return (function () { + return class TextRow { + constructor(fields) { + this.fields = fields; + } + next(extra) { + return this.fields + extra; + } + }; + })(); + "#]); + let ctor_idx = root_push(call(f, &[])); + let inst = super::bridge::construct(root_get(ctor_idx), &[num(3.0)]); + let inst_idx = root_push(inst); + let result = super::bridge::call_method(root_get(inst_idx), "next", &[num(4.0)]); + roots_truncate(ctor_idx); + assert_eq!(as_num(result), 7.0); +} + +#[test] +fn class_expression_default_constructor_and_instance_state() { + // No explicit constructor: synthesized empty one, matching a class with + // no `constructor(...)` member. + let f = dyn_fn(&[r#" + return class Empty { + set(v) { this.v = v; return this; } + get() { return this.v; } + }; + "#]); + let ctor_idx = root_push(call(f, &[])); + let inst = super::bridge::construct(root_get(ctor_idx), &[]); + let inst_idx = root_push(inst); + super::bridge::call_method(root_get(inst_idx), "set", &[num(9.0)]); + let result = super::bridge::call_method(root_get(inst_idx), "get", &[]); + roots_truncate(ctor_idx); + assert_eq!(as_num(result), 9.0); +} + +#[test] +fn class_expression_static_method_and_string_numeric_keys() { + let f = dyn_fn(&[r#" + return class Keyed { + static make() { return new Keyed(); } + "str-key"() { return "s"; } + 0() { return "n"; } + }; + "#]); + let ctor_idx = root_push(call(f, &[])); + let made = super::bridge::call_method(root_get(ctor_idx), "make", &[]); + let made_idx = root_push(made); + assert_eq!( + as_str(super::bridge::call_method( + root_get(made_idx), + "str-key", + &[] + )), + "s" + ); + assert_eq!( + as_str(super::bridge::call_method(root_get(made_idx), "0", &[])), + "n" + ); + roots_truncate(ctor_idx); +} + +#[test] +fn class_expression_two_instances_do_not_share_state() { + let f = dyn_fn(&[r#" + return class Counter { + constructor() { this.n = 0; } + inc() { this.n = this.n + 1; return this.n; } + }; + "#]); + let ctor_idx = root_push(call(f, &[])); + let a = super::bridge::construct(root_get(ctor_idx), &[]); + let a_idx = root_push(a); + let b = super::bridge::construct(root_get(ctor_idx), &[]); + let b_idx = root_push(b); + super::bridge::call_method(root_get(a_idx), "inc", &[]); + super::bridge::call_method(root_get(a_idx), "inc", &[]); + let a_result = super::bridge::call_method(root_get(a_idx), "inc", &[]); + let b_result = super::bridge::call_method(root_get(b_idx), "inc", &[]); + roots_truncate(ctor_idx); + assert_eq!(as_num(a_result), 3.0); + assert_eq!(as_num(b_result), 1.0); +} + +#[test] +fn class_expression_named_self_reference() { + // A named class expression sees its own name inside its body, same as a + // named function expression. + let f = dyn_fn(&[r#" + return (class Self { + static describe() { return typeof Self; } + }).describe(); + "#]); + let r = call(f, &[]); + assert_eq!(as_str(r), "function"); +} + +#[test] +fn class_expression_with_extends_is_unsupported() { + let f = dyn_fn(&["return class Sub extends Array {};"]); + let err = catch_throw(|| call(f, &[])).expect_err("extends must throw"); + assert!( + error_message(err).contains("class expression with `extends`"), + "unexpected message: {}", + error_message(err) + ); +} + +#[test] +fn class_expression_getter_is_unsupported() { + let f = dyn_fn(&["return class G { get x() { return 1; } };"]); + let err = catch_throw(|| call(f, &[])).expect_err("getter must throw"); + assert!(error_message(err).contains("getter/setter in class body")); +} + +#[test] +fn class_expression_field_is_unsupported() { + let f = dyn_fn(&["return class F { x = 1; };"]); + let err = catch_throw(|| call(f, &[])).expect_err("class field must throw"); + assert!(error_message(err).contains("class field")); +} + +#[test] +fn class_expression_computed_key_is_unsupported() { + let f = dyn_fn(&[r#" + const k = "m"; + return class C { [k]() { return 1; } }; + "#]); + let err = catch_throw(|| call(f, &[])).expect_err("computed key must throw"); + assert!(error_message(err).contains("computed method name in class body")); +} + +#[test] +fn class_declaration_statement_remains_unsupported() { + // Only the class EXPRESSION form is in scope for #10661; a class + // declaration statement is untouched. + let f = dyn_fn(&["class D {} return D;"]); + let err = catch_throw(|| call(f, &[])).expect_err("class declaration must throw"); + assert!(error_message(err).contains("class declaration")); +} diff --git a/test-files/test_gap_10661_dyn_eval_class_expr.ts b/test-files/test_gap_10661_dyn_eval_class_expr.ts new file mode 100644 index 0000000000..6a46d76af0 --- /dev/null +++ b/test-files/test_gap_10661_dyn_eval_class_expr.ts @@ -0,0 +1,108 @@ +// #10661: Perry's `new Function` runtime interpreter (#6559) did not support +// class expressions, so `mysql2` compiled from source but crashed at runtime +// with "unsupported construct: class expression" — `mysql2`'s row parsers +// are built at runtime by `generate-function` +// (`Function.apply(null, keys.concat(src)).apply(null, vals)`, +// generate-function/index.js:172) and the generated source is a class +// expression. +// +// This mirrors the EXACT shape captured from a live `mysql2` `SELECT` +// against a real server (`lib/parsers/text_parser.js`'s `compile()`): +// +// (function anonymous(wrap, LocalDate) { +// return ((function () { +// return class TextRow { +// constructor(fields) {} +// next(packet, fields, options) { ... } +// }; +// })()) +// }) +// +// plus the rest of the #10661 supported subset (constructor + regular +// instance/static methods, string/numeric keys, named self-reference) — +// everything the class expression form does NOT need (`extends`, +// getters/setters, private members, computed keys, class fields) is +// out of scope and stays untouched by this test. +// +// `genfun()` below is a minimal stand-in for `generate-function`'s own +// `genfun()`: `toFunction` assembles `"return (" + + ")"` and +// runs it through `Function.apply(null, keys.concat(src)).apply(null, vals)` +// — verbatim generate-function/index.js:154-172. Every `gen(...)` chain below +// therefore supplies the BODY of that one implicit `return (...)`, so a chain +// only writes its own `return` when it is inside a nested function scope +// (block 1 and 4's inner IIFE) — never at the outer level, which is exactly +// how mysql2's real `text_parser.js`/`binary_parser.js` codegen is shaped. + +function genfun() { + const lines: string[] = []; + const gen: any = function (line: string) { + lines.push(line); + return gen; + }; + gen.toFunction = function (scope: any) { + const src = "return (" + lines.join("\n") + ")"; + const keys = Object.keys(scope || {}); + const vals = keys.map((key) => scope[key]); + return Function.apply(null, keys.concat(src)).apply(null, vals); + }; + return gen; +} + +// 1. mysql2's row-parser shape verbatim: a nested IIFE returning a class +// expression with a constructor and one instance method. +{ + const gen = genfun(); + gen("(function () {")("return class TextRow {")("constructor(fields) {")( + "this.fields = fields;" + )("}")("next(extra) {")("return this.fields + extra;")("}")("};")("})()"); + const TextRow = gen.toFunction({}); + const row = new TextRow(3); + console.log("mysql2-row-parser", typeof TextRow, row.next(4)); +} + +// 2. A named class expression with constructor + multiple instance methods, +// built through the same `Function.apply` machinery, static method +// referencing the class by its own name, and string/numeric method keys. +{ + const gen = genfun(); + gen("class Counter {")("constructor(start) {")("this.n = start;")("}")( + "inc() {" + )("this.n = this.n + 1;")("return this.n;")("}")("static make(start) {")( + "return new Counter(start);" + )("}")('"label"() {')('return "counter";')("}")("0() {")( + 'return "zero-key";' + )("}")("}"); + const Counter = gen.toFunction({}); + const a = new Counter(10); + const b = Counter.make(100); + console.log("counter-a", a.inc(), a.inc(), a.inc()); + console.log("counter-b", b.inc(), b.inc()); + console.log("counter-a-again", a.inc()); + console.log("counter-label", a["label"]()); + console.log("counter-zero-key", a[0]()); +} + +// 3. No explicit constructor — the default (empty) constructor. +{ + const gen = genfun(); + gen("class Empty {")("set(v) { this.v = v; return this; }")( + "get() { return this.v; }" + )("}"); + const Empty = gen.toFunction({}); + const e = new Empty(); + console.log("empty-ctor", e.set(42).get()); +} + +// 4. A row-parser-shaped class over multiple synthetic fields, matching the +// per-field member assignment `text_parser.js` actually generates. +{ + const gen = genfun(); + gen("(function () {")("return class Row {")("constructor(fields) {")("}")( + "next(values) {" + )("var result = {};")('result["id"] = values[0];')( + 'result["name"] = values[1];' + )("return result;")("}")("};")("})()"); + const Row = gen.toFunction({}); + const row = new Row([1, 2]); + console.log("row-parser-fields", JSON.stringify(row.next([7, "ann"]))); +}