Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Compilation/RuntimeEmitter.Json.Stringify.cs
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,18 @@ private MethodBuilder EmitJsonStringifyHelper(TypeBuilder typeBuilder, EmittedRu
il.Emit(OpCodes.Isinst, _types.DictionaryStringObject);
il.Emit(OpCodes.Brfalse, notBoxedPrim);
il.MarkLabel(doBoxedUnwrap);
// #565: only a genuine boxed wrapper (carrying a string __primitiveType tag)
// is unwrapped — an ordinary object that merely has a __primitiveValue field
// must serialize as a normal object, matching the interpreter and
// RuntimeEmitter.Json.StringifyFull.cs.
var boxedPrimType = il.DeclareLocal(_types.Object);
il.Emit(OpCodes.Ldloc, valueLocal);
il.Emit(OpCodes.Ldstr, "__primitiveType");
il.Emit(OpCodes.Call, runtime.GetProperty);
il.Emit(OpCodes.Stloc, boxedPrimType);
il.Emit(OpCodes.Ldloc, boxedPrimType);
il.Emit(OpCodes.Isinst, _types.String);
il.Emit(OpCodes.Brfalse, notBoxedPrim);
var boxedPrimVal = il.DeclareLocal(_types.Object);
il.Emit(OpCodes.Ldloc, valueLocal);
il.Emit(OpCodes.Ldstr, "__primitiveValue");
Expand Down
3 changes: 2 additions & 1 deletion Execution/Interpreter.Async.cs
Original file line number Diff line number Diff line change
Expand Up @@ -913,7 +913,8 @@ private async Task<RuntimeValue> EvaluateTemplateLiteralAsync(Expr.TemplateLiter
var evaluatedExprs = new List<object?>();
foreach (var expr in template.Expressions)
{
evaluatedExprs.Add((await EvaluateAsync(expr)).ToObject());
// ToPrimitive (string hint) so boxed wrappers render their primitive (#708).
evaluatedExprs.Add(ToPrimitive((await EvaluateAsync(expr)).ToObject(), PrimitiveHint.String));
}
return RuntimeValue.FromString(BuildTemplateLiteralString(template.Strings, evaluatedExprs));
}
Expand Down
6 changes: 5 additions & 1 deletion Execution/Interpreter.Expressions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,11 @@ private RuntimeValue EvaluateLiteral(Expr.Literal literal)
/// <seealso href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals">MDN Template Literals</seealso>
private RuntimeValue EvaluateTemplateLiteral(Expr.TemplateLiteral template)
{
var evaluatedExprs = template.Expressions.Select(Evaluate).ToList();
// ToString coercion of an interpolated value goes through ToPrimitive
// (string hint), so a boxed wrapper / object with own toString renders
// its primitive (`${new String("ab")}` → "ab") rather than "[object Object]" (#708).
var evaluatedExprs = template.Expressions
.Select(e => ToPrimitive(Evaluate(e), PrimitiveHint.String)).ToList();
return RuntimeValue.FromString(BuildTemplateLiteralString(template.Strings, evaluatedExprs));
}

Expand Down
128 changes: 128 additions & 0 deletions Execution/Interpreter.Operators.cs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,13 @@ private Task<RuntimeValue> EvaluatePostfixIncrementAsync(Expr.PostfixIncrement p
/// <seealso href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Addition">MDN Addition Operator</seealso>
private object EvaluatePlus(object? left, object? right)
{
// ECMA-262 §13.15.3 ApplyStringOrNumericBinaryOperator: ToPrimitive both
// operands (default hint) before deciding string-concat vs numeric add.
// Reduces boxed Number/String/Boolean wrappers (and objects with an own
// valueOf/toString) to their primitive so `"x" + new Number(5)` → "x5"
// and `new Number(5) + 1` → 6 (#708). No-op for other values.
left = ToPrimitive(left, PrimitiveHint.Default);
right = ToPrimitive(right, PrimitiveHint.Default);
if (left is double l && right is double r) return l + r;
if (left is string || right is string)
{
Expand Down Expand Up @@ -371,6 +378,14 @@ private RuntimeValue EvaluateUnaryOperationRV(Token op, RuntimeValue rv)
/// </summary>
private static double CoerceToNumber(object? value) => CoerceToNumber(RuntimeValue.FromBoxed(value));

/// <summary>
/// JS ToNumber for a boxed value, unwrapping a boxed Number/String/Boolean
/// wrapper to its primitive first (#708). Exposed for numeric built-ins
/// (e.g. <c>String.fromCharCode</c>) that otherwise hard-crash on a wrapper
/// argument via <see cref="RuntimeValue.AsNumber"/>.
/// </summary>
internal static double ToNumber(object? value) => CoerceToNumber(RuntimeValue.FromBoxed(value));

private static double CoerceToNumber(RuntimeValue rv)
{
if (rv.IsNumber) return rv.AsNumber();
Expand All @@ -386,9 +401,110 @@ private static double CoerceToNumber(RuntimeValue rv)
return d;
return double.NaN;
}
// ECMA-262 ToNumber on an object goes through ToPrimitive (number hint).
// For a boxed Number/String/Boolean wrapper that reduces to its
// [[PrimitiveValue]]; without this `new Number(5) * 2`, comparisons, and
// bitwise ops on wrappers coerce to NaN (#708). A user-overridden valueOf
// is honored on the instance ToPrimitive paths (+, ==, templates, JSON).
if (rv.Kind == ValueKind.Object && TryGetBoxedPrimitiveValue(rv.ToObject(), out var prim))
return CoerceToNumber(RuntimeValue.FromBoxed(prim));
return double.NaN;
}

private enum PrimitiveHint { Default, Number, String }

private static readonly List<object?> _toPrimitiveNoArgs = new();

/// <summary>
/// True when <paramref name="value"/> is a boxed <c>new Number/String/Boolean</c>
/// wrapper (carries a <c>__primitiveType</c> tag); yields its <c>__primitiveValue</c>.
/// </summary>
internal static bool TryGetBoxedPrimitiveValue(object? value, out object? primitive)
{
primitive = null;
if (value is SharpTSObject obj
&& obj.GetProperty("__primitiveType") is string pt
&& pt is "Number" or "String" or "Boolean"
&& obj.HasProperty("__primitiveValue"))
{
primitive = obj.GetProperty("__primitiveValue");
return true;
}
return false;
}

/// <summary>
/// ECMA-262 §7.1.1 ToPrimitive / OrdinaryToPrimitive for the cases the
/// interpreter must coerce explicitly: boxed primitive wrappers and plain
/// objects carrying an own <c>valueOf</c>/<c>toString</c>. Calls the own
/// conversion methods in hint order (honoring a user override — #574), then
/// falls back to a wrapper's <c>__primitiveValue</c> (#708). Every other
/// value (already-primitive, array, class instance, plain object without an
/// own conversion) is returned unchanged so existing behavior is preserved.
/// </summary>
private object? ToPrimitive(object? value, PrimitiveHint hint)
{
if (value is not SharpTSObject obj) return value;
bool isWrapper = TryGetBoxedPrimitiveValue(obj, out var primitiveValue);
bool hasOwnValueOf = obj.HasProperty("valueOf");
bool hasOwnToString = obj.HasProperty("toString");
if (!isWrapper && !hasOwnValueOf && !hasOwnToString) return value;

string first = hint == PrimitiveHint.String ? "toString" : "valueOf";
string second = hint == PrimitiveHint.String ? "valueOf" : "toString";
if (TryCallOwnConversion(obj, first, out var r1)) return r1;
if (TryCallOwnConversion(obj, second, out var r2)) return r2;
return isWrapper ? primitiveValue : value;
}

/// <summary>
/// Invokes an own <c>valueOf</c>/<c>toString</c> on <paramref name="obj"/> bound
/// to it; succeeds only when the result is a primitive (per OrdinaryToPrimitive,
/// an object result is skipped so the next method is tried).
/// </summary>
private bool TryCallOwnConversion(SharpTSObject obj, string name, out object? result)
{
result = null;
if (!obj.HasProperty(name)) return false;
var fn = obj.GetProperty(name);
if (fn is SharpTSArrowFunction af && af.HasOwnThis) fn = af.Bind(obj);
if (fn is not ISharpTSCallable callable) return false;
var r = callable.CallBoxed(this, _toPrimitiveNoArgs);
if (IsObjectLike(r)) return false;
result = r;
return true;
}

/// <summary>
/// ECMA-262 §25.5.2.2 SerializeJSONProperty step 4: coerce a boxed
/// Number/String/Boolean wrapper to the primitive JSON serializes. Number→
/// ToNumber, String→ToString (both via <see cref="ToPrimitive"/>, so a user
/// override of valueOf/toString is honored — #574); Boolean→its
/// [[BooleanData]] (no coercion per spec). Returns false for any non-wrapper.
/// </summary>
internal bool TryCoerceBoxedPrimitiveForJson(object? value, out object? primitive)
{
primitive = null;
if (value is not SharpTSObject obj
|| obj.GetProperty("__primitiveType") is not string tag)
return false;
switch (tag)
{
case "Number":
primitive = CoerceToNumber(RuntimeValue.FromBoxed(ToPrimitive(obj, PrimitiveHint.Number)));
return true;
case "String":
var sp = ToPrimitive(obj, PrimitiveHint.String);
primitive = sp as string ?? Stringify(sp);
return true;
case "Boolean":
primitive = obj.GetProperty("__primitiveValue");
return true;
default:
return false;
}
}

/// <summary>
/// Evaluates the delete operator.
/// </summary>
Expand Down Expand Up @@ -637,9 +753,21 @@ private bool IsEqual(object? a, object? b)
bool bIsNullish = b == null || b is SharpTSUndefined;
if (aIsNullish && bIsNullish) return true;
if (aIsNullish || bIsNullish) return false;
// ECMA-262 §7.2.15 steps 10-11: Object vs primitive coerces the object
// through ToPrimitive, so `new Number(0) == 0` is true (#708). Only when
// the other operand is a primitive — object == object stays reference-based.
if (a is SharpTSObject && IsPrimitiveOperand(b)) a = ToPrimitive(a, PrimitiveHint.Default);
else if (b is SharpTSObject && IsPrimitiveOperand(a)) b = ToPrimitive(b, PrimitiveHint.Default);
return a!.Equals(b);
}

/// <summary>
/// True for JS primitive operands that trigger object→primitive coercion on
/// the other side of loose <c>==</c> (number, string, boolean, bigint).
/// </summary>
private static bool IsPrimitiveOperand(object? value) =>
value is double or string or bool or SharpTSBigInt;

/// <summary>
/// Determines if two values are equal using strict equality (<c>===</c>).
/// </summary>
Expand Down
18 changes: 4 additions & 14 deletions Runtime/BuiltIns/JSONBuiltIns.cs
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ private static RuntimeValue StringifyJson(Interpreter interp, RuntimeValue _, Re
// ECMA-262 25.5.2.1 step 5: a boxed Number/String wrapper passed as `space`
// contributes its primitive value before the numeric/string indent rules below.
// (Compiled mode does the same — RuntimeEmitter.Json.StringifyFull.cs.)
if (TryUnwrapBoxedPrimitive(space, out var unwrappedSpace))
if (TryUnwrapBoxedPrimitive(interp, space, out var unwrappedSpace))
space = unwrappedSpace;

// Handle space parameter: number = spaces, string = literal indent string
Expand Down Expand Up @@ -273,7 +273,7 @@ private static bool StringifyValue(Interpreter interp, object? value, object? ke
// serializes as its underlying primitive — not as an object exposing the internal
// __primitiveType/__primitiveValue marker slots. Applied after toJSON/replacer,
// before the type switch. (Compiled mode does the same — RuntimeEmitter.Json.Stringify.cs.)
if (TryUnwrapBoxedPrimitive(value, out var unwrappedPrimitive))
if (TryUnwrapBoxedPrimitive(interp, value, out var unwrappedPrimitive))
value = unwrappedPrimitive;

switch (value)
Expand Down Expand Up @@ -350,18 +350,8 @@ private static bool StringifyValue(Interpreter interp, object? value, object? ke
/// objects with a genuine [[NumberData]]/[[StringData]]/[[BooleanData]] slot are unwrapped.
/// Compiled mode performs the equivalent unwrap (RuntimeEmitter.Json.Stringify*.cs).
/// </summary>
private static bool TryUnwrapBoxedPrimitive(object? value, out object? primitive)
{
if (value is SharpTSObject obj
&& obj.GetProperty("__primitiveType") is string tag
&& tag is "Number" or "String" or "Boolean")
{
primitive = obj.GetProperty("__primitiveValue");
return true;
}
primitive = null;
return false;
}
private static bool TryUnwrapBoxedPrimitive(Interpreter interp, object? value, out object? primitive)
=> interp.TryCoerceBoxedPrimitiveForJson(value, out primitive);

private static string FormatJsonNumber(double d)
{
Expand Down
5 changes: 5 additions & 0 deletions Runtime/BuiltIns/NumberBuiltIns.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ public static class NumberBuiltIns
.MethodV2("toPrecision", 0, 1, ToPrecisionV2)
.MethodV2("toExponential", 0, 1, ToExponentialV2)
.MethodV2("toString", 0, 1, ToStringMethodV2)
// ECMA-262 §21.1.3.7: Number.prototype.valueOf returns thisNumberValue.
// Needed so `(new Number(5)).valueOf()` and ToPrimitive(number-wrapper)
// unwrap to the primitive instead of resolving Object.prototype.valueOf.
.MethodV2("valueOf", 0, (Interpreter _, double value, ReadOnlySpan<RuntimeValue> _)
=> RuntimeValue.FromNumber(value))
.Build();

/// <summary>
Expand Down
22 changes: 19 additions & 3 deletions Runtime/BuiltIns/StringBuiltIns.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ public static class StringBuiltIns
.MethodV2("at", 1, AtV2)
.MethodV2("normalize", 0, 1, NormalizeV2)
.MethodV2("localeCompare", 1, LocaleCompareV2)
// ECMA-262 §22.1.3.28/.31: String.prototype.toString and valueOf both
// return thisStringValue. Needed so `(new String("x")).toString()` and
// ToPrimitive(string-wrapper) unwrap to the primitive instead of
// resolving Object.prototype.toString ("[object Object]").
.MethodV2("toString", 0, (Interpreter _, string s, ReadOnlySpan<RuntimeValue> _)
=> RuntimeValue.FromString(s))
.MethodV2("valueOf", 0, (Interpreter _, string s, ReadOnlySpan<RuntimeValue> _)
=> RuntimeValue.FromString(s))
.Build();

private static readonly BuiltInStaticMemberLookup _staticLookup =
Expand Down Expand Up @@ -222,19 +230,27 @@ private static RuntimeValue FromCharCodeV2(Interpreter _, RuntimeValue receiver,

if (args.Length == 1)
{
var code = (int)args[0].AsNumber();
var code = (int)NumArg(args[0]);
return RuntimeValue.FromString(((char)(code & 0xFFFF)).ToString());
}

var chars = new char[args.Length];
for (int i = 0; i < args.Length; i++)
{
var code = (int)args[i].AsNumber();
var code = (int)NumArg(args[i]);
chars[i] = (char)(code & 0xFFFF);
}
return RuntimeValue.FromString(new string(chars));
}

/// <summary>
/// ToNumber-coerces a numeric argument, routing a non-number (notably a
/// boxed <c>new Number(x)</c> wrapper) through the interpreter's ToNumber so
/// it unwraps to its primitive instead of throwing on <c>AsNumber()</c> (#708).
/// </summary>
private static double NumArg(RuntimeValue rv)
=> rv.Kind == ValueKind.Number ? rv.AsNumber() : Interpreter.ToNumber(rv.ToObject());

private static RuntimeValue FromCodePointV2(Interpreter _, RuntimeValue receiver, ReadOnlySpan<RuntimeValue> args)
{
if (args.Length == 0) return RuntimeValue.EmptyString;
Expand All @@ -244,7 +260,7 @@ private static RuntimeValue FromCodePointV2(Interpreter _, RuntimeValue receiver
{
// ECMA-262 §22.1.2.2: each code point must be an integral Number in
// [0, 0x10FFFF]; NaN / Infinity / fractional values throw RangeError.
var num = arg.AsNumber();
var num = NumArg(arg);
if (!double.IsInteger(num) || num < 0 || num > 0x10FFFF)
throw new Exception($"RangeError: Invalid code point {num}");
AppendCodePoint(sb, (int)num);
Expand Down
Loading
Loading