diff --git a/src/twocore_rt_js_ffi.erl b/src/twocore_rt_js_ffi.erl index 7873c10..b8ea9ab 100644 --- a/src/twocore_rt_js_ffi.erl +++ b/src/twocore_rt_js_ffi.erl @@ -6180,7 +6180,7 @@ str_slice(Str, Start, End) -> %% str.substring(start?, end?) — clamps negatives to 0 and swaps if start > end. str_substring(Str, Start, End) -> - Cps = cps(Str), + Cps = cps(str_this(Str)), Len = length(Cps), S0 = sub_index(Start, Len, 0), E0 = sub_index(End, Len, Len), @@ -6227,9 +6227,13 @@ str_split(Str0, Sep, Limit) -> %% Object]"). This is what makes `String.prototype.trim.call(0)` yield "0", %% `.call(false)` yield "false", and `.call([1])` yield "1" once a generic `.call` %% forwards the receiver here. +%% A Symbol receiver is a TypeError: ToString of a Symbol is abrupt (§7.1.17, +%% ToString step 2), so `String.prototype.substring.call(Symbol())` throws. str_this(undefined) -> type_error(undefined); str_this(null) -> type_error(null); str_this(V) when is_binary(V) -> V; +str_this({js_symbol, _, _}) -> + type_error(<<"Cannot convert a Symbol value to a string">>); str_this(V) -> to_string(V). %% ToUint32(limit) with `undefined` mapped to the spec's 2^32-1 default. NaN and @@ -6323,8 +6327,9 @@ str_repeat(Str, N) -> %% ToIntegerOrInfinity clamped to [0, len] (undefined/NaN → 0). Returns false %% when `position` + prefix length would run past the end of the string. str_starts_with(Str, Prefix, Pos) -> + S = str_this(Str), P = to_string(Prefix), - Cps = cps(Str), + Cps = cps(S), Len = length(Cps), Start = str_pos_clamp(Pos, Len), PLen = length(cps(P)), @@ -6338,8 +6343,9 @@ str_starts_with(Str, Prefix, Pos) -> %% is undefined it defaults to len; otherwise it is ToIntegerOrInfinity clamped %% to [0, len]. Returns false when the suffix would start before index 0. str_ends_with(Str, Suffix, EndPos) -> + Recv = str_this(Str), S = to_string(Suffix), - Cps = cps(Str), + Cps = cps(Recv), Len = length(Cps), End = case EndPos of diff --git a/test/js_compiler_test.gleam b/test/js_compiler_test.gleam index f455b5a..67022ec 100644 --- a/test/js_compiler_test.gleam +++ b/test/js_compiler_test.gleam @@ -7328,3 +7328,58 @@ 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")) } + +// ==== String (wave 15) ==== + +/// §22.1.3.22/.7/.24 RequireObjectCoercible: a String.prototype method invoked +/// with a `null`/`undefined` `this` (via `.call`) throws a TypeError before any +/// argument is inspected. Encodes test262 startsWith/endsWith `this-is-null` and +/// `this-is-undefined`. +pub fn string_method_this_not_coercible_throws_test() { + let cases = [ + "String.prototype.startsWith.call(null, '')", + "String.prototype.startsWith.call(undefined, '')", + "String.prototype.endsWith.call(null, '')", + "String.prototype.endsWith.call(undefined, '')", + "String.prototype.substring.call(null)", + "String.prototype.substring.call(undefined)", + ] + list.each(cases, fn(expr) { + let m = compile("function f() { return " <> expr <> "; }") + let threw = case catch_apply(m, atom.create("f"), []) { + Error(_) -> True + Ok(_) -> False + } + threw |> should.equal(True) + }) +} + +/// ToString(this) is abrupt when `this` is a Symbol (§7.1.17 step 2), so a +/// String.prototype method called on a Symbol receiver throws a TypeError. +/// Encodes test262 substring/startsWith/endsWith `this-value-tostring-throws-symbol` +/// and `return-abrupt-from-this-as-symbol`. Asserts the thrown value is a +/// TypeError, not merely that it throws. +pub fn string_method_symbol_this_throws_typeerror_test() { + let cases = [ + "String.prototype.substring.call(Symbol())", + "String.prototype.startsWith.call(Symbol(), '')", + "String.prototype.endsWith.call(Symbol(), '')", + ] + list.each(cases, fn(expr) { + let m = + compile( + "function f() { try { " + <> expr + <> "; return \"no throw\"; } catch (e) { return e.name; } }", + ) + call(m, "f", []) |> should.equal(dyn("TypeError")) + }) +} + +/// A well-formed string receiver still produces the ordinary result — the +/// RequireObjectCoercible/ToString guard does not perturb the normal path. +pub fn string_method_this_guard_preserves_normal_test() { + val("'hello'.startsWith('he') ? 'y' : 'n'") |> should.equal(dyn("y")) + val("'hello'.endsWith('lo') ? 'y' : 'n'") |> should.equal(dyn("y")) + val("'hello'.substring(1, 3)") |> should.equal(dyn("el")) +}