mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(codemode): honor program valueOf and toString in operators and conversions (#50837)
This commit is contained in:
@@ -224,8 +224,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Coercion helpers and template interpolation accept functions and namespaces: `String(fn)` and `${fn}` give
|
||||
`"[object Function]"` rather than the source text, `isNaN(fn)` is `true`.
|
||||
- [x] `==` and `!=` follow IsLooselyEqual: objects (including functions and tool references) compare by identity, a
|
||||
nullish operand never coerces the other side, and a data object facing a primitive coerces through its built-in
|
||||
primitive form (`fn == null` is `false`, `fn == fn` is `true`, `[1] == 1` and `[1, 2] == "1,2"` are `true`).
|
||||
nullish operand never coerces the other side, and a data object facing a primitive converts through its own
|
||||
`valueOf`/`toString` (default hint) (`fn == null` is `false`, `fn == fn` is `true`, `[1] == 1` and `[1, 2] == "1,2"` are `true`).
|
||||
`switch` matches cases with `===`, so `switch (fn) { case fn: }` selects, and `Object.is` compares any two
|
||||
values. Operators inspect only their direct operands, so `rows == null` on a large array costs the same as
|
||||
`rows === null`, and an object merely holding a function inside (`[fn] + ""`, `-[fn]`) coerces like any other
|
||||
@@ -233,11 +233,16 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [ ] Coercing a function, promise, generator, or tool reference itself: `fn + ""`, `-fn`, `fn++`, and `fn == 1`
|
||||
throw `TypeError: Binary operators require data values.` (or the unary/update form) where JavaScript would use
|
||||
the source text or `NaN`.
|
||||
- [ ] ToPrimitive on program objects: operators, `Number`/`String`, `Error(message)`, `parseInt` radix, multi-argument
|
||||
`Date` construction and `Date.UTC`, and numeric built-in arguments (`Math.max`, `at`, `indexOf` start) should call
|
||||
the object's own `valueOf`/`toString` in spec order and surface their throws. Today they use the built-in form
|
||||
(`NaN`, `"[object Object]"`) and ignore own methods. Date setters and one-argument `Date` construction already
|
||||
follow ToPrimitive.
|
||||
- [x] ToPrimitive on program objects: `+ - * / % **`, the relational and bitwise operators, unary `+ - ~`, `++`/`--`,
|
||||
compound assignment, `${x}`, `Number`/`String`/`isNaN`/`isFinite`, `parseInt`/`parseFloat` (text and radix),
|
||||
`Math.*` arguments, `Error(message)`, and `Array.prototype.join`/`toString` elements call the object's own
|
||||
`valueOf`/`toString` in spec order (both operands left then right, `+` with the default hint) and surface their
|
||||
throws: `{ valueOf() { return 7 } } * 2` is `14`, `` `${{ toString() { return "x" } }}` `` is `"x"`, and
|
||||
`[1, 2]` with `arr.toString = () => "x"` makes `arr + ""` `"x"`. Dates keep their `Symbol.toPrimitive`
|
||||
behavior (`date + 1` concatenates, `date - date` subtracts).
|
||||
- [ ] ToPrimitive elsewhere: multi-argument `Date` construction, `Date.UTC`, `Error.prototype.toString` on an object
|
||||
`message`, and numeric built-in arguments outside `Math` (`at`, `indexOf` start, `toFixed` digits) still use the
|
||||
built-in form (`NaN`, `"[object Object]"`) and ignore own methods.
|
||||
- [x] Property keys follow ToPropertyKey: `x[null]`, `x[true]`, and objects (via their built-in string form) become
|
||||
string keys.
|
||||
|
||||
@@ -375,8 +380,9 @@ reject }` object.
|
||||
`flat(1.9)`, `with(1.5, v)`, `Math.max("3", "2")`, `parseInt("11", "2")`, `(1.5).toFixed("2")`,
|
||||
`String.fromCharCode("65")`, and the Uint8Array equivalents. `join(sep)` and `JSON.parse(text)` apply ToString
|
||||
(`join(null)` is `"1null2"`, `JSON.parse(123)` is `123`). `Array.from({ length: "2" })` applies ToLength; a
|
||||
promise source still throws with an `await` hint rather than JS's silent `[]`. A program object's own
|
||||
`valueOf`/`toString` is not consulted yet (see ToPrimitive above).
|
||||
promise source still throws with an `await` hint rather than JS's silent `[]`. `join`, `Math.*`, and
|
||||
`parseInt` consult a program object's own `valueOf`/`toString`; the array and number methods do not yet (see
|
||||
ToPrimitive above).
|
||||
|
||||
## Strings
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type Cursor,
|
||||
type Value,
|
||||
} from "./objects.js"
|
||||
import { typeofValue } from "./references.js"
|
||||
import { isOpaque, typeofValue } from "./references.js"
|
||||
|
||||
/** IteratorClose: a consumer failure closes the iterator and wins over any close failure, except that a generator's
|
||||
* return() is a return completion, so a failing close wins over it, as after `break`. */
|
||||
@@ -31,16 +31,15 @@ export const preserveConsumerError = <A, R>(
|
||||
})
|
||||
})
|
||||
|
||||
export type Hint = "number" | "string" | "default"
|
||||
|
||||
/**
|
||||
* ToPrimitive: calls `valueOf`/`toString` in hint order and returns the first primitive result. Dates treat the
|
||||
* default hint as "string", like their `Symbol.toPrimitive`.
|
||||
* default hint as "string", like their `Symbol.toPrimitive`. Opaque values (functions, promises, generators, tool
|
||||
* references) pass through unchanged so callers reject or describe them in their built-in form.
|
||||
*/
|
||||
export const toPrimitive = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
value: Value,
|
||||
hint: "number" | "string" | "default",
|
||||
): Effect.Effect<Value, unknown, R> => {
|
||||
if (!(value instanceof Obj)) return Effect.succeed(value)
|
||||
export const toPrimitive = <R>(ctx: Interpreter<R>, value: Value, hint: Hint): Effect.Effect<Value, unknown, R> => {
|
||||
if (!(value instanceof Obj) || isOpaque(value)) return Effect.succeed(value)
|
||||
const asString = hint === "string" || (hint === "default" && value instanceof DateObj)
|
||||
const order = asString ? ["toString", "valueOf"] : ["valueOf", "toString"]
|
||||
return Effect.gen(function* () {
|
||||
@@ -67,6 +66,25 @@ export const toPrimitiveString = <R>(ctx: Interpreter<R>, value: Value) =>
|
||||
export const toPrimitiveNumber = <R>(ctx: Interpreter<R>, value: Value) =>
|
||||
Effect.map(toPrimitive(ctx, value, "number"), coerceToNumber)
|
||||
|
||||
/**
|
||||
* Runs a synchronous native body on its arguments after ToPrimitive, in order, with one hint for all positions or
|
||||
* one per position. Primitive arguments skip the Effect entirely.
|
||||
*/
|
||||
export const withPrimitives = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
hints: Hint | ReadonlyArray<Hint>,
|
||||
values: Array<Value>,
|
||||
body: (primitives: Array<Value>) => Value,
|
||||
): Value | Effect.Effect<Value, unknown, R> => {
|
||||
if (!values.some((value) => value instanceof Obj)) return body(values)
|
||||
return Effect.map(
|
||||
Effect.forEach(values, (value, index) =>
|
||||
toPrimitive(ctx, value, typeof hints === "string" ? hints : hints[index]!),
|
||||
),
|
||||
body,
|
||||
)
|
||||
}
|
||||
|
||||
// The single acceptance list for callbacks: collections, sort, string replacers,
|
||||
// Array.from mappers, and promise reactions all admit exactly these callables.
|
||||
// Admission means dispatchable, not necessarily invocable: new-requiring
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type Value,
|
||||
} from "./objects.js"
|
||||
import type { Interpreter } from "./interpreter.js"
|
||||
import { toPrimitiveString } from "./callback.js"
|
||||
import { formatValue } from "../stdlib/console.js"
|
||||
|
||||
export const normalizeError = (error: unknown): Diagnostic => {
|
||||
@@ -139,14 +140,13 @@ const constructAggregateErrorValue = <R>(
|
||||
proto: Obj,
|
||||
): Effect.Effect<ErrorObj, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const message = args[1] === undefined ? "" : yield* toPrimitiveString(ctx, args[1])
|
||||
const cursor = yield* ctx.iterate(args[0])
|
||||
if (cursor === undefined) throw typeError("new AggregateError(...) expects a synchronous iterable of errors.")
|
||||
const errors: Array<Value> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) {
|
||||
return createAggregateErrorValue(ctx, errors, args[1] === undefined ? "" : coerceToString(args[1]), proto)
|
||||
}
|
||||
if (step.done) return createAggregateErrorValue(ctx, errors, message, proto)
|
||||
errors.push(step.value)
|
||||
}
|
||||
})
|
||||
@@ -160,7 +160,9 @@ export const errorGlobal = <R>(type: ErrorType, ctx: Interpreter<R>) => {
|
||||
const created =
|
||||
type === "AggregateError"
|
||||
? constructAggregateErrorValue(ctx, args, proto)
|
||||
: Effect.sync(() => createErrorValue(proto, args[0] === undefined ? undefined : coerceToString(args[0])))
|
||||
: Effect.map(args[0] === undefined ? Effect.undefined : toPrimitiveString(ctx, args[0]), (message) =>
|
||||
createErrorValue(proto, message),
|
||||
)
|
||||
// ES2022 `new Error(message, { cause })`: installed only when the options object has the property at all.
|
||||
const options = args[type === "AggregateError" ? 2 : 1]
|
||||
if (!(options instanceof Obj) || !has(options, "cause")) return created
|
||||
|
||||
@@ -95,7 +95,7 @@ import {
|
||||
coerceToString,
|
||||
type Value,
|
||||
} from "./objects.js"
|
||||
import { preserveConsumerError } from "./callback.js"
|
||||
import { type Hint, preserveConsumerError, toPrimitive } from "./callback.js"
|
||||
import { Pending, resolvePromise, resolvePromiseValue } from "./promises.js"
|
||||
import { describeValue, isOpaque, rejectCircularInsertion, typeofValue } from "./references.js"
|
||||
import { ScopeStack } from "./scope.js"
|
||||
@@ -103,6 +103,26 @@ import { constructRegExp } from "../stdlib/regexp.js"
|
||||
import { enumerableSource } from "../stdlib/object.js"
|
||||
import { compoundOperators } from "../stdlib/value.js"
|
||||
|
||||
/** The binary operators that convert object operands through ToPrimitive before acting on primitives. */
|
||||
const primitiveOperators = new Set([
|
||||
"+",
|
||||
"-",
|
||||
"*",
|
||||
"/",
|
||||
"%",
|
||||
"**",
|
||||
"<",
|
||||
"<=",
|
||||
">",
|
||||
">=",
|
||||
"&",
|
||||
"|",
|
||||
"^",
|
||||
"<<",
|
||||
">>",
|
||||
">>>",
|
||||
])
|
||||
|
||||
// What a loop does with its body's result: exit with a StatementResult, or undefined to keep iterating.
|
||||
// Unlabelled break ends this loop; a label the loop does not carry propagates outward.
|
||||
const loopExit = (result: StatementResult, labels: ReadonlySet<string> | undefined): StatementResult | undefined => {
|
||||
@@ -1368,10 +1388,38 @@ class Frame<R> {
|
||||
const lhs = yield* self.evaluateExpression(left)
|
||||
const rhs = yield* self.evaluateExpression(node.right)
|
||||
if (operator === "instanceof") return instanceofValue(lhs, rhs, node)
|
||||
if (lhs instanceof Obj || rhs instanceof Obj) return yield* self.applyOperator(operator, lhs, rhs, node)
|
||||
return self.applyBinaryOperator(operator, lhs, rhs, node)
|
||||
})
|
||||
}
|
||||
|
||||
/** ToPrimitive for an operand: data objects run their own methods; opaque values stay for the data gates below. */
|
||||
private toPrimitive(value: Value, hint: Hint, node: AstNode) {
|
||||
return this.native(() => toPrimitive(this.ctx, value, hint), node)
|
||||
}
|
||||
|
||||
// Arithmetic, relational, and bitwise operators convert both operands first, left then right, so a `valueOf`
|
||||
// runs (and throws) in spec order; `+` asks for the default hint and the rest for a number.
|
||||
private applyOperator(operator: string, lhs: Value, rhs: Value, node: AstNode): Effect.Effect<Value, unknown, R> {
|
||||
if (!(lhs instanceof Obj || rhs instanceof Obj))
|
||||
return Effect.succeed(this.applyBinaryOperator(operator, lhs, rhs, node))
|
||||
// IsLooselyEqual converts only an object facing a non-nullish primitive; two objects (including tool
|
||||
// references, which are not Obj) compare by identity.
|
||||
const equality = operator === "==" || operator === "!="
|
||||
const other = lhs instanceof Obj ? rhs : lhs
|
||||
const converts =
|
||||
primitiveOperators.has(operator) ||
|
||||
(equality && other !== null && other !== undefined && typeof other !== "object")
|
||||
if (!converts) return Effect.succeed(this.applyBinaryOperator(operator, lhs, rhs, node))
|
||||
const hint = operator === "+" || equality ? "default" : "number"
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const l = yield* self.toPrimitive(lhs, hint, node)
|
||||
const r = yield* self.toPrimitive(rhs, hint, node)
|
||||
return self.applyBinaryOperator(operator, l, r, node)
|
||||
})
|
||||
}
|
||||
|
||||
private applyBinaryOperator(operator: string, lhs: Value, rhs: Value, node: AstNode): Value {
|
||||
if (operator === "===") return lhs === rhs
|
||||
if (operator === "!==") return lhs !== rhs
|
||||
@@ -1383,70 +1431,60 @@ class Frame<R> {
|
||||
if (isOpaque(lhs) || isOpaque(rhs)) {
|
||||
throw invalidData("Binary operators require data values.", node)
|
||||
}
|
||||
// Addition uses the default hint; every other operator asks for a number.
|
||||
const hint = operator === "+" ? "default" : "number"
|
||||
const coerceOperand = (operand: Value) => (operand instanceof Obj ? operand.toPrimitive(hint) : operand)
|
||||
const l = coerceOperand(lhs)
|
||||
const r = coerceOperand(rhs)
|
||||
// Object operands were already converted by applyOperator; only primitives reach the arithmetic below.
|
||||
switch (operator) {
|
||||
case "+": {
|
||||
const sum = (l as string) + (r as string)
|
||||
const sum = (lhs as string) + (rhs as string)
|
||||
if (typeof sum === "string") checkStringLength(sum.length)
|
||||
return sum
|
||||
}
|
||||
case "-":
|
||||
return (l as number) - (r as number)
|
||||
return (lhs as number) - (rhs as number)
|
||||
case "*":
|
||||
return (l as number) * (r as number)
|
||||
return (lhs as number) * (rhs as number)
|
||||
case "/":
|
||||
return (l as number) / (r as number)
|
||||
return (lhs as number) / (rhs as number)
|
||||
case "%":
|
||||
return (l as number) % (r as number)
|
||||
return (lhs as number) % (rhs as number)
|
||||
case "**":
|
||||
return (l as number) ** (r as number)
|
||||
return (lhs as number) ** (rhs as number)
|
||||
case "<":
|
||||
return (l as string) < (r as string)
|
||||
return (lhs as string) < (rhs as string)
|
||||
case "<=":
|
||||
return (l as string) <= (r as string)
|
||||
return (lhs as string) <= (rhs as string)
|
||||
case ">":
|
||||
return (l as string) > (r as string)
|
||||
return (lhs as string) > (rhs as string)
|
||||
case ">=":
|
||||
return (l as string) >= (r as string)
|
||||
return (lhs as string) >= (rhs as string)
|
||||
case "&":
|
||||
return (l as number) & (r as number)
|
||||
return (lhs as number) & (rhs as number)
|
||||
case "|":
|
||||
return (l as number) | (r as number)
|
||||
return (lhs as number) | (rhs as number)
|
||||
case "^":
|
||||
return (l as number) ^ (r as number)
|
||||
return (lhs as number) ^ (rhs as number)
|
||||
case "<<":
|
||||
return (l as number) << (r as number)
|
||||
return (lhs as number) << (rhs as number)
|
||||
case ">>":
|
||||
return (l as number) >> (r as number)
|
||||
return (lhs as number) >> (rhs as number)
|
||||
case ">>>":
|
||||
return (l as number) >>> (r as number)
|
||||
return (lhs as number) >>> (rhs as number)
|
||||
case "in":
|
||||
if (!(rhs instanceof Obj)) {
|
||||
throw typeError("The 'in' operator requires a data object on the right-hand side.", node)
|
||||
}
|
||||
return has(rhs, coerceOperand(lhs) as PropertyKey)
|
||||
throw typeError("The 'in' operator requires a data object on the right-hand side.", node)
|
||||
default:
|
||||
throw typeError(`Unsupported binary operator '${operator}'.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
// IsLooselyEqual: objects (including functions and tool references) compare by identity, and only a
|
||||
// data object facing a non-nullish primitive needs to coerce, so an opaque value is rejected only there.
|
||||
// IsLooselyEqual: objects (including functions and tool references) compare by identity, and a nullish
|
||||
// primitive never equals an object.
|
||||
private looselyEqual(lhs: Value, rhs: Value, node: AstNode): boolean {
|
||||
const lhsObject = lhs !== null && typeof lhs === "object"
|
||||
const rhsObject = rhs !== null && typeof rhs === "object"
|
||||
if (lhsObject === rhsObject) return lhsObject ? lhs === rhs : lhs == rhs
|
||||
const object = lhsObject ? lhs : rhs
|
||||
const primitive = lhsObject ? rhs : lhs
|
||||
if (primitive === null || primitive === undefined) return false
|
||||
if (!(object instanceof Obj) || isOpaque(object)) {
|
||||
throw invalidData("Binary operators require data values.", node)
|
||||
}
|
||||
return object.toPrimitive("default") == primitive
|
||||
// Data objects were converted by applyOperator, so only an opaque reference facing a primitive gets here.
|
||||
throw invalidData("Binary operators require data values.", node)
|
||||
}
|
||||
|
||||
private evaluateLogicalExpression(node: LogicalExpression): Effect.Effect<Value, unknown, R> {
|
||||
@@ -1468,14 +1506,16 @@ class Frame<R> {
|
||||
if (operator === "typeof" && argument.type === "Identifier" && !this.scopes.resolve(argument.name)) {
|
||||
return Effect.succeed("undefined")
|
||||
}
|
||||
return Effect.map(this.evaluateExpression(argument), (value) => {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const value = yield* self.evaluateExpression(argument)
|
||||
if (operator === "typeof") return typeofValue(value)
|
||||
if (operator === "!") return !value
|
||||
if (operator === "void") return undefined
|
||||
if (isOpaque(value)) {
|
||||
const operand = yield* self.toPrimitive(value, "number", node)
|
||||
if (isOpaque(operand)) {
|
||||
throw invalidData("Unary operators require data values.", node)
|
||||
}
|
||||
const operand = value instanceof Obj ? value.toPrimitive("number") : value
|
||||
let result: Value
|
||||
switch (operator) {
|
||||
case "+":
|
||||
@@ -1497,11 +1537,16 @@ class Frame<R> {
|
||||
private evaluateAssignmentExpression(node: AssignmentExpression): Effect.Effect<Value, unknown, R> {
|
||||
const left = node.left
|
||||
const operator = node.operator
|
||||
// The binary operator a compound assignment applies: `+=` is `+`.
|
||||
const binary = operator.slice(0, -1)
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
if (operator === "??=" || operator === "||=" || operator === "&&=") {
|
||||
return yield* self.evaluateLogicalAssignment(node, left, operator)
|
||||
}
|
||||
if (operator !== "=" && !compoundOperators.has(operator)) {
|
||||
throw typeError(`Unsupported assignment operator '${operator}'.`, node)
|
||||
}
|
||||
if (operator === "=" && (left.type === "ObjectPattern" || left.type === "ArrayPattern")) {
|
||||
const rightValue = yield* self.evaluateExpression(node.right)
|
||||
yield* self.assignPattern(left, rightValue, node)
|
||||
@@ -1512,17 +1557,24 @@ class Frame<R> {
|
||||
if (operator !== "=") {
|
||||
const current = self.scopes.get(name, left)
|
||||
const rightValue = yield* self.evaluateExpression(node.right)
|
||||
return self.scopes.set(name, self.applyCompoundAssignment(operator, current, rightValue, node), left)
|
||||
const next =
|
||||
current instanceof Obj || rightValue instanceof Obj
|
||||
? yield* self.applyOperator(binary, current, rightValue, node)
|
||||
: self.applyBinaryOperator(binary, current, rightValue, node)
|
||||
return self.scopes.set(name, next, left)
|
||||
}
|
||||
const rightValue = yield* self.evaluateNamed(node.right, name)
|
||||
return self.scopes.set(name, rightValue, left)
|
||||
}
|
||||
if (left.type === "MemberExpression") {
|
||||
return yield* self.modifyMember(left, (current) =>
|
||||
Effect.map(self.evaluateExpression(node.right), (rightValue) => {
|
||||
if (operator === "=") return { write: true, next: rightValue, result: rightValue }
|
||||
const next = self.applyCompoundAssignment(operator, current, rightValue, node)
|
||||
return { write: true, next, result: next }
|
||||
Effect.flatMap(self.evaluateExpression(node.right), (rightValue) => {
|
||||
if (operator === "=") return Effect.succeed({ write: true, next: rightValue, result: rightValue })
|
||||
return Effect.map(self.applyOperator(binary, current, rightValue, node), (next) => ({
|
||||
write: true,
|
||||
next,
|
||||
result: next,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1572,8 +1624,7 @@ class Frame<R> {
|
||||
throw typeError(`Unsupported update operator '${operator}'.`, node)
|
||||
}
|
||||
|
||||
// CodeMode numeric coercion, not host Number(): null-prototype data objects would make
|
||||
// the host throw during ToPrimitive, and opaque runtime references must reject clearly.
|
||||
// CodeMode numeric coercion, not host Number(), so opaque runtime references reject clearly.
|
||||
const operand = (current: Value): number => {
|
||||
if (isOpaque(current)) {
|
||||
throw invalidData(`'${operator}' requires a data value.`, argument)
|
||||
@@ -1582,21 +1633,26 @@ class Frame<R> {
|
||||
}
|
||||
|
||||
if (argument.type === "Identifier") {
|
||||
return Effect.sync(() => {
|
||||
const name = argument.name
|
||||
const current = operand(this.scopes.get(name, argument))
|
||||
const next = current + increment
|
||||
const name = argument.name
|
||||
const current = this.scopes.get(name, argument)
|
||||
const update = (value: Value) => {
|
||||
const before = operand(value)
|
||||
const next = before + increment
|
||||
this.scopes.set(name, next, argument)
|
||||
return prefix ? next : current
|
||||
})
|
||||
return prefix ? next : before
|
||||
}
|
||||
if (!(current instanceof Obj)) return Effect.sync(() => update(current))
|
||||
return Effect.map(this.toPrimitive(current, "number", argument), update)
|
||||
}
|
||||
|
||||
if (argument.type === "MemberExpression") {
|
||||
return this.modifyMember(argument, (current) => {
|
||||
const value = operand(current)
|
||||
const next = value + increment
|
||||
return Effect.succeed({ write: true, next, result: prefix ? next : value })
|
||||
})
|
||||
return this.modifyMember(argument, (current) =>
|
||||
Effect.map(this.toPrimitive(current, "number", argument), (primitive) => {
|
||||
const value = operand(primitive)
|
||||
const next = value + increment
|
||||
return { write: true, next, result: prefix ? next : value }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
throw typeError("Update target must be an Identifier or MemberExpression.", argument)
|
||||
@@ -2065,7 +2121,7 @@ class Frame<R> {
|
||||
|
||||
if (index < expressions.length) {
|
||||
const raw = yield* self.evaluateExpression(expressions[index])
|
||||
output += coerceToString(raw)
|
||||
output += coerceToString(yield* self.toPrimitive(raw, "string", expressions[index]))
|
||||
checkStringLength(output.length)
|
||||
}
|
||||
}
|
||||
@@ -2115,13 +2171,6 @@ class Frame<R> {
|
||||
)
|
||||
}
|
||||
|
||||
private applyCompoundAssignment(operator: string, current: Value, incoming: Value, node: AstNode): Value {
|
||||
if (!compoundOperators.has(operator)) {
|
||||
throw typeError(`Unsupported assignment operator '${operator}'.`, node)
|
||||
}
|
||||
return this.applyBinaryOperator(operator.slice(0, -1), current, incoming, node)
|
||||
}
|
||||
|
||||
private getMemberReference(
|
||||
node: MemberExpression,
|
||||
): Effect.Effect<MemberReference | ToolReference | { value: Value } | typeof OptionalShortCircuit, unknown, R> {
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
type Value,
|
||||
} from "../interpreter/objects.js"
|
||||
import { describeValue, rejectCircularInsertion } from "../interpreter/references.js"
|
||||
import { applyCollectionCallback, invoke, preserveConsumerError } from "../interpreter/callback.js"
|
||||
import { applyCollectionCallback, invoke, preserveConsumerError, withPrimitives } from "../interpreter/callback.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { compareText } from "../tool-runtime.js"
|
||||
|
||||
@@ -146,20 +146,30 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
"join",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
const joined = self(thisValue, "join")
|
||||
.items.map((item) => coerceToString(item ?? ""))
|
||||
.join(args[0] === undefined ? "," : coerceToString(args[0]))
|
||||
checkStringLength(joined.length)
|
||||
return joined
|
||||
// .map would keep holes, which Effect.forEach would then hand to the body as undefined.
|
||||
const parts = Array.from(self(thisValue, "join").items, (item) => item ?? "")
|
||||
return withPrimitives(
|
||||
ctx,
|
||||
"string",
|
||||
[args[0] === undefined ? "," : args[0], ...parts],
|
||||
([separator, ...items]) => {
|
||||
const joined = items.map(coerceToString).join(coerceToString(separator))
|
||||
checkStringLength(joined.length)
|
||||
return joined
|
||||
},
|
||||
)
|
||||
},
|
||||
],
|
||||
[
|
||||
"toString",
|
||||
0,
|
||||
(thisValue) =>
|
||||
self(thisValue, "toString")
|
||||
.items.map((item) => coerceToString(item ?? ""))
|
||||
.join(","),
|
||||
withPrimitives(
|
||||
ctx,
|
||||
"string",
|
||||
Array.from(self(thisValue, "toString").items, (item) => item ?? ""),
|
||||
(items) => items.map(coerceToString).join(","),
|
||||
),
|
||||
],
|
||||
[
|
||||
"includes",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Effect } from "effect"
|
||||
import { constants, type Method, methods } from "../interpreter/native.js"
|
||||
import { typeError } from "../interpreter/model.js"
|
||||
import { Obj, coerceToNumber } from "../interpreter/objects.js"
|
||||
import { preserveConsumerError } from "../interpreter/callback.js"
|
||||
import { preserveConsumerError, withPrimitives } from "../interpreter/callback.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
|
||||
// Bun exposes ES2026 Math.sumPrecise before TypeScript's standard library types.
|
||||
@@ -12,25 +12,27 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate only the arguments a method consumes; like JS, extras are ignored
|
||||
// (so built-ins work as callbacks receiving (element, index, array)).
|
||||
const unary = (name: string, op: (a: number) => number): Method => [name, 1, (_, args) => op(coerceToNumber(args[0]))]
|
||||
|
||||
const binary = (name: string, op: (a: number, b: number) => number): Method => [
|
||||
name,
|
||||
2,
|
||||
(_, args) => op(coerceToNumber(args[0]), coerceToNumber(args[1])),
|
||||
]
|
||||
|
||||
const variadic = (name: string, op: (...values: Array<number>) => number): Method => [
|
||||
name,
|
||||
2,
|
||||
(_, args) => op(...args.map(coerceToNumber)),
|
||||
]
|
||||
|
||||
export const mathGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const builtins = ctx.builtins
|
||||
const math = new Obj(builtins.Object)
|
||||
// Convert only the arguments a method consumes; like JS, extras are ignored
|
||||
// (so built-ins work as callbacks receiving (element, index, array)).
|
||||
const unary = (name: string, op: (a: number) => number): Method => [
|
||||
name,
|
||||
1,
|
||||
(_, args) => withPrimitives(ctx, "number", [args[0]], ([a]) => op(coerceToNumber(a))),
|
||||
]
|
||||
const binary = (name: string, op: (a: number, b: number) => number): Method => [
|
||||
name,
|
||||
2,
|
||||
(_, args) =>
|
||||
withPrimitives(ctx, "number", [args[0], args[1]], ([a, b]) => op(coerceToNumber(a), coerceToNumber(b))),
|
||||
]
|
||||
const variadic = (name: string, op: (...values: Array<number>) => number): Method => [
|
||||
name,
|
||||
2,
|
||||
(_, args) => withPrimitives(ctx, "number", args, (values) => op(...values.map(coerceToNumber))),
|
||||
]
|
||||
constants(math, {
|
||||
PI: Math.PI,
|
||||
E: Math.E,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { constructor, constants, methods } from "../interpreter/native.js"
|
||||
import { coerceToNumber, coerceToString, type Value } from "../interpreter/objects.js"
|
||||
import { coerceToNumber, type Value } from "../interpreter/objects.js"
|
||||
import { rangeError, typeError } from "../interpreter/model.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { coercion } from "./value.js"
|
||||
import { coerce, coercion } from "./value.js"
|
||||
|
||||
export const numberGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const builtins = ctx.builtins
|
||||
@@ -26,14 +26,8 @@ export const numberGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
["isFinite", 1, (_, args) => Number.isFinite(args[0])],
|
||||
["isNaN", 1, (_, args) => Number.isNaN(args[0])],
|
||||
["isSafeInteger", 1, (_, args) => Number.isSafeInteger(args[0])],
|
||||
[
|
||||
"parseInt",
|
||||
2,
|
||||
(_, args) => {
|
||||
return parseInt(coerceToString(args[0]), coerceToNumber(args[1]))
|
||||
},
|
||||
],
|
||||
["parseFloat", 1, (_, args) => parseFloat(coerceToString(args[0]))],
|
||||
["parseInt", 2, (_, args) => coerce(ctx, "parseInt", args)],
|
||||
["parseFloat", 1, (_, args) => coerce(ctx, "parseFloat", args)],
|
||||
])
|
||||
|
||||
const self = (thisValue: Value, name: string): number => {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { fn } from "../interpreter/native.js"
|
||||
import { coerceToNumber, coerceToString, type Native, type Value } from "../interpreter/objects.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { withPrimitives } from "../interpreter/callback.js"
|
||||
|
||||
export const compoundOperators = new Set(["+=", "-=", "*=", "/=", "%=", "**=", "&=", "|=", "^=", "<<=", ">>=", ">>>="])
|
||||
|
||||
export type Coercion = "Number" | "String" | "Boolean" | "parseInt" | "parseFloat" | "isFinite" | "isNaN"
|
||||
|
||||
const coerce = <R>(ctx: Interpreter<R>, name: Coercion, args: Array<Value>): Value => {
|
||||
export const coerce = <R>(ctx: Interpreter<R>, name: Coercion, args: Array<Value>) => {
|
||||
// Native: Number() is 0 and String() is "", unlike their undefined-argument forms; the
|
||||
// other coercers match native through the undefined-argument path below.
|
||||
if (args.length === 0) {
|
||||
@@ -14,15 +15,19 @@ const coerce = <R>(ctx: Interpreter<R>, name: Coercion, args: Array<Value>): Val
|
||||
if (name === "String") return ""
|
||||
}
|
||||
const raw = args[0]
|
||||
if (name === "Number") return coerceToNumber(raw)
|
||||
if (name === "Boolean") return Boolean(raw)
|
||||
if (name === "isFinite") return Number.isFinite(coerceToNumber(raw))
|
||||
if (name === "isNaN") return Number.isNaN(coerceToNumber(raw))
|
||||
if (name === "parseInt") {
|
||||
return parseInt(coerceToString(raw), coerceToNumber(args[1]))
|
||||
return withPrimitives(ctx, ["string", "number"], [raw, args[1]], ([text, radix]) =>
|
||||
parseInt(coerceToString(text), coerceToNumber(radix)),
|
||||
)
|
||||
}
|
||||
if (name === "parseFloat") return parseFloat(coerceToString(raw))
|
||||
return coerceToString(raw)
|
||||
return withPrimitives(ctx, name === "String" || name === "parseFloat" ? "string" : "number", [raw], ([value]) => {
|
||||
if (name === "Number") return coerceToNumber(value)
|
||||
if (name === "isFinite") return Number.isFinite(coerceToNumber(value))
|
||||
if (name === "isNaN") return Number.isNaN(coerceToNumber(value))
|
||||
if (name === "parseFloat") return parseFloat(coerceToString(value))
|
||||
return coerceToString(value)
|
||||
})
|
||||
}
|
||||
|
||||
/** A global coercion function such as `Number` or `parseInt`. */
|
||||
|
||||
@@ -1571,3 +1571,120 @@ describe("this, arguments, and Function.prototype.call/apply/bind", () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ToPrimitive: operators and conversions honor program valueOf and toString", () => {
|
||||
test("program-installed valueOf and toString on opaque values are ignored at every site", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const f = () => 1
|
||||
f.toString = () => "custom"
|
||||
f.valueOf = () => 5
|
||||
return [String(f), \`\${f}\`, [f].join(), new Error(f).message, isNaN(Number(f)), isNaN(Math.abs(f))]
|
||||
`),
|
||||
).toEqual(["[object Function]", "[object Function]", "[object Function]", "[object Function]", true, true])
|
||||
})
|
||||
|
||||
test("== converts an object facing a non-nullish primitive through its own valueOf", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const one = { valueOf() { return 1 } }
|
||||
return [one == 1, 1 == one, one == true, one == "1", one == null, one == one, one == { valueOf() { return 1 } }, [1] == 1]
|
||||
`),
|
||||
).toEqual([true, true, true, true, false, true, false, true])
|
||||
expect((await error(`(() => 1) == 1`)).message).toContain("Binary operators require data values")
|
||||
})
|
||||
|
||||
test("operators, unary, template literals, and conversion functions use the object's own methods", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const money = { valueOf() { return 7 } }
|
||||
return [money * 2, money + 1, money + "", -money, +money, ~money, money < 8, money ** 2, money | 8,
|
||||
Number(money), Math.max(money, 1), \`\${money}\`, String(money), isNaN(money), isFinite(money),
|
||||
parseInt({ toString() { return "42px" } }), parseInt("ff", { valueOf() { return 16 } }),
|
||||
Number.parseFloat({ toString() { return "1.5" } })]
|
||||
`),
|
||||
).toEqual([
|
||||
14,
|
||||
8,
|
||||
"7",
|
||||
-7,
|
||||
7,
|
||||
-8,
|
||||
true,
|
||||
49,
|
||||
15,
|
||||
7,
|
||||
7,
|
||||
"[object Object]",
|
||||
"[object Object]",
|
||||
false,
|
||||
true,
|
||||
42,
|
||||
255,
|
||||
1.5,
|
||||
])
|
||||
})
|
||||
|
||||
test("the hint picks the method: + and Number prefer valueOf, template literals and String prefer toString", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const both = { valueOf() { return 1 }, toString() { return "s" } }
|
||||
return [both + "", \`\${both}\`, String(both), both * 2, new Error(both).message, [both].join(), [both, 2] + ""]
|
||||
`),
|
||||
).toEqual(["1", "s", "s", 2, "s", "s", "s,2"])
|
||||
})
|
||||
|
||||
test("operands convert left then right, and a throwing valueOf surfaces as the program error", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const order = []
|
||||
const a = { valueOf() { order.push("a"); return 1 } }, b = { valueOf() { order.push("b"); return 2 } }
|
||||
a + b; a < b; a - b
|
||||
return order
|
||||
`),
|
||||
).toEqual(["a", "b", "a", "b", "a", "b"])
|
||||
expect(
|
||||
await value(`
|
||||
const bad = { valueOf() { throw new RangeError("nope") } }
|
||||
const names = []
|
||||
try { bad + 1 } catch (e) { names.push(e.name) }
|
||||
try { Number(bad) } catch (e) { names.push(e.name) }
|
||||
try { Math.abs(bad) } catch (e) { names.push(e.name) }
|
||||
return names
|
||||
`),
|
||||
).toEqual(["RangeError", "RangeError", "RangeError"])
|
||||
})
|
||||
|
||||
test("arrays keep their built-in join form unless the program replaces toString", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const arr = [1, 2]
|
||||
const before = [arr + "", [] + [], [1, , 3].join("-"), [1, { toString() { return "q" } }].join("-")]
|
||||
arr.toString = () => "x"
|
||||
return [...before, arr + "", \`\${arr}\`, String(arr)]
|
||||
`),
|
||||
).toEqual(["1,2", "", "1--3", "1-q", "x", "x", "x"])
|
||||
})
|
||||
|
||||
test("update and compound assignment convert the current value", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
let x = { valueOf() { return 5 } }
|
||||
const o = { n: { valueOf() { return 4 } } }
|
||||
const after = x++
|
||||
o.n += 1
|
||||
o.n++
|
||||
let s = { valueOf() { return 2 } }
|
||||
s *= 3
|
||||
return [after, x, o.n, s]
|
||||
`),
|
||||
).toEqual([5, 6, 6, 6])
|
||||
})
|
||||
|
||||
test("functions and other opaque values still reject arithmetic, and an object without a primitive form throws", async () => {
|
||||
expect((await error(`const f = () => 1; return f + 1`)).message).toContain("Binary operators require data values")
|
||||
expect((await error(`return -(() => 1)`)).message).toContain("Unary operators require data values")
|
||||
const failure = await error(`return { valueOf() { return {} }, toString() { return [] } } + 1`)
|
||||
expect(failure.message).toContain("Cannot convert object to primitive value")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,10 +14,14 @@
|
||||
"built-ins/Object/isSealed",
|
||||
"built-ins/Object/preventExtensions",
|
||||
"built-ins/String/raw",
|
||||
"built-ins/parseFloat",
|
||||
"built-ins/parseInt",
|
||||
"language/arguments-object",
|
||||
"language/expressions/does-not-equals",
|
||||
"language/expressions/equals",
|
||||
"language/expressions/exponentiation",
|
||||
"language/expressions/tagged-template",
|
||||
"language/expressions/template-literal",
|
||||
"language/expressions/this",
|
||||
"language/statements"
|
||||
],
|
||||
|
||||
@@ -365,8 +365,6 @@ built-ins/Array/prototype/indexOf/call-with-boolean.js # TypeError: Array.proto
|
||||
built-ins/Array/prototype/indexOf/length-near-integer-limit.js # TypeError: Array.prototype.indexOf called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/join/S15.4.4.5_A2_T1.js # Array.prototype.join called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/join/S15.4.4.5_A2_T4.js # Array.prototype.join called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/join/S15.4.4.5_A3.1_T2.js # Array.join expects zero arguments or one string separator.
|
||||
built-ins/Array/prototype/join/S15.4.4.5_A3.2_T2.js # x.join() must return "*" Expected SameValue(«"[object Object]"», «"*"») to be true
|
||||
built-ins/Array/prototype/join/S15.4.4.5_A4_T3.js # Array.prototype.join called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/join/S15.4.4.5_A5_T1.js # #1: Array.prototype[1] = 1; x = [0]; x.length = 2; x.join() === "0,1". Actual: 0,
|
||||
built-ins/Array/prototype/join/call-with-boolean.js # TypeError: Array.prototype.join called on incompatible receiver a boolean.
|
||||
@@ -833,7 +831,6 @@ built-ins/Array/prototype/some/15.4.4.17-8-7.js # … cannot be constructed: us
|
||||
built-ins/Array/prototype/some/15.4.4.17-8-8.js # … cannot be constructed: user-defined constructors and classes are not supported. Call it as a funct
|
||||
built-ins/Array/prototype/some/call-with-boolean.js # TypeError: Array.prototype.some called on incompatible receiver a boolean.
|
||||
built-ins/Array/prototype/sort/S15.4.4.11_A2.1_T3.js # #1: Check ToString operator
|
||||
built-ins/Array/prototype/sort/S15.4.4.11_A2.2_T3.js # #1: Check ToString operator
|
||||
built-ins/Array/prototype/sort/S15.4.4.11_A3_T1.js # Array.prototype.sort called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/sort/S15.4.4.11_A3_T2.js # Array.prototype.sort called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/sort/S15.4.4.11_A4_T3.js # Array.prototype.sort called on incompatible receiver a data object.
|
||||
@@ -873,7 +870,6 @@ built-ins/Array/prototype/toSpliced/length-clamped-to-2pow53minus1.js # TypeErr
|
||||
built-ins/Array/prototype/toSpliced/length-exceeding-array-length-limit.js # TypeError: Only init object properties are supported.
|
||||
built-ins/Array/prototype/toSpliced/length-tolength.js # TypeError: Array.prototype.toSpliced called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/toSpliced/this-value-boolean.js # TypeError: Array.prototype.toSpliced called on incompatible receiver a boolean.
|
||||
built-ins/Array/prototype/toString/S15.4.4.2_A1_T4.js # Expected a Test262Error to be thrown but no exception was thrown at all
|
||||
built-ins/Array/prototype/toString/S15.4.4.2_A3_T1.js # #1: Array.prototype[1] = 1; x = [0]; x.length = 2; x.toString() === "0,1". Actual: 0,
|
||||
built-ins/Array/prototype/toString/call-with-boolean.js # TypeError: Array.prototype.toString called on incompatible receiver a boolean.
|
||||
built-ins/Array/prototype/unshift/S15.4.4.13_A2_T1.js # Array.prototype.unshift called on incompatible receiver a data object.
|
||||
@@ -906,11 +902,6 @@ built-ins/Iterator/prototype/take/limit-tonumber-throws.js # Expected a Test262
|
||||
built-ins/Iterator/prototype/take/limit-tonumber.js # Iterator.prototype.take expects a non-negative count, received NaN.
|
||||
built-ins/Object/is/not-same-value-x-y-object.js # Object(number) wrapper objects are not supported; use the primitive value directly.
|
||||
built-ins/Object/is/same-value-x-y-object.js # Object(number) wrapper objects are not supported; use the primitive value directly.
|
||||
language/expressions/does-not-equals/S11.9.2_A7.8.js # #1: (true != {valueOf: function() {return 1}}) === false
|
||||
language/expressions/does-not-equals/S11.9.2_A7.9.js # #1: ({valueOf: function() {return 1}} != true) === false
|
||||
language/expressions/equals/S11.9.1_A7.8.js # #1: ({valueOf: function() {return 1}} == true) === true
|
||||
language/expressions/equals/S11.9.1_A7.9.js # #1: (true == {valueOf: function() {return 1}}) === true
|
||||
language/expressions/equals/S9.1_A1_T3.js # #1: var object = {valueOf: function() {return 1}, toString: function() {return 0}}; object + "" ===
|
||||
built-ins/Object/freeze/throws-when-false.js # Unknown identifier 'Proxy'.
|
||||
built-ins/Object/getPrototypeOf/15.2.3.2-2-2.js # … cannot be constructed: user-defined constructors and classes are not supported.
|
||||
built-ins/Object/isExtensible/15.2.3.13-2-24.js # … cannot be constructed: user-defined constructors and classes are not supported.
|
||||
@@ -1104,3 +1095,5 @@ built-ins/Array/prototype/some/15.4.4.17-1-15.js # TypeError: Array.prototype.s
|
||||
built-ins/Array/prototype/some/15.4.4.17-7-c-i-25.js # TypeError: Array.prototype.some called on incompatible receiver an Arguments.
|
||||
built-ins/Array/prototype/some/15.4.4.17-7-c-i-26.js # TypeError: Array.prototype.some called on incompatible receiver an Arguments.
|
||||
built-ins/Array/prototype/some/15.4.4.17-7-c-i-27.js # TypeError: Array.prototype.some called on incompatible receiver an Arguments.
|
||||
built-ins/parseFloat/S15.1.2.3_A7.5.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/parseInt/S15.1.2.2_A9.5.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
|
||||
@@ -354,3 +354,17 @@ describe("tools.search alias", () => {
|
||||
expect(await value(runtime, `return await tools.search({})`)).toBe("custom")
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool references under ==", () => {
|
||||
test("compare by identity against data objects without converting them", async () => {
|
||||
const runtime = CodeMode.make({ tools: { probe: echo("Probe", "ok") } })
|
||||
expect(
|
||||
await value(
|
||||
runtime,
|
||||
`let calls = 0
|
||||
const o = { valueOf() { calls++; return 1 } }
|
||||
return [o == tools.probe, tools == { a: 1 }, tools.probe == null, calls]`,
|
||||
),
|
||||
).toEqual([false, false, false, 0])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user