feat(codemode): native coercion parity in interpreter (#37608)
This commit is contained in:
parent
7d4496eafc
commit
5f437a09b0
10 changed files with 323 additions and 48 deletions
|
|
@ -80,7 +80,7 @@ ultimate source of truth.
|
||||||
- [x] Expression and block function bodies.
|
- [x] Expression and block function bodies.
|
||||||
- [x] User callbacks for the supported Array, Map, Set, URLSearchParams, sort, string-replacement, and `Array.from`
|
- [x] User callbacks for the supported Array, Map, Set, URLSearchParams, sort, string-replacement, and `Array.from`
|
||||||
mapper APIs, with one shared acceptance rule everywhere including promise reactions.
|
mapper APIs, with one shared acceptance rule everywhere including promise reactions.
|
||||||
- [x] `Boolean`, `Number`, `String`, `parseInt`, `parseFloat`, and URI helpers as callbacks.
|
- [x] `Boolean`, `Number`, `String`, `parseInt`, `parseFloat`, `isFinite`, `isNaN`, and URI helpers as callbacks.
|
||||||
- [x] Built-in method references as callbacks, such as `values.map(Math.abs)`, `records.map(JSON.stringify)`,
|
- [x] Built-in method references as callbacks, such as `values.map(Math.abs)`, `records.map(JSON.stringify)`,
|
||||||
`items.forEach(console.log)`, and `Promise.resolve(-1).then(Math.abs)`. Extra callback arguments a built-in
|
`items.forEach(console.log)`, and `Promise.resolve(-1).then(Math.abs)`. Extra callback arguments a built-in
|
||||||
does not consume are ignored, like JS; consumed arguments stay strictly validated (`Math.floor` still rejects a
|
does not consume are ignored, like JS; consumed arguments stay strictly validated (`Math.floor` still rejects a
|
||||||
|
|
@ -210,9 +210,12 @@ ultimate source of truth.
|
||||||
- [x] `localeCompare`; locale and options arguments are currently ignored.
|
- [x] `localeCompare`; locale and options arguments are currently ignored.
|
||||||
- [x] `toString`, `length`, numeric indexing, spread, and `for...of` by Unicode code point.
|
- [x] `toString`, `length`, numeric indexing, spread, and `for...of` by Unicode code point.
|
||||||
- [x] Static `String.fromCharCode` and `String.fromCodePoint`.
|
- [x] Static `String.fromCharCode` and `String.fromCodePoint`.
|
||||||
- [ ] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` currently
|
- [x] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` coerce like
|
||||||
reject instead of coercing.
|
native JS, `split(undefined)` returns the whole string, and `includes`/`startsWith`/`endsWith` reject regular
|
||||||
- [ ] Native no-argument parity for `match()` and `search()`.
|
expressions with a native-style `TypeError`. Opaque runtime references still reject as data errors, and
|
||||||
|
`repeat` still requires a finite non-negative count.
|
||||||
|
- [x] Native no-argument parity for `match()`, `matchAll()`, and `search()`; all behave as an empty pattern. Present
|
||||||
|
arguments must still be a regular expression or string pattern.
|
||||||
|
|
||||||
## Numbers and Math
|
## Numbers and Math
|
||||||
|
|
||||||
|
|
@ -226,13 +229,16 @@ ultimate source of truth.
|
||||||
- [x] Math methods: `random`, `max`, `min`, `abs`, `acos`, `acosh`, `asin`, `asinh`, `atan`, `atan2`, `atanh`,
|
- [x] Math methods: `random`, `max`, `min`, `abs`, `acos`, `acosh`, `asin`, `asinh`, `atan`, `atan2`, `atanh`,
|
||||||
`floor`, `ceil`, `round`, `trunc`, `sign`, `sqrt`, `cbrt`, `pow`, `hypot`, `cos`, `cosh`, `sin`, `sinh`,
|
`floor`, `ceil`, `round`, `trunc`, `sign`, `sqrt`, `cbrt`, `pow`, `hypot`, `cos`, `cosh`, `sin`, `sinh`,
|
||||||
`tan`, `tanh`, `log`, `log2`, `log10`, `log1p`, `exp`, `expm1`, `f16round`, `fround`, `clz32`, and `imul`.
|
`tan`, `tanh`, `log`, `log2`, `log10`, `log1p`, `exp`, `expm1`, `f16round`, `fround`, `clz32`, and `imul`.
|
||||||
- [ ] Native zero-argument behavior for `Number()` and `String()`; they currently do not produce `0` and `""`.
|
- [x] Native zero-argument behavior for `Number()` and `String()`: they produce `0` and `""`, while
|
||||||
- [ ] `++` and `--` must use CodeMode numeric coercion and reject opaque runtime references; they currently call host
|
`Number(undefined)` stays `NaN` and `String(undefined)` stays `"undefined"`.
|
||||||
`Number(...)` directly.
|
- [x] `++` and `--` use CodeMode numeric coercion (numeric strings increment, plain data objects become `NaN`, Dates
|
||||||
- [ ] Unknown static members must read as `undefined` for feature detection; some currently appear callable or throw
|
use their epoch time) and reject opaque runtime references as data errors.
|
||||||
during property access.
|
- [x] Unknown static members on global namespaces and on `Number`/`String`/the coercion functions read as `undefined`
|
||||||
|
for feature detection. Calling any undefined value reports a native-style `TypeError` naming the callee, for
|
||||||
|
example `Math.sumPrecise is not a function.` Blocked members (`constructor`, `__proto__`, ...) still throw,
|
||||||
|
and unknown `Promise` statics keep their descriptive error.
|
||||||
- [ ] `Math.sumPrecise`.
|
- [ ] `Math.sumPrecise`.
|
||||||
- [ ] Global coercing `isFinite` and `isNaN`.
|
- [x] Global coercing `isFinite` and `isNaN`; opaque runtime references reject as data errors, like `Number(...)`.
|
||||||
|
|
||||||
## JSON and console
|
## JSON and console
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import {
|
||||||
PromiseNamespace,
|
PromiseNamespace,
|
||||||
UriFunction,
|
UriFunction,
|
||||||
} from "./model.js"
|
} from "./model.js"
|
||||||
import { rejectCircularInsertion, typeofValue } from "./references.js"
|
import { containsOpaqueReference, rejectCircularInsertion, typeofValue } from "./references.js"
|
||||||
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
|
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
|
||||||
import {
|
import {
|
||||||
CodeModeDate,
|
CodeModeDate,
|
||||||
|
|
@ -137,21 +137,31 @@ export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unkno
|
||||||
return invokeJsonMethod(ref.name, args, node)
|
return invokeJsonMethod(ref.name, args, node)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requireDataArgument = (name: string, index: number, arg: unknown, node: AstNode): unknown => {
|
||||||
|
if (containsOpaqueReference(arg)) {
|
||||||
|
throw new InterpreterRuntimeError(
|
||||||
|
`String.${name} expects argument ${index + 1} to be a data value.`,
|
||||||
|
node,
|
||||||
|
"InvalidDataValue",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return arg
|
||||||
|
}
|
||||||
|
|
||||||
const invokeStringMethod = (value: string, name: string, args: Array<unknown>, node: AstNode): unknown => {
|
const invokeStringMethod = (value: string, name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||||
const str = (index: number): string => {
|
// Coerce arguments like native JS; opaque runtime references still reject.
|
||||||
const arg = args[index]
|
const str = (index: number): string => coerceToString(requireDataArgument(name, index, args[index], node))
|
||||||
if (typeof arg !== "string")
|
const num = (index: number): number => coerceToNumber(requireDataArgument(name, index, args[index], node))
|
||||||
throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node)
|
|
||||||
return arg
|
|
||||||
}
|
|
||||||
const num = (index: number): number => {
|
|
||||||
const arg = args[index]
|
|
||||||
if (typeof arg !== "number")
|
|
||||||
throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node)
|
|
||||||
return arg
|
|
||||||
}
|
|
||||||
const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index))
|
const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index))
|
||||||
const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index))
|
const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index))
|
||||||
|
const rejectRegex = (): void => {
|
||||||
|
if (args[0] instanceof CodeModeRegExp) {
|
||||||
|
throw new InterpreterRuntimeError(
|
||||||
|
`String.${name} cannot take a regular expression; use regex.test(string) or String.search instead.`,
|
||||||
|
node,
|
||||||
|
).as("TypeError")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let result: unknown
|
let result: unknown
|
||||||
switch (name) {
|
switch (name) {
|
||||||
|
|
@ -187,8 +197,11 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "split": {
|
case "split": {
|
||||||
if (args.length === 0) {
|
// Native: an undefined separator returns the whole string, not a split on "undefined",
|
||||||
result = [value]
|
// unless the limit truncates to zero.
|
||||||
|
if (args[0] === undefined) {
|
||||||
|
const requestedLimit = optNum(1)
|
||||||
|
result = requestedLimit !== undefined && requestedLimit >>> 0 === 0 ? [] : [value]
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (args[0] instanceof CodeModeRegExp) {
|
if (args[0] instanceof CodeModeRegExp) {
|
||||||
|
|
@ -203,12 +216,15 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||||
result = value.slice(optNum(0), optNum(1))
|
result = value.slice(optNum(0), optNum(1))
|
||||||
break
|
break
|
||||||
case "includes":
|
case "includes":
|
||||||
|
rejectRegex()
|
||||||
result = value.includes(str(0), optNum(1))
|
result = value.includes(str(0), optNum(1))
|
||||||
break
|
break
|
||||||
case "startsWith":
|
case "startsWith":
|
||||||
|
rejectRegex()
|
||||||
result = value.startsWith(str(0), optNum(1))
|
result = value.startsWith(str(0), optNum(1))
|
||||||
break
|
break
|
||||||
case "endsWith":
|
case "endsWith":
|
||||||
|
rejectRegex()
|
||||||
result = value.endsWith(str(0), optNum(1))
|
result = value.endsWith(str(0), optNum(1))
|
||||||
break
|
break
|
||||||
case "indexOf":
|
case "indexOf":
|
||||||
|
|
@ -263,7 +279,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||||
case "repeat": {
|
case "repeat": {
|
||||||
const count = num(0)
|
const count = num(0)
|
||||||
if (!Number.isFinite(count) || count < 0)
|
if (!Number.isFinite(count) || count < 0)
|
||||||
throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node)
|
throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node).as("RangeError")
|
||||||
result = value.repeat(count)
|
result = value.repeat(count)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -301,6 +317,8 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||||
return boundedData(result, `String.${name} result`)
|
return boundedData(result, `String.${name} result`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const arrayStatics = new Set(["isArray", "of", "from"])
|
||||||
|
|
||||||
const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "isArray":
|
case "isArray":
|
||||||
|
|
@ -400,11 +418,9 @@ const invokeStringReplacer = <R>(
|
||||||
if (name === "replace") value.replace(pattern.regex, collect)
|
if (name === "replace") value.replace(pattern.regex, collect)
|
||||||
else value.replaceAll(pattern.regex, collect)
|
else value.replaceAll(pattern.regex, collect)
|
||||||
} else {
|
} else {
|
||||||
if (typeof pattern !== "string") {
|
const search = coerceToString(requireDataArgument(name, 0, pattern, node))
|
||||||
throw new InterpreterRuntimeError(`String.${name} expects argument 1 to be a string.`, node)
|
if (name === "replace") value.replace(search, collect)
|
||||||
}
|
else value.replaceAll(search, collect)
|
||||||
if (name === "replace") value.replace(pattern, collect)
|
|
||||||
else value.replaceAll(pattern, collect)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ export class GlobalMethodReference {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CoercionFunction {
|
export class CoercionFunction {
|
||||||
constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat") {}
|
constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat" | "isFinite" | "isNaN") {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UriFunction {
|
export class UriFunction {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
ErrorConstructorReference,
|
ErrorConstructorReference,
|
||||||
GlobalMethodReference,
|
GlobalMethodReference,
|
||||||
GlobalNamespace,
|
GlobalNamespace,
|
||||||
|
type GlobalNamespaceName,
|
||||||
getArray,
|
getArray,
|
||||||
getBoolean,
|
getBoolean,
|
||||||
getNode,
|
getNode,
|
||||||
|
|
@ -34,7 +35,7 @@ import {
|
||||||
UriFunction,
|
UriFunction,
|
||||||
} from "./model.js"
|
} from "./model.js"
|
||||||
import { caughtErrorValue, constructErrorValue } from "./errors.js"
|
import { caughtErrorValue, constructErrorValue } from "./errors.js"
|
||||||
import { type CallbackRunner, invokeArrayFrom, invokeGlobalMethod, invokeIntrinsic } from "./methods.js"
|
import { arrayStatics, type CallbackRunner, invokeArrayFrom, invokeGlobalMethod, invokeIntrinsic } from "./methods.js"
|
||||||
import {
|
import {
|
||||||
constructPromise,
|
constructPromise,
|
||||||
invokePromiseInstanceMethod,
|
invokePromiseInstanceMethod,
|
||||||
|
|
@ -46,10 +47,11 @@ import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, t
|
||||||
import { ScopeStack } from "./scope.js"
|
import { ScopeStack } from "./scope.js"
|
||||||
import { arrayMethods, mapMethods, setMethods, spreadItems } from "../stdlib/collections.js"
|
import { arrayMethods, mapMethods, setMethods, spreadItems } from "../stdlib/collections.js"
|
||||||
import { consoleMethods, formatConsoleMessage } from "../stdlib/console.js"
|
import { consoleMethods, formatConsoleMessage } from "../stdlib/console.js"
|
||||||
import { dateMethods } from "../stdlib/date.js"
|
import { dateMethods, dateStatics } from "../stdlib/date.js"
|
||||||
import { mathConstants } from "../stdlib/math.js"
|
import { jsonStatics } from "../stdlib/json.js"
|
||||||
|
import { mathConstants, mathMethods } from "../stdlib/math.js"
|
||||||
import { numberConstants, numberMethods, numberStatics } from "../stdlib/number.js"
|
import { numberConstants, numberMethods, numberStatics } from "../stdlib/number.js"
|
||||||
import { objectMethodsPreservingIdentity } from "../stdlib/object.js"
|
import { objectMethodsPreservingIdentity, objectStatics } from "../stdlib/object.js"
|
||||||
import { promiseStatics } from "../stdlib/promise.js"
|
import { promiseStatics } from "../stdlib/promise.js"
|
||||||
import { escapeRegexHint, regexpMethods, regexpProperties, regexFailureReason } from "../stdlib/regexp.js"
|
import { escapeRegexHint, regexpMethods, regexpProperties, regexFailureReason } from "../stdlib/regexp.js"
|
||||||
import { stringMethods, stringStatics } from "../stdlib/string.js"
|
import { stringMethods, stringStatics } from "../stdlib/string.js"
|
||||||
|
|
@ -57,6 +59,7 @@ import {
|
||||||
urlMethods,
|
urlMethods,
|
||||||
urlProperties,
|
urlProperties,
|
||||||
urlSearchParamsMethods,
|
urlSearchParamsMethods,
|
||||||
|
urlStatics,
|
||||||
urlWritableProperties,
|
urlWritableProperties,
|
||||||
invokeUriFunction,
|
invokeUriFunction,
|
||||||
uriArgument,
|
uriArgument,
|
||||||
|
|
@ -83,6 +86,32 @@ import {
|
||||||
CodeModeURLSearchParams,
|
CodeModeURLSearchParams,
|
||||||
} from "../values.js"
|
} from "../values.js"
|
||||||
|
|
||||||
|
const globalStaticMembers: Partial<Record<GlobalNamespaceName, Set<string>>> = {
|
||||||
|
Object: objectStatics,
|
||||||
|
Math: mathMethods,
|
||||||
|
JSON: jsonStatics,
|
||||||
|
Array: arrayStatics,
|
||||||
|
console: consoleMethods,
|
||||||
|
Date: dateStatics,
|
||||||
|
URL: urlStatics,
|
||||||
|
}
|
||||||
|
|
||||||
|
const calleeDescription = (callee: AstNode): string => {
|
||||||
|
if (callee.type === "Identifier") return getString(callee, "name")
|
||||||
|
if (callee.type === "MemberExpression") {
|
||||||
|
const object = getNode(callee, "object")
|
||||||
|
const property = getNode(callee, "property")
|
||||||
|
const key =
|
||||||
|
callee.computed !== true && property.type === "Identifier"
|
||||||
|
? getString(property, "name")
|
||||||
|
: property.type === "Literal" && typeof property.value === "string"
|
||||||
|
? property.value
|
||||||
|
: undefined
|
||||||
|
if (object.type === "Identifier" && key !== undefined) return `${getString(object, "name")}.${key}`
|
||||||
|
}
|
||||||
|
return "The called value"
|
||||||
|
}
|
||||||
|
|
||||||
const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => {
|
const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => {
|
||||||
if (rhs instanceof ErrorConstructorReference) {
|
if (rhs instanceof ErrorConstructorReference) {
|
||||||
const brand = errorBrandName(lhs)
|
const brand = errorBrandName(lhs)
|
||||||
|
|
@ -199,6 +228,8 @@ export class Interpreter<R> {
|
||||||
globalScope.set("console", { mutable: false, value: new GlobalNamespace("console") })
|
globalScope.set("console", { mutable: false, value: new GlobalNamespace("console") })
|
||||||
globalScope.set("parseInt", { mutable: false, value: new CoercionFunction("parseInt") })
|
globalScope.set("parseInt", { mutable: false, value: new CoercionFunction("parseInt") })
|
||||||
globalScope.set("parseFloat", { mutable: false, value: new CoercionFunction("parseFloat") })
|
globalScope.set("parseFloat", { mutable: false, value: new CoercionFunction("parseFloat") })
|
||||||
|
globalScope.set("isFinite", { mutable: false, value: new CoercionFunction("isFinite") })
|
||||||
|
globalScope.set("isNaN", { mutable: false, value: new CoercionFunction("isNaN") })
|
||||||
globalScope.set("Date", { mutable: false, value: new GlobalNamespace("Date") })
|
globalScope.set("Date", { mutable: false, value: new GlobalNamespace("Date") })
|
||||||
globalScope.set("RegExp", { mutable: false, value: new GlobalNamespace("RegExp") })
|
globalScope.set("RegExp", { mutable: false, value: new GlobalNamespace("RegExp") })
|
||||||
globalScope.set("Map", { mutable: false, value: new GlobalNamespace("Map") })
|
globalScope.set("Map", { mutable: false, value: new GlobalNamespace("Map") })
|
||||||
|
|
@ -1454,10 +1485,23 @@ export class Interpreter<R> {
|
||||||
throw new InterpreterRuntimeError(`Unsupported update operator '${operator}'.`, node)
|
throw new InterpreterRuntimeError(`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.
|
||||||
|
const operand = (current: unknown): number => {
|
||||||
|
if (containsOpaqueReference(current)) {
|
||||||
|
throw new InterpreterRuntimeError(
|
||||||
|
`'${operator}' requires a data value in CodeMode.`,
|
||||||
|
argument,
|
||||||
|
"InvalidDataValue",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return coerceToNumber(current)
|
||||||
|
}
|
||||||
|
|
||||||
if (argument.type === "Identifier") {
|
if (argument.type === "Identifier") {
|
||||||
return Effect.sync(() => {
|
return Effect.sync(() => {
|
||||||
const name = getString(argument, "name")
|
const name = getString(argument, "name")
|
||||||
const current = Number(this.scopes.get(name, argument))
|
const current = operand(this.scopes.get(name, argument))
|
||||||
const next = current + increment
|
const next = current + increment
|
||||||
this.scopes.set(name, next, argument)
|
this.scopes.set(name, next, argument)
|
||||||
return prefix ? next : current
|
return prefix ? next : current
|
||||||
|
|
@ -1466,7 +1510,7 @@ export class Interpreter<R> {
|
||||||
|
|
||||||
if (argument.type === "MemberExpression") {
|
if (argument.type === "MemberExpression") {
|
||||||
return this.modifyMember(argument, (current) => {
|
return this.modifyMember(argument, (current) => {
|
||||||
const value = Number(current)
|
const value = operand(current)
|
||||||
const next = value + increment
|
const next = value + increment
|
||||||
return Effect.succeed({ write: true, next, result: prefix ? next : value })
|
return Effect.succeed({ write: true, next, result: prefix ? next : value })
|
||||||
})
|
})
|
||||||
|
|
@ -1563,6 +1607,9 @@ export class Interpreter<R> {
|
||||||
callable.settle(args[0])
|
callable.settle(args[0])
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
if (callable === undefined || callable === null) {
|
||||||
|
throw new InterpreterRuntimeError(`${calleeDescription(callee)} is not a function.`, callee).as("TypeError")
|
||||||
|
}
|
||||||
throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee)
|
throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -1833,16 +1880,18 @@ export class Interpreter<R> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (objectValue instanceof GlobalNamespace) {
|
if (objectValue instanceof GlobalNamespace) {
|
||||||
if (typeof key !== "string" || isBlockedMember(key)) {
|
if (typeof key === "string" && isBlockedMember(key)) {
|
||||||
throw new InterpreterRuntimeError(
|
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available in CodeMode.`, propertyNode)
|
||||||
`${objectValue.name}.${String(key)} is not available in CodeMode.`,
|
|
||||||
propertyNode,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
if (typeof key !== "string") return new ComputedValue(undefined)
|
||||||
if (objectValue.name === "Math" && mathConstants.has(key)) {
|
if (objectValue.name === "Math" && mathConstants.has(key)) {
|
||||||
return new ComputedValue((Math as unknown as Record<string, number>)[key])
|
return new ComputedValue((Math as unknown as Record<string, number>)[key])
|
||||||
}
|
}
|
||||||
return new GlobalMethodReference(objectValue.name, key)
|
if (globalStaticMembers[objectValue.name]?.has(key)) {
|
||||||
|
return new GlobalMethodReference(objectValue.name, key)
|
||||||
|
}
|
||||||
|
// Unknown static members read as undefined so feature detection works like native JS.
|
||||||
|
return new ComputedValue(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof objectValue === "string") {
|
if (typeof objectValue === "string") {
|
||||||
|
|
@ -1858,12 +1907,21 @@ export class Interpreter<R> {
|
||||||
return new ComputedValue(undefined)
|
return new ComputedValue(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (objectValue instanceof CoercionFunction && typeof key === "string" && !isBlockedMember(key)) {
|
if (objectValue instanceof CoercionFunction) {
|
||||||
|
if (typeof key === "string" && isBlockedMember(key)) {
|
||||||
|
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available in CodeMode.`, propertyNode)
|
||||||
|
}
|
||||||
|
if (typeof key !== "string") return new ComputedValue(undefined)
|
||||||
if (objectValue.name === "Number" && numberConstants.has(key)) {
|
if (objectValue.name === "Number" && numberConstants.has(key)) {
|
||||||
return new ComputedValue((Number as unknown as Record<string, number>)[key])
|
return new ComputedValue((Number as unknown as Record<string, number>)[key])
|
||||||
}
|
}
|
||||||
if (objectValue.name === "Number" && numberStatics.has(key)) return new GlobalMethodReference("Number", key)
|
if (objectValue.name === "Number" && numberStatics.has(key)) {
|
||||||
if (objectValue.name === "String" && stringStatics.has(key)) return new GlobalMethodReference("String", key)
|
return new GlobalMethodReference("Number", key)
|
||||||
|
}
|
||||||
|
if (objectValue.name === "String" && stringStatics.has(key)) {
|
||||||
|
return new GlobalMethodReference("String", key)
|
||||||
|
}
|
||||||
|
return new ComputedValue(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (objectValue instanceof CodeModeDate) {
|
if (objectValue instanceof CodeModeDate) {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ export const dateMethods = new Set([
|
||||||
"getTimezoneOffset",
|
"getTimezoneOffset",
|
||||||
])
|
])
|
||||||
|
|
||||||
|
export const dateStatics = new Set(["now", "parse", "UTC"])
|
||||||
|
|
||||||
export const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNode): number => {
|
export const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNode): number => {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "now":
|
case "now":
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { type AstNode, InterpreterRuntimeError, supportedSyntaxMessage } from ".
|
||||||
import { typeofValue } from "../interpreter/references.js"
|
import { typeofValue } from "../interpreter/references.js"
|
||||||
import { copyIn, copyOut } from "../tool-runtime.js"
|
import { copyIn, copyOut } from "../tool-runtime.js"
|
||||||
|
|
||||||
|
export const jsonStatics = new Set(["parse", "stringify"])
|
||||||
|
|
||||||
export const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
export const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "stringify": {
|
case "stringify": {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import { boundedData, coerceToString } from "./value.js"
|
||||||
|
|
||||||
export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"])
|
export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"])
|
||||||
|
|
||||||
|
export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "is", "assign", "fromEntries"])
|
||||||
|
|
||||||
export const invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
export const invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||||
const requireObject = (): Record<string, unknown> => {
|
const requireObject = (): Record<string, unknown> => {
|
||||||
const input = args[0]
|
const input = args[0]
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ export const escapeRegexHint =
|
||||||
'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.'
|
'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.'
|
||||||
|
|
||||||
export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => {
|
export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => {
|
||||||
|
// Native parity: an undefined pattern behaves as an empty pattern.
|
||||||
|
if (arg === undefined) return new RegExp("", extraFlags)
|
||||||
if (arg instanceof CodeModeRegExp) return arg.regex
|
if (arg instanceof CodeModeRegExp) return arg.regex
|
||||||
if (typeof arg === "string") {
|
if (typeof arg === "string") {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -61,10 +61,19 @@ export const coerceToString = (value: unknown): string => {
|
||||||
export const coerceToNumber = (value: unknown): number => {
|
export const coerceToNumber = (value: unknown): number => {
|
||||||
if (value instanceof CodeModeDate) return value.time
|
if (value instanceof CodeModeDate) return value.time
|
||||||
if (isCodeModeValue(value)) return Number.NaN
|
if (isCodeModeValue(value)) return Number.NaN
|
||||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? Number.NaN : Number(value)
|
// Arrays coerce through our own string coercion: host Number(array) joins with host
|
||||||
|
// ToPrimitive, which throws on the null-prototype objects the interpreter produces.
|
||||||
|
if (Array.isArray(value)) return Number(coerceToString(value))
|
||||||
|
return value !== null && typeof value === "object" ? Number.NaN : Number(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node: AstNode): unknown => {
|
export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node: AstNode): unknown => {
|
||||||
|
// 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) {
|
||||||
|
if (ref.name === "Number") return 0
|
||||||
|
if (ref.name === "String") return ""
|
||||||
|
}
|
||||||
const raw = args[0]
|
const raw = args[0]
|
||||||
// Error values are plain SafeObjects; the boundedData path below would strip their brand.
|
// Error values are plain SafeObjects; the boundedData path below would strip their brand.
|
||||||
if (ref.name === "String" && errorBrandName(raw) !== undefined) return coerceToString(raw)
|
if (ref.name === "String" && errorBrandName(raw) !== undefined) return coerceToString(raw)
|
||||||
|
|
@ -72,12 +81,16 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node
|
||||||
if (ref.name === "Boolean") return true
|
if (ref.name === "Boolean") return true
|
||||||
if (ref.name === "Number") return coerceToNumber(raw)
|
if (ref.name === "Number") return coerceToNumber(raw)
|
||||||
if (ref.name === "String") return coerceToString(raw)
|
if (ref.name === "String") return coerceToString(raw)
|
||||||
|
if (ref.name === "isFinite") return Number.isFinite(coerceToNumber(raw))
|
||||||
|
if (ref.name === "isNaN") return Number.isNaN(coerceToNumber(raw))
|
||||||
if (ref.name === "parseInt") return parseInt(coerceToString(raw))
|
if (ref.name === "parseInt") return parseInt(coerceToString(raw))
|
||||||
return parseFloat(coerceToString(raw))
|
return parseFloat(coerceToString(raw))
|
||||||
}
|
}
|
||||||
const value = boundedData(raw, `${ref.name} input`)
|
const value = boundedData(raw, `${ref.name} input`)
|
||||||
if (ref.name === "Number") return coerceToNumber(value)
|
if (ref.name === "Number") return coerceToNumber(value)
|
||||||
if (ref.name === "Boolean") return Boolean(value)
|
if (ref.name === "Boolean") return Boolean(value)
|
||||||
|
if (ref.name === "isFinite") return Number.isFinite(coerceToNumber(value))
|
||||||
|
if (ref.name === "isNaN") return Number.isNaN(coerceToNumber(value))
|
||||||
if (ref.name === "parseInt") {
|
if (ref.name === "parseInt") {
|
||||||
const radix = args[1]
|
const radix = args[1]
|
||||||
if (radix !== undefined && typeof radix !== "number") {
|
if (radix !== undefined && typeof radix !== "number") {
|
||||||
|
|
|
||||||
|
|
@ -669,3 +669,177 @@ describe("destructuring assignment", () => {
|
||||||
expect(err.message).toContain("Property key must be a string or number")
|
expect(err.message).toContain("Property key must be a string or number")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("coercion parity: zero-argument coercion functions", () => {
|
||||||
|
test("Number() is 0 and String() is empty, unlike their undefined-argument forms", async () => {
|
||||||
|
expect(await value(`return Number()`)).toBe(0)
|
||||||
|
expect(await value(`return String()`)).toBe("")
|
||||||
|
expect(await value(`return Boolean()`)).toBe(false)
|
||||||
|
expect(await value(`return Number.isNaN(Number(undefined))`)).toBe(true)
|
||||||
|
expect(await value(`return String(undefined)`)).toBe("undefined")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parseInt() and parseFloat() stay NaN with no argument", async () => {
|
||||||
|
expect(await value(`return Number.isNaN(parseInt())`)).toBe(true)
|
||||||
|
expect(await value(`return Number.isNaN(parseFloat())`)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("coercion parity: global isFinite and isNaN", () => {
|
||||||
|
test("coerce their argument like native JS, unlike the Number statics", async () => {
|
||||||
|
expect(await value(`return isFinite("42")`)).toBe(true)
|
||||||
|
expect(await value(`return Number.isFinite("42")`)).toBe(false)
|
||||||
|
expect(await value(`return isNaN("oops")`)).toBe(true)
|
||||||
|
expect(await value(`return isNaN("42")`)).toBe(false)
|
||||||
|
expect(await value(`return isFinite(Infinity)`)).toBe(false)
|
||||||
|
expect(await value(`return isNaN(null)`)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("zero-argument forms match native", async () => {
|
||||||
|
expect(await value(`return isFinite()`)).toBe(false)
|
||||||
|
expect(await value(`return isNaN()`)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("read as functions", async () => {
|
||||||
|
expect(await value(`return typeof isFinite`)).toBe("function")
|
||||||
|
expect(await value(`return typeof isNaN`)).toBe("function")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("work as array callbacks", async () => {
|
||||||
|
expect(await value(`return [1, "2", "x", Infinity].filter(isFinite)`)).toEqual([1, "2"])
|
||||||
|
expect(await value(`return ["1", "x"].map(isNaN)`)).toEqual([false, true])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("coercion parity: arrays coerce to numbers through their string form", () => {
|
||||||
|
test("arrays with objects become NaN instead of crashing on host ToPrimitive", async () => {
|
||||||
|
expect(await value(`let x = [{}]; x++; return Number.isNaN(x)`)).toBe(true)
|
||||||
|
expect(await value(`return isFinite([{}])`)).toBe(false)
|
||||||
|
expect(await value(`return "abc".slice([{}])`)).toBe("abc")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("single-element and empty arrays match native Number()", async () => {
|
||||||
|
expect(await value(`return Number([5])`)).toBe(5)
|
||||||
|
expect(await value(`return Number([])`)).toBe(0)
|
||||||
|
expect(await value(`return Number.isNaN(Number([1, 2]))`)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("coercion parity: String method arguments coerce like native JS", () => {
|
||||||
|
test("includes and indexOf coerce numbers", async () => {
|
||||||
|
expect(await value(`return "v1.2".includes(1)`)).toBe(true)
|
||||||
|
expect(await value(`return "a2b".indexOf(2)`)).toBe(1)
|
||||||
|
expect(await value(`return "abc".includes("d")`)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("slice, repeat, and padStart coerce numeric strings", async () => {
|
||||||
|
expect(await value(`return "abc".slice("1")`)).toBe("bc")
|
||||||
|
expect(await value(`return "ab".repeat("2")`)).toBe("abab")
|
||||||
|
expect(await value(`return "7".padStart("3", 0)`)).toBe("007")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("split coerces separators but treats undefined as absent", async () => {
|
||||||
|
expect(await value(`return "a1b".split(1)`)).toEqual(["a", "b"])
|
||||||
|
expect(await value(`return "a,b".split(undefined)`)).toEqual(["a,b"])
|
||||||
|
expect(await value(`return "a,b".split()`)).toEqual(["a,b"])
|
||||||
|
expect(await value(`return "a,b".split(undefined, 0)`)).toEqual([])
|
||||||
|
expect(await value(`return "a,b".split(undefined, 1)`)).toEqual(["a,b"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("replace coerces search and replacement values", async () => {
|
||||||
|
expect(await value(`return "a1b".replace(1, 2)`)).toBe("a2b")
|
||||||
|
expect(await value(`return "a1b".replace(1, () => "x")`)).toBe("axb")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("repeat rejections carry the native RangeError name", async () => {
|
||||||
|
expect(await value(`try { "a".repeat(-1) } catch (e) { return e.name }`)).toBe("RangeError")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("includes, startsWith, and endsWith reject regular expressions with a TypeError", async () => {
|
||||||
|
expect(await value(`try { "abc".includes(/a/) } catch (e) { return e.name }`)).toBe("TypeError")
|
||||||
|
expect(await value(`try { "abc".startsWith(/a/) } catch (e) { return e.name }`)).toBe("TypeError")
|
||||||
|
expect(await value(`try { "abc".endsWith(/a/) } catch (e) { return e.name }`)).toBe("TypeError")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("opaque runtime references still reject as data errors", async () => {
|
||||||
|
const err = await error(`const f = () => 1; return "abc".includes(f)`)
|
||||||
|
expect(err.message).toContain("data value")
|
||||||
|
const replacerErr = await error(`const f = () => 1; return "a".replace(f, () => "x")`)
|
||||||
|
expect(replacerErr.message).toContain("data value")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("coercion parity: match() and search() with no argument", () => {
|
||||||
|
test("behave as an empty pattern like native JS", async () => {
|
||||||
|
expect(await value(`return "abc".search()`)).toBe(0)
|
||||||
|
expect(await value(`const m = "abc".match(); return { first: m[0], index: m.index }`)).toEqual({
|
||||||
|
first: "",
|
||||||
|
index: 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("coercion parity: ++ and -- use CodeMode numeric coercion", () => {
|
||||||
|
test("numeric strings increment like native JS", async () => {
|
||||||
|
expect(await value(`let x = "5"; x++; return x`)).toBe(6)
|
||||||
|
expect(await value(`let x = "5"; return ++x`)).toBe(6)
|
||||||
|
expect(await value(`const o = { n: "2" }; o.n--; return o.n`)).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("dates increment through their epoch time", async () => {
|
||||||
|
expect(await value(`let d = new Date(5); d++; return d`)).toBe(6)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("plain data objects become NaN instead of crashing", async () => {
|
||||||
|
expect(await value(`let x = {}; x++; return Number.isNaN(x)`)).toBe(true)
|
||||||
|
expect(await value(`const o = { a: {} }; o.a++; return Number.isNaN(o.a)`)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("opaque runtime references reject with a clear error", async () => {
|
||||||
|
const err = await error(`let f = () => 1; f++`)
|
||||||
|
expect(err.message).toContain("data value")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("coercion parity: unknown static members read as undefined", () => {
|
||||||
|
test("feature detection on missing statics works like native JS", async () => {
|
||||||
|
expect(await value(`return typeof Math.sumPrecise`)).toBe("undefined")
|
||||||
|
expect(await value(`return Object.groupBy === undefined`)).toBe(true)
|
||||||
|
expect(await value(`return RegExp.escape === undefined`)).toBe(true)
|
||||||
|
expect(await value(`return Number.range === undefined`)).toBe(true)
|
||||||
|
expect(await value(`return String.raw === undefined`)).toBe(true)
|
||||||
|
expect(await value(`return isFinite.something === undefined`)).toBe(true)
|
||||||
|
expect(await value(`return console.group === undefined`)).toBe(true)
|
||||||
|
expect(await value(`return Date.moment === undefined`)).toBe(true)
|
||||||
|
expect(await value(`return JSON.rawJSON === undefined`)).toBe(true)
|
||||||
|
expect(await value(`return URL.createObjectURL === undefined`)).toBe(true)
|
||||||
|
expect(await value(`return Map.groupBy === undefined`)).toBe(true)
|
||||||
|
expect(await value(`return Math.sumPrecise?.([1]) ?? "fallback"`)).toBe("fallback")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("known statics still resolve and run", async () => {
|
||||||
|
expect(await value(`return typeof Math.max`)).toBe("function")
|
||||||
|
expect(await value(`return typeof console.log`)).toBe("function")
|
||||||
|
expect(await value(`return typeof Date.now`)).toBe("function")
|
||||||
|
expect(await value(`return Math.max(1, 2)`)).toBe(2)
|
||||||
|
expect(await value(`return URL.canParse("https://example.com")`)).toBe(true)
|
||||||
|
expect(await value(`return Number.isInteger(3)`)).toBe(true)
|
||||||
|
expect(await value(`return Number.MAX_SAFE_INTEGER`)).toBe(Number.MAX_SAFE_INTEGER)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("calling an unknown static reports a native-style TypeError", async () => {
|
||||||
|
expect(await value(`try { Math.sumPrecise([1]) } catch (e) { return e.name + ": " + e.message }`)).toBe(
|
||||||
|
"TypeError: Math.sumPrecise is not a function.",
|
||||||
|
)
|
||||||
|
expect(await value(`try { Math["sumPrecise"]([1]) } catch (e) { return e.message }`)).toBe(
|
||||||
|
"Math.sumPrecise is not a function.",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("blocked members still throw instead of reading as undefined", async () => {
|
||||||
|
const err = await error(`return Math.constructor`)
|
||||||
|
expect(err.message).toContain("not available")
|
||||||
|
const coercionErr = await error(`return Number.constructor`)
|
||||||
|
expect(coercionErr.message).toContain("Number.constructor is not available")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue