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: 7 additions & 5 deletions packages/codemode/interpreter-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,9 +238,9 @@ ultimate source of truth.
use their epoch time) and reject opaque runtime references as data errors.
- [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,
example `Math.sum is not a function.` Blocked members (`constructor`, `__proto__`, ...) still throw,
and unknown `Promise` statics keep their descriptive error.
- [ ] `Math.sumPrecise`.
- [x] `Math.sumPrecise` over supported collection iterables, rejecting non-number elements without coercion.
- [x] Global coercing `isFinite` and `isNaN`; opaque runtime references reject as data errors, like `Number(...)`.

## JSON and console
Expand Down Expand Up @@ -275,15 +275,17 @@ ultimate source of truth.

- [x] Literal and `RegExp(pattern, flags)` construction, with or without `new`.
- [x] `test`, `exec`, and `toString`.
- [x] Readable `source`, `flags`, `lastIndex`, `global`, `ignoreCase`, `multiline`, `sticky`, `unicode`, and `dotAll`.
- [x] Readable `source`, `flags`, `lastIndex`, `hasIndices`, `global`, `ignoreCase`, `multiline`, `sticky`, `unicode`,
`unicodeSets`, and `dotAll`.
- [x] Captures, safe named groups (blocked member names are omitted), match `.index`, and stateful global matching.
- [x] Integration with supported String methods, including function replacers.
- [x] Writable `lastIndex`.
- [ ] `hasIndices`, match `indices`, and `unicodeSets` metadata for the `d` and `v` flags.
- [ ] `RegExp.escape`.
- [x] Match `indices` metadata for the `d` flag, including named groups on `exec`, `match`, and `matchAll` results.
- [x] `RegExp.escape`.

## Map and Set

- [ ] Static `Map.groupBy`.
- [x] `new Map()` from entry arrays or another Map.
- [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, and `forEach`.
- [x] `new Set()` from arrays, strings, or another Set.
Expand Down
10 changes: 3 additions & 7 deletions packages/codemode/src/interpreter/methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { invokeJsonMethod } from "../stdlib/json.js"
import { invokeMathMethod } from "../stdlib/math.js"
import { invokeNumberMethod, invokeNumberStatic } from "../stdlib/number.js"
import { invokeObjectMethod } from "../stdlib/object.js"
import { invokeRegExpMethod, matchToValue, toHostRegex } from "../stdlib/regexp.js"
import { invokeRegExpMethod, invokeRegExpStatic, matchToValue, toHostRegex } from "../stdlib/regexp.js"
import { invokeStringStatic } from "../stdlib/string.js"
import { invokeURLMethod, invokeURLStatic, uriArgument } from "../stdlib/url.js"
import { boundedData, coerceToNumber, coerceToString, errorBrandName } from "../stdlib/value.js"
Expand Down Expand Up @@ -126,12 +126,8 @@ export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unkno
if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node)
if (ref.namespace === "URL") return invokeURLStatic(ref.name, args, node)
if (ref.namespace === "Date") return invokeDateStatic(ref.name, args, node)
if (
ref.namespace === "RegExp" ||
ref.namespace === "Map" ||
ref.namespace === "Set" ||
ref.namespace === "URLSearchParams"
) {
if (ref.namespace === "RegExp") return invokeRegExpStatic(ref.name, args, node)
if (ref.namespace === "Map" || ref.namespace === "Set" || ref.namespace === "URLSearchParams") {
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node)
}
return invokeJsonMethod(ref.name, args, node)
Expand Down
9 changes: 8 additions & 1 deletion packages/codemode/src/interpreter/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,13 @@ import { mathConstants, mathMethods } from "../stdlib/math.js"
import { numberConstants, numberMethods, numberStatics } from "../stdlib/number.js"
import { objectMethodsPreservingIdentity, objectStatics } from "../stdlib/object.js"
import { promiseStatics } from "../stdlib/promise.js"
import { escapeRegexHint, regexpMethods, regexpProperties, regexFailureReason } from "../stdlib/regexp.js"
import {
escapeRegexHint,
regexpMethods,
regexpProperties,
regexpStatics,
regexFailureReason,
} from "../stdlib/regexp.js"
import { stringMethods, stringStatics } from "../stdlib/string.js"
import {
urlMethods,
Expand Down Expand Up @@ -93,6 +99,7 @@ const globalStaticMembers: Partial<Record<GlobalNamespaceName, Set<string>>> = {
Array: arrayStatics,
console: consoleMethods,
Date: dateStatics,
RegExp: regexpStatics,
URL: urlStatics,
}

Expand Down
23 changes: 22 additions & 1 deletion packages/codemode/src/stdlib/math.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { spreadItems } from "./collections.js"

// Bun exposes ES2026 Math.sumPrecise before TypeScript's standard library types.
declare global {
interface Math {
sumPrecise(values: Iterable<number>): number
}
}

export const mathConstants = new Set(["PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2"])

export const mathMethods = new Set([
Expand Down Expand Up @@ -37,11 +47,23 @@ export const mathMethods = new Set([
"fround",
"clz32",
"imul",
"sumPrecise",
])

export const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNode): number => {
if (!mathMethods.has(name)) throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node)
if (name === "random") return Math.random()
if (name === "sumPrecise") {
const items = spreadItems(args[0])
if (items === undefined) {
throw new InterpreterRuntimeError("Math.sumPrecise expects an iterable collection.", node).as("TypeError")
}
const numbers = Array.from(items)
if (!numbers.every((item): item is number => typeof item === "number")) {
throw new InterpreterRuntimeError("Math.sumPrecise expects an iterable of numbers.", node).as("TypeError")
}
return Math.sumPrecise(numbers)
}
// Validate only the arguments the method consumes; like JS, extras are ignored
// (so built-ins work as callbacks receiving (element, index, array)).
const num = (index: number): number => {
Expand Down Expand Up @@ -131,4 +153,3 @@ export const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNo
}
throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node)
}
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
52 changes: 45 additions & 7 deletions packages/codemode/src/stdlib/regexp.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,33 @@
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
import { CodeModeRegExp } from "../values.js"
import { coerceToNumber, coerceToString } from "./value.js"

type MatchValue = Array<unknown> & {
index?: number
groups?: SafeObject
indices?: IndicesValue
}

type IndicesValue = Array<unknown> & {
groups?: SafeObject
}

export const regexpMethods = new Set(["test", "exec", "toString"])

export const regexpStatics = new Set(["escape"])

export const regexpProperties = new Set([
"source",
"flags",
"lastIndex",
"hasIndices",
"global",
"ignoreCase",
"multiline",
"sticky",
"unicode",
"unicodeSets",
"dotAll",
])

Expand Down Expand Up @@ -39,18 +58,27 @@ export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFl
}

