From fbe1889ae282d782482e4d2781d9da15ba64cb97 Mon Sep 17 00:00:00 2001 From: hiett Date: Wed, 15 Jul 2026 07:20:30 +0100 Subject: [PATCH 1/3] js: split this-reading top-level functions into a %this impl + wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plain function's `this` was baked to globalThis at lower time, so it could never be a call-time value. Give every top-level function whose body reads `this` an internal `%this` implementation that takes `this` as an explicit leading parameter, and make the exported `/n` a thin wrapper that forwards the sloppy-mode default `this` (the globalThis singleton). This is behaviour-identical on its own — an ordinary `f(...)` call still runs with `this === globalThis` — but it is the groundwork for letting an iteration method's thisArg or `.call`/`.apply`/`.bind` supply a real receiver: those sites can now target the `%this` impl instead of dropping the receiver. --- src/twocore/frontend/js/lower.gleam | 156 ++++++++++++++++++++++++---- 1 file changed, 136 insertions(+), 20 deletions(-) diff --git a/src/twocore/frontend/js/lower.gleam b/src/twocore/frontend/js/lower.gleam index 48ad0f5..74bd79b 100644 --- a/src/twocore/frontend/js/lower.gleam +++ b/src/twocore/frontend/js/lower.gleam @@ -278,6 +278,14 @@ type Ctx { // top-level functions with a rest param → their FIXED param count (a call bundles // the trailing args into the rest array passed as the last parameter). fn_rest: Dict(String, Int), + // top-level function names whose body reads `this` and which therefore have a + // `%this` implementation taking `this` as an explicit LEADING parameter + // (with `/n` a thin wrapper forwarding the sloppy-mode default `this` = + // globalThis). A call/callback site that supplies a dynamic `this` — an Array + // iteration method's `thisArg`, or `.call`/`.apply`/`.bind` — targets the + // `%this` impl so the receiver reaches the function's `this` (wave 14). A name + // NOT in this set carries no `this` parameter, so a `thisArg` for it is dropped. + this_fns: List(String), classes: Dict(String, ClassInfo), // the superclass name while lowering a class's constructor/methods (for `super`). current_super: Option(String), @@ -370,12 +378,24 @@ pub fn program( } }), ) + // Top-level functions whose body reads `this`: `lower_function` splits each into a + // `%this` impl (taking `this`) plus a wrapper, and a `thisArg` call site can + // target the impl to make the receiver reach the callback's `this` (wave 14). + let this_fns = + list.filter_map(decls, fn(d) { + let #(name, params, body) = d + case function_reads_this(pattern_names_lax(params), block_stmts(body)) { + True -> Ok(name) + False -> Error(Nil) + } + }) let ctx = Ctx( module_name: module_name, funcs: fn_names, fn_arity:, fn_rest:, + this_fns:, classes:, current_super: None, loop: None, @@ -389,12 +409,16 @@ pub fn program( top_level: False, ) - use funcs <- result_try( + use func_pairs <- result_try( list.try_map(decls, fn(d) { let #(name, params, body) = d lower_function(name, params, body, ctx) }), ) + // The exported wrapper (or plain function) of each declaration, plus the internal + // `%this` impls those with a `this` body produced. + let funcs = list.map(func_pairs, fn(p) { p.0 }) + let func_extras = list.flat_map(func_pairs, fn(p) { p.1 }) use main <- result_try(lower_main(top, ctx)) use lambda_funcs <- result_try( list.try_map(dict.values(lambdas), fn(lam) { lower_lambda(lam, ctx) }), @@ -404,10 +428,10 @@ pub fn program( ) let class_funcs = list.flatten(class_fn_lists) - // The named JS functions (exported) plus the internal lifted lambdas + class - // methods (not exported). + // The named JS functions (exported) plus the internal `%this` impls, lifted + // lambdas, and class methods (not exported). let named = [main, ..funcs] - let functions = list.flatten([named, lambda_funcs, class_funcs]) + let functions = list.flatten([named, func_extras, lambda_funcs, class_funcs]) Ok(ir.Module( name: module_name, uses_numerics: True, @@ -826,13 +850,17 @@ fn lower_class( ) }), ) - // static methods are plain functions (no `this`): reuse the top-level function path. - use static_fns <- result_try( + // static methods reuse the top-level function path (`this` inside a static method + // is not modelled beyond the sloppy default, so a `this`-reading static gets the + // wrapper + `%this` impl split like any plain function; its extras are collected). + use static_pairs <- result_try( list.try_map(parts.statics, fn(m) { let #(mname, mparams, mbody) = m lower_function(cname <> "$static$" <> mname, mparams, mbody, cctx) }), ) + let static_fns = list.map(static_pairs, fn(p) { p.0 }) + let static_extras = list.flat_map(static_pairs, fn(p) { p.1 }) // accessors: each getter is `C$get$name(this)`, each setter `C$set$name(this, v)`, // lowered like a method (this-first). A property may have either or both. use accessor_fns <- result_try( @@ -865,7 +893,13 @@ fn lower_class( }), ) Ok( - list.flatten([[ctor_fn], method_fns, static_fns, list.flatten(accessor_fns)]), + list.flatten([ + [ctor_fn], + method_fns, + static_fns, + static_extras, + list.flatten(accessor_fns), + ]), ) } @@ -1670,12 +1704,29 @@ fn transform_generator( Ok(gen_wrap(param_names, locals, st)) } +/// Lower a top-level `function` declaration (also reused for a class's STATIC +/// methods) to its IR function(s). +/// +/// Returns `#(exported, extras)`: `exported` is the `/n` function the module +/// exports and every ordinary call site targets; `extras` is the additional internal +/// functions to add to the module (NOT exported). +/// +/// If the body references `this` (directly, or through a nested arrow/function +/// expression that captures it in this model), the function is split so `this` can be +/// a real call-time value (wave 14): an implementation `%this/(n+1)` takes +/// `this` as an explicit LEADING parameter, and the exported `/n` becomes a thin +/// wrapper that forwards the sloppy-mode default `this` (the `globalThis` singleton, +/// §10.2.1.2) — so a plain `f(…)` call is behaviour-identical to before (its `this` +/// was already `globalThis`). A call site that has a dynamic receiver (an iteration +/// method's `thisArg`, or `.call`/`.apply`/`.bind`) instead targets the `%this` impl, +/// making the receiver reach the function's `this`. If the body never reads `this`, +/// a single unchanged `/n` function is produced (`extras` empty). fn lower_function( name: String, params: List(ast.Pattern), body: ast.Statement, ctx: Ctx, -) -> Result(ir.Function, Error) { +) -> Result(#(ir.Function, List(ir.Function)), Error) { use #(pnames, defaults, rest) <- result_try(param_info(params)) // A rest param is an ordinary trailing parameter bound to an array (the caller // bundles the extra args); no default prologue applies to it. @@ -1683,10 +1734,6 @@ fn lower_function( Some(r) -> list.append(pnames, [r]) None -> pnames } - let env = - list.fold(all_params, dict.new(), fn(acc, n) { - dict.insert(acc, n, ir.Var(n)) - }) let stmts = list.append(default_prologue(defaults), block_stmts(body)) let ctx2 = Ctx( @@ -1694,14 +1741,83 @@ fn lower_function( scope_mutated: assigned_in(stmts), ctor_aliases: ctor_aliases_of(stmts), ) - use #(body_expr, _ctr) <- result_try(lower_body(stmts, env, ctx2, 0)) - Ok(ir.Function( - name: name, - params: list.map(all_params, fn(n) { ir.Local(n, ir.TTerm) }), - result: [ir.TTerm], - locals: [], - body: body_expr, - )) + case function_reads_this(all_params, block_stmts(body)) { + // No `this` in the body — the ordinary single-function lowering (unchanged). + False -> { + let env = + list.fold(all_params, dict.new(), fn(acc, n) { + dict.insert(acc, n, ir.Var(n)) + }) + use #(body_expr, _ctr) <- result_try(lower_body(stmts, env, ctx2, 0)) + Ok( + #( + ir.Function( + name: name, + params: list.map(all_params, fn(n) { ir.Local(n, ir.TTerm) }), + result: [ir.TTerm], + locals: [], + body: body_expr, + ), + [], + ), + ) + } + // The body reads `this` — split into a `this`-taking impl plus an exported + // wrapper that supplies the default `this` (globalThis). + True -> { + let impl_name = this_impl_name(name) + let impl_params = ["this", ..all_params] + let env = + list.fold(impl_params, dict.new(), fn(acc, n) { + dict.insert(acc, n, ir.Var(n)) + }) + use #(body_expr, _ctr) <- result_try(lower_body(stmts, env, ctx2, 0)) + let impl = + ir.Function( + name: impl_name, + params: list.map(impl_params, fn(n) { ir.Local(n, ir.TTerm) }), + result: [ir.TTerm], + locals: [], + body: body_expr, + ) + // `/n (p…) = %this/(n+1)(globalThis, p…)`. + let call = + ir.CallDirect(impl_name, [ir.Var("%gt"), ..list.map(all_params, ir.Var)]) + let wrapper_body = + ir.Let( + ["%gt"], + ir.CallHost("js", "globalthis_new", []), + ir.Let(["%r"], call, ir.Return([ir.Var("%r")])), + ) + let wrapper = + ir.Function( + name: name, + params: list.map(all_params, fn(n) { ir.Local(n, ir.TTerm) }), + result: [ir.TTerm], + locals: [], + body: wrapper_body, + ) + Ok(#(wrapper, [impl])) + } + } +} + +/// The internal implementation name for a `this`-taking function: `%this`. The +/// `%` byte can never appear in a JS identifier (nor in a lifted class/lambda name, +/// which use `$`), so this never collides with a user function or a generated name. +fn this_impl_name(name: String) -> String { + name <> "%this" +} + +/// True when a function body references `this`. In this compiler's model a nested +/// arrow OR function expression captures `this` lexically, so a `this` anywhere in the +/// body (including inside such nested functions) makes the enclosing function's `this` +/// observable — exactly the free-variable test `fv_lambda` already computes. +fn function_reads_this( + params: List(String), + body: List(ast.Statement), +) -> Bool { + list.contains(fv_lambda(params, body), "this") } fn lower_main( From 01dd0119fd90943e52ba93bdaefe7e68821e5fe5 Mon Sep 17 00:00:00 2001 From: hiett Date: Wed, 15 Jul 2026 07:42:16 +0100 Subject: [PATCH 2/3] js: thread a real thisArg to named callbacks and .call/.apply/.bind Now that a this-reading top-level function has a %this implementation taking this as a leading parameter, wire the sites that supply a dynamic receiver to target it: - an Array iteration method (map/filter/forEach/some/every/find*) called with a NAMED callback + thisArg bakes the thisArg as the callback's this; - f.call(thisArg, ...args) becomes a direct call of f%this with the receiver; - f.apply(thisArg, argArray) applies a this-baked closure over f%this to the array's elements (via the now-exported apply_arg_list); - f.bind(thisArg, ...bound) returns a closure with this fixed to thisArg. An arrow, a this-less function, or a plain-function VALUE variable is not matched and keeps the prior behaviour (arrows correctly ignore a thisArg; a value variable's thisArg is still dropped, a documented v1 gap). apply_arg_list is exported from the runtime and registered in the emit js-op allowlist so the apply path can reach it. --- src/twocore/backend/emit_core.gleam | 1 + src/twocore/frontend/js/lower.gleam | 191 ++++++++++++++++++++++++++-- src/twocore/runtime/rt_js.gleam | 7 + src/twocore_rt_js_ffi.erl | 6 +- test/js_compiler_test.gleam | 118 +++++++++++++++++ 5 files changed, 312 insertions(+), 11 deletions(-) diff --git a/src/twocore/backend/emit_core.gleam b/src/twocore/backend/emit_core.gleam index 0a95038..3905a1e 100644 --- a/src/twocore/backend/emit_core.gleam +++ b/src/twocore/backend/emit_core.gleam @@ -4245,6 +4245,7 @@ fn resolve_js(op: String) -> Option(String) { "func_bind" -> Some("func_bind") "fit_list" -> Some("fit_list") "array_to_list" -> Some("array_to_list") + "apply_arg_list" -> Some("apply_arg_list") "array_from" -> Some("array_from") "array_from_map" -> Some("array_from_map") "array_flat" -> Some("array_flat") diff --git a/src/twocore/frontend/js/lower.gleam b/src/twocore/frontend/js/lower.gleam index 74bd79b..b29006e 100644 --- a/src/twocore/frontend/js/lower.gleam +++ b/src/twocore/frontend/js/lower.gleam @@ -5696,13 +5696,33 @@ fn lower_instance_method( ctx: Ctx, ctr: Int, ) -> Result(#(List(Bind), ir.Value, Int), Error) { - case method, unbound_proto_method(object), arguments { - "call", Some(m), [recv, ..rest] -> - lower_instance_method(recv, m, rest, env, ctx, ctr) - "apply", Some(m), [recv, argarr] -> - case array_literal_elements(argarr) { - Some(elems) -> lower_instance_method(recv, m, elems, env, ctx, ctr) - None -> + // `f.call/apply/bind(thisArg, …)` where `f` is a top-level function that reads + // `this` (wave 14): dispatch to `f%this` with the supplied `thisArg` as the + // function's `this`, per §20.2.3.1/.2/.3. A function that ignores `this`, or a + // `.call`/`.apply` through a plain-function VALUE variable, is not matched here and + // keeps the runtime `func_call`/`func_apply` path (which drops the `thisArg`). + case named_this_fn(object, env, ctx), method { + Some(fname), "call" -> lower_named_call(fname, arguments, env, ctx, ctr) + Some(fname), "apply" -> lower_named_apply(fname, arguments, env, ctx, ctr) + Some(fname), "bind" -> lower_named_bind(fname, arguments, env, ctx, ctr) + _, _ -> + case method, unbound_proto_method(object), arguments { + "call", Some(m), [recv, ..rest] -> + lower_instance_method(recv, m, rest, env, ctx, ctr) + "apply", Some(m), [recv, argarr] -> + case array_literal_elements(argarr) { + Some(elems) -> lower_instance_method(recv, m, elems, env, ctx, ctr) + None -> + lower_instance_method_thisarg( + object, + method, + arguments, + env, + ctx, + ctr, + ) + } + _, _, _ -> lower_instance_method_thisarg( object, method, @@ -5712,11 +5732,94 @@ fn lower_instance_method( ctr, ) } - _, _, _ -> - lower_instance_method_thisarg(object, method, arguments, env, ctx, ctr) } } +/// `f.call(thisArg, …args)` on a top-level function `fname` reading `this` — a direct +/// call of `fname%this` with `thisArg` first and the remaining arguments fitted to the +/// function's declared arity (missing → `undefined`, extras dropped), per §20.2.3.3. +fn lower_named_call( + fname: String, + arguments: List(ast.Expression), + env: Env, + ctx: Ctx, + ctr: Int, +) -> Result(#(List(Bind), ir.Value, Int), Error) { + use #(ba, argvals, ctr) <- result_try(lower_args(arguments, env, ctx, ctr)) + let #(tv, callargs) = case argvals { + [t, ..rest] -> #(t, rest) + [] -> #(undefined(), []) + } + Ok(bind_after( + ba, + ir.CallDirect(this_impl_name(fname), [ + tv, + ..fit_args(callargs, this_fn_arity(ctx, fname)) + ]), + ctr, + )) +} + +/// `f.apply(thisArg, argArray)` on a top-level function `fname` reading `this` +/// (§20.2.3.1). Builds a `this`-baked closure over `fname%this` and applies it to the +/// elements of `argArray` (a `null`/`undefined`/absent array → no arguments), so the +/// call runs with `this === thisArg` regardless of whether `argArray` is a literal or a +/// runtime value. +fn lower_named_apply( + fname: String, + arguments: List(ast.Expression), + env: Env, + ctx: Ctx, + ctr: Int, +) -> Result(#(List(Bind), ir.Value, Int), Error) { + use #(ba, argvals, ctr) <- result_try(lower_args(arguments, env, ctx, ctr)) + let #(tv, argarr) = case argvals { + [t, a, ..] -> #(t, a) + [t] -> #(t, undefined()) + [] -> #(undefined(), undefined()) + } + let #(bcl, cbv, ctr) = + bind_after( + ba, + ir.MakeClosure(this_impl_name(fname), [tv], this_fn_arity(ctx, fname)), + ctr, + ) + // CreateListFromArrayLike: null/undefined → []; an array → its elements. + let #(bl, listv, ctr) = + bind_after(bcl, ir.CallHost("js", "apply_arg_list", [argarr]), ctr) + Ok(bind_after(bl, ir.CallHost("js", "apply_fn", [cbv, listv]), ctr)) +} + +/// `f.bind(thisArg, …boundArgs)` on a top-level function `fname` reading `this` +/// (§20.2.3.2). Returns a closure over `fname%this` whose captured leading argument is +/// `thisArg` followed by the bound-argument prefix, so a later call prepends nothing +/// more than its own arguments. `this` is therefore FIXED to `thisArg` for the bound +/// function. Bound arguments beyond the function's arity are dropped (the function +/// ignores them); the bound function carries no `length`/`name`/`prototype` (a v1 gap). +fn lower_named_bind( + fname: String, + arguments: List(ast.Expression), + env: Env, + ctx: Ctx, + ctr: Int, +) -> Result(#(List(Bind), ir.Value, Int), Error) { + use #(ba, argvals, ctr) <- result_try(lower_args(arguments, env, ctx, ctr)) + let #(tv, boundargs) = case argvals { + [t, ..rest] -> #(t, rest) + [] -> #(undefined(), []) + } + let arity = this_fn_arity(ctx, fname) + // Cap the captured bound args to the function's arity so the `MakeClosure` capture + // count + remaining arity stays exactly the impl's `this`+params arity. + let bound_capped = list.take(boundargs, arity) + let remaining = arity - list.length(bound_capped) + Ok(bind_after( + ba, + ir.MakeClosure(this_impl_name(fname), [tv, ..bound_capped], remaining), + ctr, + )) +} + /// The runtime `(Recv, Fn)` op backing an Array iteration method that accepts a /// `thisArg` — the family §23.1.3.* whose callback is called with `this === thisArg`. /// `Some(op)` for a recognized iteration method; `None` otherwise. Callbacks run @@ -5777,11 +5880,81 @@ fn lower_instance_method_thisarg( ctr, ) } + // A NAMED callback that is a top-level function reading `this` (wave 14): bake the + // `thisArg` as the `this` argument of its `%this` implementation, so the callback + // runs with `this === thisArg` (§23.1.3.*). An arrow / this-less callback, or a + // callback that is a local value, is not matched here and keeps the generic path + // (which correctly ignores the `thisArg`). + Some(op), [callback, thisarg] -> + case named_this_fn(callback, env, ctx) { + Some(fname) -> + lower_named_this_iter(object, op, fname, thisarg, env, ctx, ctr) + None -> + lower_instance_method_generic( + object, + method, + arguments, + env, + ctx, + ctr, + ) + } _, _ -> lower_instance_method_generic(object, method, arguments, env, ctx, ctr) } } +/// `Some(name)` when `object` is a bare reference to a top-level function that has a +/// `%this` implementation (its body reads `this`) and is NOT shadowed by a local +/// binding — the case where a dynamic receiver can be threaded to the function's +/// `this` by targeting `%this`. `None` otherwise (an arrow, a local value, or a +/// function that ignores `this`). +fn named_this_fn(object: ast.Expression, env: Env, ctx: Ctx) -> Option(String) { + case object { + ast.Identifier(name:, ..) -> + case !dict.has_key(env, name) && list.contains(ctx.this_fns, name) { + True -> Some(name) + False -> None + } + _ -> None + } +} + +/// The JS user-parameter count of top-level function `name` (0 if unknown) — the +/// arity a callback closure / `.call`-style dispatch fits its arguments to. The +/// `%this` implementation has one MORE parameter (the leading `this`). +fn this_fn_arity(ctx: Ctx, name: String) -> Int { + case dict.get(ctx.fn_arity, name) { + Ok(a) -> a + Error(Nil) -> 0 + } +} + +/// Lower `recv.(namedFn, thisArg)` where `namedFn` is a top-level function that +/// reads `this`: build a zero-argument-adaptive closure over `namedFn%this` whose +/// captured leading argument is `thisArg`, so each callback invocation runs with +/// `this === thisArg`. The closure is then handed to the ordinary `(Recv, Fn)` +/// iteration op unchanged. +fn lower_named_this_iter( + object: ast.Expression, + op: String, + fname: String, + thisarg: ast.Expression, + env: Env, + ctx: Ctx, + ctr: Int, +) -> Result(#(List(Bind), ir.Value, Int), Error) { + use #(bo, recv, ctr) <- result_try(lower_expr(object, env, ctx, ctr)) + use #(bt, tv, ctr) <- result_try(lower_expr(thisarg, env, ctx, ctr)) + let #(bc, cbv, ctr) = + bind_after( + list.append(bo, bt), + ir.MakeClosure(this_impl_name(fname), [tv], this_fn_arity(ctx, fname)), + ctr, + ) + Ok(bind_after(bc, ir.CallHost("js", op, [recv, cbv]), ctr)) +} + /// True when the collected callback lambda at `span` reads `this` (so a `thisArg` is /// observable and worth binding). `False` for an uncollected span or a callback that /// ignores `this` — in which case the `thisArg` is irrelevant and the generic path diff --git a/src/twocore/runtime/rt_js.gleam b/src/twocore/runtime/rt_js.gleam index dbaeebd..8766c9d 100644 --- a/src/twocore/runtime/rt_js.gleam +++ b/src/twocore/runtime/rt_js.gleam @@ -451,6 +451,13 @@ pub fn fit_list(args: Dynamic, n: Dynamic) -> Dynamic @external(erlang, "twocore_rt_js_ffi", "array_to_list") pub fn array_to_list(arr: Dynamic) -> Dynamic +/// CreateListFromArrayLike for `Function.prototype.apply` (§20.2.3.1): a +/// null/undefined/absent argument array yields no arguments, an array yields its +/// elements in order. Behind a `f.apply(thisArg, argArray)` on a user function whose +/// `this`-baked callback closure is then applied to these arguments (wave 14). +@external(erlang, "twocore_rt_js_ffi", "apply_arg_list") +pub fn apply_arg_list(arr: Dynamic) -> Dynamic + /// `Array.from(x)` — a new array from an array/string/array-like. @external(erlang, "twocore_rt_js_ffi", "array_from") pub fn array_from(x: Dynamic) -> Dynamic diff --git a/src/twocore_rt_js_ffi.erl b/src/twocore_rt_js_ffi.erl index 9d09766..7873c10 100644 --- a/src/twocore_rt_js_ffi.erl +++ b/src/twocore_rt_js_ffi.erl @@ -68,7 +68,7 @@ static_get/2, static_get_chain/2, static_set/3, has_prop/2, delete_prop/2, new_array/1, array_construct/1, array_push/2, array_pop/1, is_array/1, array_spread_into/2, array_from/1, array_from_map/2, array_flat/2, array_fill/4, array_copy_within/4, array_splice/2, array_at/2, array_proto_fn/1, - apply_fn/2, func_call/2, func_apply/2, func_bind/2, fit_list/2, array_to_list/1, + apply_fn/2, func_call/2, func_apply/2, func_bind/2, fit_list/2, array_to_list/1, apply_arg_list/1, str_pad_start/3, str_pad_end/3, string_from_char_code/1, string_from_code_point/1, string_raw/2, date_now/0, date_new/1, date_utc/1, date_parse/1, date_call/3, @@ -4924,7 +4924,9 @@ make_bound(F, Bound, _) -> fun() -> call_cb(F, Bound) end. %% CreateListFromArrayLike for Function.prototype.apply: null/undefined → no args; -%% otherwise the array's elements in order. +%% otherwise the array's elements in order. Exported so the JS lowering can turn a +%% `f.apply(thisArg, argArray)` on a user function into an `apply_fn` over the +%% `this`-baked callback closure (wave 14). apply_arg_list(undefined) -> []; apply_arg_list(null) -> []; apply_arg_list(Arr) -> array_to_list(Arr). diff --git a/test/js_compiler_test.gleam b/test/js_compiler_test.gleam index 063a5ce..f455b5a 100644 --- a/test/js_compiler_test.gleam +++ b/test/js_compiler_test.gleam @@ -7210,3 +7210,121 @@ pub fn function_bind_typeof_test() { ) |> should.equal(1.0) } + +// ==== this parameter (wave 14) ==== + +// §23.1.3.19 Array.prototype.map with an INLINE function callback + object thisArg: +// the callback runs with `this === thisArg`, so each element maps to `thisArg.k`. +pub fn wave14_map_inline_thisarg_test() { + val("[1, 2, 3].map(function () { return this.k; }, { k: 9 }).join(',')") + |> should.equal(dyn("9,9,9")) +} + +// §23.1.3.15 Array.prototype.forEach with an inline callback + object thisArg mutates +// the thisArg: `this.n++` over [1,2] into { n: 0 } leaves n === 2. +pub fn wave14_foreach_inline_thisarg_mutates_test() { + let m = + compile( + "function f() { var o = { n: 0 }; [1, 2].forEach(function () { this.n++; }, o); return o.n; }", + ) + to_float(call(m, "f", [])) |> should.equal(2.0) +} + +// §23.1.3.15 forEach with a NAMED top-level callback + object thisArg (the test262 +// `*-5-*` shape): the callback runs with `this === thisArg`. Mirrors +// Array/prototype/forEach/15.4.4.18-5-9. +pub fn wave14_foreach_named_thisarg_identity_test() { + let m = + compile( + "var result = false; var o = {}; function cb(val, idx, obj) { result = this === o; } function run() { [11].forEach(cb, o); return result ? 1 : 0; }", + ) + to_float(call(m, "run", [])) |> should.equal(1.0) +} + +// §23.1.3.19 map with a NAMED callback + object thisArg reads through `this`: each +// element maps to `this.base + element`. +pub fn wave14_map_named_thisarg_test() { + let m = + compile( + "function addBase(x) { return this.base + x; } function run() { return [1, 2, 3].map(addBase, { base: 10 }).join(','); }", + ) + call(m, "run", []) |> should.equal(dyn("11,12,13")) +} + +// §20.2.3.3 Function.prototype.call: a NAMED top-level function invoked with a real +// thisArg runs with that receiver as `this`. `f.call({ v: 5 })` → 5. +pub fn wave14_named_call_thisarg_test() { + let m = + compile( + "function getv() { return this.v; } function run() { return getv.call({ v: 5 }); }", + ) + to_float(call(m, "run", [])) |> should.equal(5.0) +} + +// §20.2.3.3 call forwards arguments AFTER the thisArg: `add.call({ base: 100 }, 5)` +// runs with `this.base === 100` and the first call argument 5 → 105. +pub fn wave14_named_call_thisarg_and_args_test() { + let m = + compile( + "function add(x) { return this.base + x; } function run() { return add.call({ base: 100 }, 5); }", + ) + to_float(call(m, "run", [])) |> should.equal(105.0) +} + +// §20.2.3.1 Function.prototype.apply on a NAMED function: the (absent) argument array +// yields no arguments, and `this` is the supplied receiver. `f.apply({ v: 6 })` → 6. +pub fn wave14_named_apply_thisarg_test() { + let m = + compile( + "function getv() { return this.v; } function run() { return getv.apply({ v: 6 }); }", + ) + to_float(call(m, "run", [])) |> should.equal(6.0) +} + +// §20.2.3.1 apply spreads its argument array after the thisArg: `add.apply({ base: 1 +// }, [2, 3])` runs with `this.base === 1` and arguments 2, 3 → 1 + 2 + 3 === 6. +pub fn wave14_named_apply_thisarg_args_test() { + let m = + compile( + "function add(a, b) { return this.base + a + b; } function run() { return add.apply({ base: 1 }, [2, 3]); }", + ) + to_float(call(m, "run", [])) |> should.equal(6.0) +} + +// §20.2.3.2 Function.prototype.bind fixes `this`: the bound function runs with the +// bound receiver no matter how it is later called. `getv.bind({ v: 8 })()` → 8. +pub fn wave14_named_bind_thisarg_test() { + let m = + compile( + "function getv() { return this.v; } function run() { var g = getv.bind({ v: 8 }); return g(); }", + ) + to_float(call(m, "run", [])) |> should.equal(8.0) +} + +// An ARROW inside a class method reads the METHOD's `this` lexically (it does NOT get +// its own `this`), so it sees the instance field the constructor set. +pub fn wave14_arrow_in_method_sees_method_this_test() { + let m = + compile( + "class C { constructor() { this.n = 7; } run() { var f = () => this.n; return f(); } } function run() { return new C().run(); }", + ) + to_float(call(m, "run", [])) |> should.equal(7.0) +} + +// A directly-called top-level function's `this` is unchanged from before wave 14: the +// sloppy-mode global object, so `this === globalThis`. No regression for a plain call. +pub fn wave14_plain_direct_call_this_is_global_test() { + let m = + compile( + "function f() { return this === globalThis ? 1 : 0; } function run() { return f(); }", + ) + to_float(call(m, "run", [])) |> should.equal(1.0) +} + +// An ARROW callback ignores a thisArg (its `this` is lexical), so a top-level arrow's +// `this` stays globalThis and the thisArg's `k` is NOT observed — the arrow just +// doubles. Confirms the wave-14 named-callback path does not hijack arrows. +pub fn wave14_arrow_callback_ignores_thisarg_test() { + val("[1, 2, 3].map((x) => x * 2, { k: 9 }).join(',')") + |> should.equal(dyn("2,4,6")) +} From 61cff303afa222e727426bd30eefb1c2e6c45d52 Mon Sep 17 00:00:00 2001 From: hiett Date: Wed, 15 Jul 2026 07:50:51 +0100 Subject: [PATCH 3/3] js: document the wave-14 this-parameter model and remaining gaps Update the module header and the call/apply/iteration dispatch docs to describe the new %this-impl-plus-wrapper lowering, which sites thread a dynamic thisArg, and what is still deferred (a thisArg for a callback held in a variable, and sloppy-mode this->global / primitive-this boxing). --- src/twocore/frontend/js/lower.gleam | 68 ++++++++++++++++------------- 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/src/twocore/frontend/js/lower.gleam b/src/twocore/frontend/js/lower.gleam index b29006e..be1f19d 100644 --- a/src/twocore/frontend/js/lower.gleam +++ b/src/twocore/frontend/js/lower.gleam @@ -185,15 +185,21 @@ //// non-deterministic case test may run when JS would not have reached it. //// * A `break`/`continue` to a label attached to a `switch` or a plain block that is //// nested OUTSIDE a loop targets the wrong construct; labels on loops work correctly. -//// * The `thisArg` of an Array iteration method (`map`/`filter`/`forEach`/`some`/ -//// `every`/`find`/`findIndex`/`findLast`/`findLastIndex`) is honored ONLY when the -//// callback is an INLINE `function () {…}` expression (its `this` capture is bound to -//// the `thisArg`, like an object accessor's). A callback passed by NAME (a top-level -//// function reference) ignores the `thisArg`: a top-level function carries no `this` -//// parameter (its `this` resolves to `globalThis`), so binding a dynamic receiver -//// would need `this` to become a call-time parameter of every plain function — a -//// larger ABI change not yet made. An ARROW callback correctly ignores `thisArg` -//// (lexical `this`). +//// * `this` as a call-time value (wave 14): a top-level `function` whose body reads +//// `this` is lowered as a `%this` impl that takes `this` as an explicit +//// LEADING parameter, with the exported `/n` a thin wrapper forwarding the +//// sloppy-mode default (`globalThis`). So the `thisArg` of an Array iteration +//// method (`map`/`filter`/`forEach`/`some`/`every`/`find`/`findIndex`/`findLast`/ +//// `findLastIndex`) IS honored both for an INLINE `function () {…}` callback (its +//// `this` capture is bound to the `thisArg`, like an object accessor's) AND for a +//// callback passed by NAME (the `thisArg` is baked as the `%this` argument of the +//// named function's impl). `f.call(t, …)` / `f.apply(t, …)` / `f.bind(t, …)` on a +//// named top-level function likewise thread `t` as its `this`. An ARROW callback +//// correctly ignores `thisArg` (lexical `this`). STILL DROPPED (a v1 gap needing a +//// value-level receiver): a `thisArg` for a callback held in a VARIABLE (a +//// function-expression value, or `let g = f; g` / `g.call(t)`) — the value carries +//// no overridable `this` — and sloppy-mode `this`→global boxing / primitive-`this` +//// boxing for a directly-invoked function (its default `this` is `globalThis`). import arc/parser/ast import gleam/dict.{type Dict} @@ -5677,15 +5683,18 @@ fn array_literal_elements(e: ast.Expression) -> Option(List(ast.Expression)) { } } -/// Dispatch an instance/method call, first intercepting a generic +/// Dispatch an instance/method call. First intercepts `f.call/apply/bind(thisArg, …)` +/// where `f` is a top-level function that reads `this` (wave 14): the `thisArg` is +/// threaded to `f`'s `%this` implementation so the receiver reaches its `this` (see +/// `lower_named_call`/`lower_named_apply`/`lower_named_bind`). Then intercepts a generic /// `.prototype..call(recv, …args)` / `.apply(recv, [args])` — an unbound /// built-in prototype method invoked with an explicit receiver. Per §20.2.3.3/.1 the /// unbound method must run with `recv` as its `this`, which in this receiver-first /// model is exactly the ordinary method call `recv.(…args)`; rewriting to it reuses -/// the full method-dispatch machinery and forwards the receiver correctly. A user -/// function's `.call`/`.apply`, or an unbound value that is not a static -/// `.prototype.` reference, falls through to the runtime `func_call`/ -/// `func_apply` path (which — as documented — drops the plain-function `thisArg`). +/// the full method-dispatch machinery and forwards the receiver correctly. A `.call`/ +/// `.apply` through a plain-function VALUE variable, or an unbound value that is not a +/// static `.prototype.` reference, falls through to the runtime `func_call`/ +/// `func_apply` path (which — as documented — drops that value's `thisArg`, a v1 gap). /// `.apply` is only unrolled when its argument array is a literal; otherwise it too /// falls through. fn lower_instance_method( @@ -5841,23 +5850,22 @@ fn array_this_iter_op(method: String) -> Option(String) { } } -/// Bucket 1 (partial): thread the `thisArg` of an Array iteration method into an -/// INLINE `function` callback that reads `this`. A regular function expression's -/// `this` is a lexical capture in this model (like an arrow's), so — exactly as an -/// object getter/setter binds `this` to the object at install -/// (`lower_obj_accessor_closure`) — we bind the callback closure's `this` capture to -/// the `thisArg` value instead of the enclosing `this`. The callback is then passed to -/// the ordinary `(Recv, Fn)` iteration op unchanged. +/// Thread the `thisArg` of an Array iteration method into a callback that reads +/// `this`, for the two shapes that carry a bindable receiver: +/// * an INLINE `function () {…}` callback — its `this` is a lexical capture in this +/// model, so (exactly as an object getter/setter binds `this` to the object at +/// install, `lower_obj_accessor_closure`) we bind the callback closure's `this` +/// capture to the `thisArg` value instead of the enclosing `this`; +/// * a callback passed by NAME that is a top-level function reading `this` (wave 14) — +/// we bake the `thisArg` as the `this` argument of the function's `%this` impl (the +/// shape most test262 `this-arg` files use, e.g. `arr.forEach(callbackfn, thisArg)`). +/// The resulting callback closure is handed to the ordinary `(Recv, Fn)` iteration op +/// unchanged. /// -/// SCOPE: this only covers an INLINE `function () {…}` callback (NOT an arrow, whose -/// `this` is always lexical per spec, and NOT a callback passed by NAME). A `thisArg` -/// for a named top-level-function callback (`arr.map(callbackfn, thisArg)` — the shape -/// most test262 `this-arg` files use) is NOT handled: a top-level function carries no -/// `this` parameter (its `this` resolves to `globalThis` at lower time), so binding a -/// dynamic `thisArg` would require making `this` a call-time parameter of every plain -/// function — a cross-cutting ABI change deferred to a future wave. Anything not -/// matching the inline-function shape falls through to the generic dispatch (which -/// drops the `thisArg`, the documented v1 behavior). +/// SCOPE: an ARROW callback is not matched (its `this` is always lexical per spec, so +/// the `thisArg` is correctly ignored). A callback held in a VARIABLE (a +/// function-expression value) also falls through to the generic dispatch, which drops +/// the `thisArg` — a v1 gap (a value carries no overridable `this`). fn lower_instance_method_thisarg( object: ast.Expression, method: String,