export const matchToValue = (match: RegExpMatchArray): Array<unknown> => {
const result: Array<unknown> = Array.from(match, (group) => group)
if (match.index !== undefined) (result as Record<string, unknown> & Array<unknown>).index = match.index
const result: MatchValue = Array.from(match, (group) => group)
if (match.index !== undefined) result.index = match.index
if (match.groups) {
const groups: SafeObject = Object.create(null) as SafeObject
for (const [key, group] of Object.entries(match.groups)) {
if (!isBlockedMember(key)) groups[key] = group
}
;(result as Record<string, unknown> & Array<unknown>).groups = groups
result.groups = groups
}
if (match.indices) result.indices = indicesToValue(match.indices)
return result
}

export const invokeRegExpStatic = (name: string, args: Array<unknown>, node: AstNode): string => {
if (name !== "escape") throw new InterpreterRuntimeError(`RegExp.${name} is not available in CodeMode.`, node)
if (typeof args[0] !== "string") {
throw new InterpreterRuntimeError("RegExp.escape expects a string.", node).as("TypeError")
}
return RegExp.escape(args[0])
}

export const invokeRegExpMethod = (
value: CodeModeRegExp,
name: string,
Expand Down Expand Up @@ -85,7 +113,17 @@ const toLength = (value: unknown): number => {
if (Number.isNaN(number) || number <= 0) return 0
return Math.min(Math.floor(number), Number.MAX_SAFE_INTEGER)
}
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
import { CodeModeRegExp } from "../values.js"
import { coerceToNumber, coerceToString } from "./value.js"

const indicesToValue = (indices: RegExpIndicesArray): IndicesValue => {
const result: IndicesValue = Array.from(indices, (range) => (range === undefined ? undefined : [...range]))
if (indices.groups) {
const groups: SafeObject = Object.create(null) as SafeObject
for (const [key, range] of Object.entries(indices.groups)) {
if (!isBlockedMember(key)) groups[key] = range === undefined ? undefined : [...range]
}
result.groups = groups
return result
}
result.groups = undefined
return result
}
18 changes: 10 additions & 8 deletions packages/codemode/test/parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -856,9 +856,9 @@ describe("coercion parity: ++ and -- use CodeMode numeric coercion", () => {

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 typeof Math.sum`)).toBe("undefined")
expect(await value(`return Object.groupBy === undefined`)).toBe(true)
expect(await value(`return RegExp.escape === undefined`)).toBe(true)
expect(await value(`return RegExp.quote === 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)
Expand All @@ -867,26 +867,28 @@ describe("coercion parity: unknown static members read as undefined", () => {
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")
expect(await value(`return Math.sum?.([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 typeof Math.sumPrecise`)).toBe("function")
expect(await value(`return typeof RegExp.escape`)).toBe("function")
expect(await value(`return Math.max(1, 2)`)).toBe(2)
expect(await value(`return Math.sumPrecise([1, 2])`)).toBe(3)
expect(await value(`return RegExp.escape("a.b")`)).toBe("\\x61\\.b")
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.",
expect(await value(`try { Math.sum([1]) } catch (e) { return e.name + ": " + e.message }`)).toBe(
"TypeError: Math.sum is not a function.",
)
expect(await value(`try { Math["sum"]([1]) } catch (e) { return e.message }`)).toBe("Math.sum is not a function.")
})

test("blocked members still throw instead of reading as undefined", async () => {
Expand Down
Loading
Loading