Compare commits

...
Author SHA1 Message Date
rekram1-node af8eb4403c fix(tui): warn when launch directory falls back 2026-09-25 05:27:22 +00:00
Aiden Cline 5335347e80 feat(codemode): add WeakMap and WeakSet (#51257) 2026-09-25 00:15:19 -05:00
Aiden Cline 16b18dff13 Revert "fix(core): fit model limits and recover compaction overflow" (#51273) 2026-09-25 00:12:14 -05:00
Aiden Cline 61c2349cef fix(core): fit model limits and recover compaction overflow (#51238) 2026-09-25 00:11:40 -05:00
opencode-agent[bot]andBrendonovich 684721efb8 feat(app): add provider account switching (#51266)
Co-authored-by: Brendonovich <Brendonovich@users.noreply.github.com>
2026-09-25 05:03:33 +00:00
Aiden Cline 962c14a49c fix(codemode): honor thisArg, program toString in computed keys, and ToPrimitive in String and Number arguments (#51264) 2026-09-24 23:46:15 -05:00
opencode-agent[bot]andrekram1-node 85b98e7da4 fix(tui): handle storage watcher errors after startup (#51243)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-24 20:25:06 -05:00
Aiden Cline 8061220b08 test(codemode): vendor every eligible test262 directory and bound unsupported globals (#51242) 2026-09-24 20:19:24 -05:00
Luke Parker b02cc35f13 fix(desktop): keep browser page visible under floating content (#51240) 2026-09-25 10:35:17 +10:00
Aiden Cline e23d89c9a9 fix(codemode): destructure object patterns from primitives and convert Date components through ToPrimitive (#51239) 2026-09-24 19:32:19 -05:00
e8b3e19e85 fix(tui): don't crash when fs.watch throws (e.g. ENOSPC) (#51210)
Co-authored-by: Alireza Haghdoost <haghdoost@uber.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 19:16:43 -05:00
Aiden Cline 5256f30957 feat(codemode): honor program valueOf and toString in operators and conversions (#50837) 2026-09-24 18:45:48 -05:00
47 changed files with 2501 additions and 591 deletions
@@ -2,7 +2,7 @@ import { DialogProvider } from "@opencode/ui/context/dialog"
import { Browser } from "@opencode/plugin-browser/rpc"
import { For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { render } from "solid-js/web"
import { Portal, render } from "solid-js/web"
import { LanguageProvider, UiI18nBridge } from "../src/runtime/i18n/language"
import type { BrowserPaneLayout, BrowserPaneRegistration } from "../src/runtime/platform/browser-pane"
import type { createSessionBrowser } from "../src/session/browser/model"
@@ -27,7 +27,12 @@ export function mountBrowserPane() {
loadErrors: {} as Record<string, string | undefined>,
error: undefined as string | undefined,
layouts: {} as Record<string, BrowserPaneLayout | undefined>,
covered: false,
captures: 0,
holdCapture: false,
})
// Each capture waits until the fixture releases it, so a spec can observe the pending state.
const held: (() => void)[] = []
const tabs = ["Alpha", "Beta"].map((name) => ({
id: Browser.TabID.make(`tab_${name === "Alpha" ? "11111111" : "22222222"}-1111-1111-1111-111111111111`),
title: name,
@@ -44,6 +49,17 @@ export function mountBrowserPane() {
{
setLayout: (layout) => setStore("layouts", tab.title, layout),
command: async () => undefined,
capture: async () => {
setStore("captures", (count) => count + 1)
if (store.holdCapture) await new Promise<void>((resolve) => held.push(resolve))
const canvas = new OffscreenCanvas(4, 4)
const paint = canvas.getContext("2d")
if (paint) {
paint.fillStyle = "#3b82f6"
paint.fillRect(0, 0, 4, 4)
}
return canvas.convertToBlob()
},
close: () => undefined,
},
]),
@@ -118,12 +134,34 @@ export function mountBrowserPane() {
Complete navigation
</button>
<button onClick={() => setStore("visible", (visible) => !visible)}>Toggle Review tab</button>
<button onClick={() => setStore("holdCapture", true)}>Hold capture</button>
<button onClick={() => held.splice(0).forEach((resolve) => resolve())}>Release capture</button>
<button onClick={() => setStore("covered", (covered) => !covered)}>Toggle popover</button>
</nav>
<div style={{ width: "640px", height: "360px", border: "1px solid #555" }}>
<p>Captures: {store.captures}</p>
<div style={{ position: "relative", width: "640px", height: "360px", border: "1px solid #555" }}>
<Show when={store.mounted}>
<SessionBrowserPane browser={browser} visible={store.visible} />
</Show>
</div>
<Show when={store.covered}>
{/* Floating content portals into <body> like a menu or hover card over the page. */}
<Portal mount={document.body}>
<div
data-popper-positioner
data-testid="fixture-popover"
style={{
position: "fixed",
top: "0",
left: "0",
width: "320px",
height: "480px",
"z-index": "1001",
"pointer-events": "none",
}}
/>
</Portal>
</Show>
<h2 style={{ "font-size": "18px", margin: "20px 0 12px" }}>Native layout recorder</h2>
<p>The desktop boundary keeps each session's page visible until its registration is hidden.</p>
<For each={tabs}>
@@ -58,6 +58,27 @@ story("hides the native view immediately while the pane stays mounted", async ({
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("keeps a still of the page under floating content that covers it", async ({ page }, testInfo) => {
const root = page.getByTestId("browser-pane-fixture")
const still = root.locator("#browser-panel img")
await root.getByRole("button", { name: "Hold capture", exact: true }).click()
await root.getByRole("button", { name: "Toggle popover", exact: true }).click()
await expect(root.getByText("Captures: 1", { exact: true })).toBeVisible()
// The native page stays up until its still is ready, so the pane never shows blank.
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(still).toHaveCount(0)
await root.getByRole("button", { name: "Release capture", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await expect(still).toBeVisible()
await page.screenshot({ path: testInfo.outputPath("covered.png") })
await root.getByRole("button", { name: "Toggle popover", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(still).toHaveCount(0)
await expect(root.getByText("Captures: 1", { exact: true })).toBeVisible()
})
story("shows the empty state over a blank native page and restores navigation", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Blank page", exact: true }).click()
+9
View File
@@ -1439,6 +1439,15 @@ export const dict = {
"settings.providers.section.connected": "Connected providers",
"settings.providers.connected.empty": "No connected providers",
"settings.providers.connected.environmentDescription": "Connected from your environment variables",
"settings.providers.account.manage": "Manage {{provider}} accounts",
"settings.providers.account.group": "Accounts",
"settings.providers.account.add": "Add account",
"settings.providers.account.remove": "Remove account…",
"settings.providers.account.active": "Active",
"settings.providers.account.switched.title": "{{provider}} account switched",
"settings.providers.account.switched.description": "Now using {{account}}.",
"settings.providers.account.removed.title": "{{account}} removed",
"settings.providers.account.removed.description": "{{provider}} will no longer use this account.",
"settings.providers.console.available.one": "{{count}} provider available",
"settings.providers.console.available.other": "{{count}} providers available",
"settings.providers.section.popular": "Popular providers",
@@ -25,6 +25,8 @@ export type BrowserPaneEvent =
export type BrowserPaneRegistration = {
setLayout(layout?: BrowserPaneLayout): void
command(command: BrowserPaneCommand): Promise<void>
/** Captures the shown page, or resolves null when nothing is on screen. */
capture(tabID: Browser.TabID): Promise<Blob | null>
close(): void
}
@@ -43,6 +43,9 @@ function fixture() {
async command(command) {
call.commands.push(command)
},
async capture() {
return null
},
close() {
call.closed = true
},
+63 -2
View File
@@ -11,6 +11,7 @@ import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useCommand } from "@/shell/commands/command"
import type { Browser } from "@opencode/plugin-browser/rpc"
import type { createSessionBrowser } from "./model"
export function SessionBrowserPane(props: { browser: ReturnType<typeof createSessionBrowser>; visible: boolean }) {
@@ -30,6 +31,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
// A submitted navigation the browser has not reported yet; keeps the empty state hidden meanwhile.
navigating: false,
visible: typeof document === "undefined" || document.visibilityState === "visible",
// A still of the page shown in the DOM while floating content covers the hidden native view.
snapshot: undefined as { tabID: Browser.TabID; url: string } | undefined,
})
const empty = () => !address() && !state()?.loading && !store.navigating
let surface: HTMLDivElement | undefined
@@ -37,6 +40,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
let frame: number | undefined
let layout: string | undefined
let until = 0
let capturing: Browser.TabID | undefined
let release: ReturnType<typeof setTimeout> | undefined
const canvas = document.createElement("canvas")
canvas.width = canvas.height = 1
const paint = canvas.getContext("2d", { willReadFrequently: true })
@@ -69,6 +74,45 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
const r = el.getBoundingClientRect()
return r.width > 0 && r.left < rect.right && r.right > rect.left && r.top < rect.bottom && r.bottom > rect.top
})
const replaceSnapshot = (next?: { tabID: Browser.TabID; url: string }) => {
if (store.snapshot?.url) URL.revokeObjectURL(store.snapshot.url)
setStore("snapshot", next)
}
// Keep the page on screen as a still under the floating content. The native view
// stays visible until the still has decoded, so the pane never flashes blank.
const freeze = (tabID: Browser.TabID) => {
clearTimeout(release)
release = undefined
if (store.snapshot?.tabID === tabID || capturing === tabID) return
capturing = tabID
void (registration()?.capture(tabID) ?? Promise.resolve(null))
.catch(() => null)
.then(async (blob) => {
const url = blob ? URL.createObjectURL(blob) : ""
if (url) {
const image = new Image()
image.src = url
await image.decode().catch(() => undefined)
}
if (capturing !== tabID) {
if (url) URL.revokeObjectURL(url)
return
}
capturing = undefined
// A failed capture still hides the page; the pane shows its background as before.
replaceSnapshot({ tabID, url })
schedule()
})
}
const thaw = () => {
capturing = undefined
if (!store.snapshot || release !== undefined) return
// Keep the still under the native view until the view has painted again.
release = setTimeout(() => {
release = undefined
replaceSnapshot()
}, 150)
}
const measure = () => {
if (!surface) return
const tab = state()
@@ -84,7 +128,11 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
const bottom = Math.round(rect.bottom * zoom)
// The desktop page hides blank and loading documents itself; only hide here
// while the pane shows its own empty or failed state over the surface.
const visible = props.visible && store.visible && !empty() && !failed() && !dialog.active && !covered(rect)
const shown = props.visible && store.visible && !empty() && !failed() && !dialog.active
const cover = covered(rect)
if (shown && cover) freeze(tab.id)
if (!cover) thaw()
const visible = shown && !(cover && store.snapshot?.tabID === tab.id)
// The cutout exposes the app backdrop outside the rounded Review card,
// not the browser surface inside it.
const color = getComputedStyle(
@@ -186,6 +234,9 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible"))
onCleanup(() => {
if (frame !== undefined) cancelAnimationFrame(frame)
clearTimeout(release)
capturing = undefined
replaceSnapshot()
})
return (
@@ -296,7 +347,17 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
{error()}
</div>
</Show>
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base flex items-center justify-center">
<div ref={surface} class="relative min-h-0 flex-1 bg-v2-background-bg-base flex items-center justify-center">
<Show when={store.snapshot?.tabID === state()?.id && !empty() && !failed() && store.snapshot?.url}>
{(url) => (
<img
src={url()}
alt=""
draggable={false}
class="absolute inset-0 size-full pointer-events-none select-none"
/>
)}
</Show>
<Show when={(empty() || failed()) && !props.browser.suspended()}>
{/* Add the 40px toolbar to the file empty state's 160px bottom padding to align their centers. */}
<div
@@ -0,0 +1,34 @@
import { describe, expect, test } from "bun:test"
import type { IntegrationInfo } from "@opencode/client/promise"
import { activeProviderAccount, providerAccounts } from "./accounts"
const integration = (connections: IntegrationInfo["connections"]): IntegrationInfo => ({
id: "openai",
name: "OpenAI",
methods: [],
connections,
})
describe("provider accounts", () => {
test("preserves the server's active-first credential order", () => {
const value = integration([
{ type: "credential", id: "cred_work", label: "Work", method: "key" },
{ type: "env", name: "OPENAI_API_KEY" },
{ type: "credential", id: "cred_personal", label: "Personal", method: "oauth" },
])
expect(providerAccounts(value)).toEqual([
{ type: "credential", id: "cred_work", label: "Work", method: "key" },
{ type: "credential", id: "cred_personal", label: "Personal", method: "oauth" },
])
expect(activeProviderAccount(value)).toEqual({ type: "credential", id: "cred_work", label: "Work", method: "key" })
})
test("returns no active account for environment-only integrations", () => {
const value = integration([{ type: "env", name: "OPENAI_API_KEY" }])
expect(providerAccounts(value)).toEqual([])
expect(activeProviderAccount(value)).toBeUndefined()
expect(providerAccounts(undefined)).toEqual([])
})
})
@@ -0,0 +1,11 @@
import type { ConnectionInfo, IntegrationInfo } from "@opencode/client/promise"
export type ProviderAccount = Extract<ConnectionInfo, { type: "credential" }>
export function providerAccounts(integration: IntegrationInfo | undefined) {
return integration?.connections.filter((connection): connection is ProviderAccount => connection.type === "credential") ?? []
}
export function activeProviderAccount(integration: IntegrationInfo | undefined) {
return providerAccounts(integration)[0]
}
+162 -19
View File
@@ -2,6 +2,7 @@ import { Button } from "@opencode/ui/button"
import { Badge } from "@opencode/ui/badge"
import { useDialog } from "@opencode/ui/context/dialog"
import { Icon } from "@opencode/ui/icon"
import { Menu } from "@opencode/ui/menu"
import { OpenCodeLogo } from "@/providers/opencode-logo"
import { showToast } from "@/shell/notifications/toast"
import { popularProviders, useProviders } from "@/providers/catalog/providers"
@@ -16,6 +17,7 @@ import { CONSOLE_INTEGRATION, CONSOLE_PROVIDERS } from "@/providers/connect/cont
import { DialogConnectProvider, useProviderConnectController } from "@/providers/connect/dialog"
import { ProviderModelIcon } from "@/providers/models/provider-group"
import { SettingsList } from "@/settings/list"
import { activeProviderAccount, providerAccounts, type ProviderAccount } from "./accounts"
import "@/settings/settings.css"
type ProviderSource = "env" | "api" | "account" | "config" | "custom"
@@ -47,6 +49,7 @@ export const SettingsProviders: Component<{
disconnecting: {} as Record<string, "removing" | "removed" | "absent" | undefined>,
consoleExpanded: false,
connecting: false,
credentialID: undefined as string | undefined,
})
const updateDisconnecting = (ids: string[], status: "removing" | "removed" | "absent" | undefined) =>
setState("disconnecting", (current) => ({
@@ -190,6 +193,8 @@ export const SettingsProviders: Component<{
return currentSource !== "env" && currentSource !== "config"
}
const canManageAccounts = (item: ProviderItem) => providerAccounts(integration(item)).length > 0
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const disconnect = async (item: ProviderItem, name: string) => {
@@ -230,6 +235,132 @@ export const SettingsProviders: Component<{
})
}
const refreshAccounts = async () => {
const location = props.directory ? { directory: props.directory } : undefined
data.location.integration.invalidate(location)
data.location.provider.invalidate(location)
data.location.model.invalidate(location)
await Promise.all([
data.location.integration.sync(location),
data.location.provider.sync(location),
data.location.model.sync(location),
])
}
const accountError = (error: unknown) => {
const message = error instanceof Error ? error.message : String(error)
showToast({ title: language.t("common.requestFailed"), description: message })
}
const activate = async (provider: ProviderItem, providerName: string, account: ProviderAccount) => {
if (activeProviderAccount(integration(provider))?.id === account.id) return
setState("credentialID", account.id)
await serverSdk.api.credential
.activate({ credentialID: account.id })
.then(refreshAccounts)
.then(() =>
showToast({
variant: "success",
icon: "circle-check",
title: language.t("settings.providers.account.switched.title", { provider: providerName }),
description: language.t("settings.providers.account.switched.description", { account: account.label }),
}),
)
.catch(accountError)
.finally(() => setState("credentialID", undefined))
}
const remove = async (provider: ProviderItem, providerName: string, account: ProviderAccount) => {
const final = providerAccounts(integration(provider)).length === 1
setState("credentialID", account.id)
await serverSdk.api.credential
.remove({ credentialID: account.id })
.then(refreshAccounts)
.then(() =>
showToast({
variant: "success",
icon: "circle-check",
title: language.t(
final ? "provider.disconnect.toast.disconnected.title" : "settings.providers.account.removed.title",
final ? { provider: providerName } : { account: account.label },
),
description: language.t(
final
? "provider.disconnect.toast.disconnected.description"
: "settings.providers.account.removed.description",
{ provider: providerName },
),
}),
)
.catch(accountError)
.finally(() => setState("credentialID", undefined))
}
function AccountMenu(menuProps: { provider: ProviderItem; name?: string }) {
const accounts = () => providerAccounts(integration(menuProps.provider))
const active = () => activeProviderAccount(integration(menuProps.provider))
const name = () => menuProps.name ?? menuProps.provider.name
return (
<Menu placement="bottom-end" gutter={6}>
<Menu.Trigger
as={Button}
size="normal"
variant="ghost-muted"
class="settings-provider-account-trigger"
aria-label={language.t("settings.providers.account.manage", { provider: name() })}
>
<span>{active()?.label}</span>
<Icon name="chevron-down" size="small" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="settings-provider-account-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
<Menu.Group>
<Menu.GroupLabel>{language.t("settings.providers.account.group")}</Menu.GroupLabel>
<Menu.RadioGroup
class="settings-provider-account-list"
value={active()?.id}
onChange={(credentialID) => {
const account = accounts().find((item) => item.id === credentialID)
if (account) void activate(menuProps.provider, name(), account)
}}
>
<For each={accounts()}>
{(account) => (
<Menu.RadioItem value={account.id} closeOnSelect disabled={state.credentialID !== undefined}>
<span class="settings-provider-account-label">{account.label}</span>
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</Menu.Group>
<Menu.Separator />
<Menu.Item disabled={state.credentialID !== undefined} onSelect={() => connect(menuProps.provider.id)}>
{language.t("settings.providers.account.add")}
</Menu.Item>
<Menu.Sub placement="left-start">
<Menu.SubTrigger disabled={state.credentialID !== undefined || accounts().length === 0}>
{language.t("settings.providers.account.remove")}
</Menu.SubTrigger>
<Menu.SubContent class="settings-provider-account-submenu">
<For each={accounts()}>
{(account) => (
<Menu.Item
badge={account.id === active()?.id ? language.t("settings.providers.account.active") : undefined}
onSelect={() => void remove(menuProps.provider, name(), account)}
>
<span class="settings-provider-account-label">{account.label}</span>
</Menu.Item>
)}
</For>
</Menu.SubContent>
</Menu.Sub>
</Menu.Content>
</Menu.Portal>
</Menu>
)
}
return (
<>
<div class="settings-tab-header">
@@ -268,22 +399,27 @@ export const SettingsProviders: Component<{
</div>
</div>
<Show
when={canDisconnect(item)}
when={canManageAccounts(item)}
fallback={
<span class="settings-provider-env-hint">
{language.t("settings.providers.connected.environmentDescription")}
</span>
<Show
when={canDisconnect(item)}
fallback={
<span class="settings-provider-env-hint">
{language.t("settings.providers.connected.environmentDescription")}
</span>
}
>
<Button
size="normal"
variant="ghost-muted"
onClick={() => void disconnect(item, item.name)}
>
{language.t("common.disconnect")}
</Button>
</Show>
}
>
<Button
size="normal"
variant="ghost-muted"
onClick={() =>
void disconnect(item, item.name)
}
>
{language.t("common.disconnect")}
</Button>
<AccountMenu provider={item} />
</Show>
</div>
}
@@ -326,13 +462,20 @@ export const SettingsProviders: Component<{
</Show>
</div>
</div>
<Button
size="normal"
variant="ghost-muted"
onClick={() => void disconnect(item, language.t("provider.connect.opencode.name"))}
<Show
when={canManageAccounts(item)}
fallback={
<Button
size="normal"
variant="ghost-muted"
onClick={() => void disconnect(item, language.t("provider.connect.opencode.name"))}
>
{language.t("common.disconnect")}
</Button>
}
>
{language.t("common.disconnect")}
</Button>
<AccountMenu provider={item} name={language.t("provider.connect.opencode.name")} />
</Show>
</div>
<Show when={state.consoleExpanded}>
<div class="settings-provider-console-list">
+41
View File
@@ -887,6 +887,47 @@
opacity: 1;
}
.settings-provider-account-trigger {
min-width: 0;
max-width: min(240px, 45%);
}
.settings-provider-account-trigger > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-provider-account-menu[data-component="menu-v2-content"] {
width: min(260px, calc(100vw - 32px));
overflow: visible;
}
.settings-provider-account-list {
max-height: min(240px, calc(var(--kb-popper-content-available-height) - 120px));
overflow-y: auto;
}
.settings-provider-account-submenu[data-component="menu-v2-content"] {
width: min(260px, calc(100vw - 32px));
max-height: min(360px, var(--kb-popper-content-available-height));
overflow-y: auto;
}
.settings-provider-account-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@container settings-panel (max-width: 520px) {
.settings-provider-account-trigger {
max-width: 100%;
}
}
.settings-providers-view-all {
margin-top: 20px;
padding: 0;
+49 -27
View File
@@ -85,8 +85,9 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Array binding and assignment destructuring from strings, Maps, Sets, URLSearchParams, custom synchronous
iterators, and synchronous generators, including stepwise elisions/rest and `IteratorClose` on early completion
or binding/default failure.
- [ ] Object destructuring from primitives follows ToObject (`const { length } = "abc"`, `const {} = 1`); non-object
sources are rejected.
- [x] Object destructuring from primitives follows ToObject: `const { length } = "abc"` is `3`, `const { toFixed } = 1`
finds the built-in, `const {} = 1` is a no-op, and a rest element copies a string's indexes (`{ 1: "y", 2: "z" }`).
Only `null` and `undefined` sources throw (`Cannot destructure null as it is null.`).
- [x] Destructuring reads through the prototype chain like member access: `const { constructor } = error` and
`const { slice } = values` find the inherited built-in.
- [x] Any assignment target as a `for...in` head, like `for...of`: `for (x.y in obj)`, `for (a[i++] in obj)`, and
@@ -130,7 +131,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [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
does not consume are ignored, like JS, and consumed arguments coerce, like JS (`"3.7".replace(/\d\.\d/,
Math.floor)` is `"3"`). A detached method loses its receiver, as in JS: `values.filter("abc".includes)` is a `TypeError`
Math.floor)` is `"3"`). A detached method loses its receiver, as in JS: `values.filter("abc".includes)` is a `TypeError`
because `includes` is called without a string `this`.
- [x] Constructors work as callbacks with JS call semantics: `Error` types construct (`messages.map(Error)`),
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Headers`, `Promise`) throw a `TypeError`,
@@ -154,8 +155,9 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
array, an array-like object (its `length` clamped and capped like `Array.from`), or `null`/`undefined`. A
bound function is named `bound f`, has its remaining `length`, and is not constructible.
- [x] `JSON.parse` revivers and `JSON.stringify` function replacers see the holder object as `this`.
- [ ] The optional `thisArg` of iteration methods (`map`, `forEach`, `Map.prototype.forEach`, `Array.from`, …) is
accepted but not yet passed as `this`; callbacks run with `this` undefined.
- [x] The optional `thisArg` of the Array, Uint8Array, and `Array.from` callback methods and of Map, Set,
URLSearchParams, and Headers `forEach` is the callback's `this`: `[1, 2].forEach(function () { this.n++ }, c)`
increments `c.n` twice. Arrows ignore it, as in JS; `reduce`/`reduceRight` take an initial value instead.
- [ ] User-defined constructor calls.
- [ ] Classes and private fields.
- [x] Functions are objects: they hold own properties (`fn.count = 1`), enumerate them, and expose read-only `name`
@@ -224,8 +226,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,13 +235,26 @@ 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] Property keys follow ToPropertyKey: `x[null]`, `x[true]`, and objects (via their built-in string form) become
string keys.
- [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).
- [x] String and Number method arguments convert through ToPrimitive in spec order, receiver first: search strings,
separators, fills, and replacements with the string hint, indexes, counts, digits, and radixes with the number
hint (`"abc".indexOf({ toString() { return "b" } })` is `1`, `(255).toString({ valueOf() { return 16 } })` is
`"ff"`, `String.prototype.trim.call({ toString() { return " a " } })` is `"a"`). Only consumed positions
convert; a RegExp pattern is used as is, and `includes`/`startsWith`/`endsWith` reject one before converting.
- [ ] ToPrimitive elsewhere: `Error.prototype.toString` on an object `message` and numeric arguments of the Array and
Uint8Array methods (`at`, `indexOf` start, `slice`) still use the built-in form (`NaN`, `"[object Object]"`) and
ignore own methods.
- [x] Property keys follow ToPropertyKey: `x[null]` and `x[true]` become string keys, and a data object key
converts through its own `toString`/`valueOf` (string hint) exactly once per access, in reads, writes,
compound assignment, `++`, `delete`, `in`, object literals, and destructuring:
`o[{ toString() { return "id" } }] += 1` updates `o.id`. A nullish base throws before the key converts, as
in JS. Opaque values (functions, promises, tool references) keep their built-in string form.
## Promises and tools
@@ -300,8 +315,9 @@ reject }` object.
- [x] `Object()` and `new Object()` return `{}` for nullish arguments and pass objects through unchanged;
primitive wrapper objects (`Object(1)`) are rejected explicitly.
- [x] Computed property names and object spread. Any value works as a key (ToPropertyKey): strings, numbers, and the
two confined symbols as themselves, everything else as its string form (`o[null]` is `o["null"]`, `o[{}]` is
`o["[object Object]"]`), in reads, writes, literals, `in`, and destructuring.
two confined symbols as themselves, data objects through their own `toString` (`o[[1, 2]]` is `o["1,2"]`), and
everything else as its string form (`o[null]` is `o["null"]`), in reads, writes, literals, `in`, and
destructuring.
- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`, with
synchronous iterator support for `fromEntries`. Sources follow ToObject: strings enumerate by index, other
primitives and wrappers contribute nothing, and `null`/`undefined` throw. `Object.assign` accepts array
@@ -362,7 +378,6 @@ reject }` object.
shares one prototype, where JavaScript gives each collection its own; `Object.getPrototypeOf` shows the
difference.
- [x] `length`, numeric indexing, index assignment, spread, and `for...of`.
- [x] The `thisArg` argument of `Array.from` is accepted and ignored, like JS arrows.
- [x] `Array.prototype.toSpliced`.
- [x] Canonical array/string index parsing: keys such as `"01"` are ordinary properties rather than aliases of index
`1`.
@@ -375,8 +390,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.*`, `parseInt`,
and the String and Number methods consult a program object's own `valueOf`/`toString`; the array methods do not
yet (see ToPrimitive above).
## Strings
@@ -393,14 +409,15 @@ reject }` object.
- [x] Static `String.fromCharCode` and `String.fromCodePoint`.
- [x] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` coerce like
native JS, `split(undefined)` returns the whole string, and `includes`/`startsWith`/`endsWith` reject regular
expressions with a native-style `TypeError`. Opaque runtime references still reject as data errors, and
`repeat` still requires a finite non-negative count.
expressions with a native-style `TypeError`. Data objects convert through their own `toString`/`valueOf` (see
ToPrimitive above). 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.
- [x] `String.raw`, on a template object or any `{ raw }` object; raw strings and substitutions coerce through their own
`toString`.
- [x] `match`, `matchAll`, `search`, and `split` read any non-RegExp argument as a pattern string, as `new RegExp(arg)`
would: `"a1b".match(1)` matches `/1/`, `search(null)` looks for `"null"`, and `undefined` is the empty pattern.
Objects use their built-in string form until ToPrimitive lands.
would: `"a1b".match(1)` matches `/1/`, `search(null)` looks for `"null"`, `undefined` is the empty pattern, and
an object supplies its own `toString`.
## Numbers and Math
@@ -454,15 +471,15 @@ reject }` object.
- [x] `getTimezoneOffset`, arithmetic, relational comparison, and `instanceof Date`.
- [x] Date values serialize to ISO strings; invalid dates serialize to `null`.
- [x] Local and UTC Date setters, including native argument coercion, mutation, rollover, invalid-Date recovery, and
`TimeClip` behavior.
`TimeClip` behavior. On an invalid Date every setter but `setTime` and `set(UTC)FullYear` answers `NaN` without
writing, so a time set inside an argument's `valueOf` survives.
- [x] `Date.prototype.toUTCString` and its `toGMTString` alias.
- [x] `toDateString` and `toTimeString` in the host's local timezone.
- [x] `toLocaleString`, `toLocaleDateString`, and `toLocaleTimeString` always format as `en-US` in UTC
(`"1/1/1970, 12:00:00 AM"`) so output does not depend on the host.
- [x] Native one-argument Date coercion for supported values, including booleans, null, arrays, and plain objects.
- [x] Date setters and one-argument construction coerce object arguments through their own `valueOf`/`toString` and
surface their throws.
- [ ] Multi-argument construction and `Date.UTC` coerce object arguments the same way (see ToPrimitive above).
- [x] Date setters, construction, and `Date.UTC` coerce object arguments through their own `valueOf`/`toString` in
argument order and surface their throws; only the first seven components are converted.
- [x] Native Date loose-equality and default primitive-coercion semantics, using CodeMode's deterministic ISO string
representation for the string primitive.
- [x] Native `RangeError` branding for invalid `toISOString()` calls.
@@ -509,6 +526,11 @@ reject }` object.
- [x] Map and Set values serialize to `{}` at host/JSON boundaries.
- [x] Set composition and relation methods: `union`, `intersection`, `difference`, `symmetricDifference`, `isSubsetOf`,
`isSupersetOf`, and `isDisjointFrom`, including supported Set-like operands.
- [x] `WeakMap` (`get`, `set`, `has`, `delete`, `getOrInsert`, `getOrInsertComputed`) and `WeakSet` (`add`, `has`,
`delete`), constructed from iterables. Keys must be program objects: a primitive or tool reference throws
`Invalid value used as weak map key`, while `has`/`delete`/`get` with one answer `false`/`undefined`. Entries are
held by a host weak collection, so nothing is retained past the key's own lifetime. As in JS they have no `size`,
iteration, or `clear`, `structuredClone` rejects them, and they serialize to `{}` at host boundaries.
## URL and URI helpers
+29 -8
View File
@@ -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,28 @@ 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 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 | Effect.Effect<Value, unknown, R>,
): Value | Effect.Effect<Value, unknown, R> => {
if (!values.some((value) => value instanceof Obj)) return body(values)
return Effect.flatMap(
Effect.forEach(values, (value, index) =>
toPrimitive(ctx, value, typeof hints === "string" ? hints : hints[index]!),
),
(primitives) => {
const result = body(primitives)
return Effect.isEffect(result) ? result : Effect.succeed(result)
},
)
}
// 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
+6 -4
View File
@@ -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
+3 -1
View File
@@ -2,7 +2,7 @@ import { Effect } from "effect"
import { Arr, Callable, coerceToInteger, coerceToString, get, Obj, type Value } from "./objects.js"
import { arrayGlobal } from "../stdlib/array.js"
import { textDecoderGlobal, textEncoderGlobal, uint8ArrayGlobal } from "../stdlib/bytes.js"
import { mapGlobal, setGlobal } from "../stdlib/collections.js"
import { mapGlobal, setGlobal, weakMapGlobal, weakSetGlobal } from "../stdlib/collections.js"
import { consoleGlobal } from "../stdlib/console.js"
import { dateGlobal } from "../stdlib/date.js"
import { jsonGlobal } from "../stdlib/json.js"
@@ -111,6 +111,8 @@ const table: Record<string, Factory> = {
RegExp: (ctx) => regexpGlobal(ctx),
Map: (ctx) => mapGlobal(ctx),
Set: (ctx) => setGlobal(ctx),
WeakMap: (ctx) => weakMapGlobal(ctx),
WeakSet: (ctx) => weakSetGlobal(ctx),
URL: (ctx) => urlGlobal(ctx),
URLSearchParams: (ctx) => urlSearchParamsGlobal(ctx),
Headers: (ctx) => headersGlobal(ctx),
+182 -111
View File
@@ -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,30 @@ 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([
"+",
"-",
"*",
"/",
"%",
"**",
"<",
"<=",
">",
">=",
"&",
"|",
"^",
"<<",
">>",
">>>",
])
/** ToPropertyKey on a primitive (or an opaque value, which keeps its built-in string form). */
const propertyKey = (value: Value): PropertyKey =>
typeof value === "string" || typeof value === "number" || typeof value === "symbol" ? value : coerceToString(value)
// 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 => {
@@ -1116,18 +1140,15 @@ class Frame<R> {
}
if (pattern.type === "ObjectPattern") {
if (!(value instanceof Obj)) {
throw typeError(
`Object destructuring requires a data object or array value, received ${describeValue(value)}.`,
pattern,
)
if (value === null || value === undefined) {
throw typeError(`Cannot destructure ${describeValue(value)} as it is ${value}.`, pattern)
}
const consumed = new Set<PropertyKey>()
for (const property of pattern.properties) {
if (property.type === "RestElement") {
const rest = new Obj(self.ctx.builtins.Object)
assign(rest, value, consumed)
assign(rest, enumerableSource(self.ctx, "Object destructuring", value, pattern), consumed)
yield* self.declarePattern(property.argument, rest, mutable, property, initialize)
continue
}
@@ -1136,7 +1157,7 @@ class Frame<R> {
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.declarePattern(
property.value,
self.readProperty(value, key, property),
self.destructuredProperty(value, key, property),
mutable,
property,
initialize,
@@ -1175,24 +1196,21 @@ class Frame<R> {
}
if (pattern.type === "ObjectPattern") {
if (!(value instanceof Obj)) {
throw invalidData(
`Object destructuring requires a data object or array value, received ${describeValue(value)}.`,
pattern,
)
if (value === null || value === undefined) {
throw typeError(`Cannot destructure ${describeValue(value)} as it is ${value}.`, pattern)
}
const consumed = new Set<PropertyKey>()
for (const property of pattern.properties) {
if (property.type === "RestElement") {
const rest = new Obj(self.ctx.builtins.Object)
assign(rest, value, consumed)
assign(rest, enumerableSource(self.ctx, "Object destructuring", value, pattern), consumed)
yield* self.assignPattern(property.argument, rest, property)
continue
}
const key = yield* self.destructuringPropertyKey(property)
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.assignPattern(property.value, self.readProperty(value, key, property), property)
yield* self.assignPattern(property.value, self.destructuredProperty(value, key, property), property)
}
return
}
@@ -1263,7 +1281,7 @@ class Frame<R> {
}
const keyNode = property.key
if (property.computed) {
return Effect.map(this.evaluateExpression(keyNode), (value) => this.toPropertyKey(value))
return Effect.flatMap(this.evaluateExpression(keyNode), (value) => this.toPropertyKey(value, keyNode))
}
if (keyNode.type === "Identifier") return Effect.succeed(keyNode.name)
if (keyNode.type === "Literal") return Effect.succeed(String(keyNode.value))
@@ -1368,85 +1386,105 @@ 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 === "!="
// `in` checks the right operand before ToPropertyKey on the left, so a bad right side wins over a bad key.
if (operator === "in" && lhs instanceof Obj && !isOpaque(lhs) && rhs instanceof Obj) {
return Effect.map(this.toPropertyKey(lhs, node), (key) => has(rhs, key))
}
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
if (operator === "==") return this.looselyEqual(lhs, rhs, node)
if (operator === "!=") return !this.looselyEqual(lhs, rhs, node)
if (operator === "in" && rhs instanceof Obj && !isOpaque(lhs)) {
return has(rhs, lhs !== null && typeof lhs === "object" ? coerceToString(lhs) : (lhs as PropertyKey))
}
if (operator === "in" && rhs instanceof Obj && !isOpaque(lhs)) return has(rhs, propertyKey(lhs))
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)
@@ -1996,11 +2052,11 @@ class Frame<R> {
let key: PropertyKey
if (property.computed) {
key = self.toPropertyKey(yield* self.evaluateExpression(keyNode))
key = yield* self.toPropertyKey(yield* self.evaluateExpression(keyNode), keyNode)
} else if (keyNode.type === "Identifier") {
key = keyNode.name
} else if (keyNode.type === "Literal") {
key = self.toPropertyKey(literal(keyNode))
key = propertyKey(literal(keyNode))
} else {
throw typeError("Unsupported object property key shape.", keyNode)
}
@@ -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> {
@@ -2135,37 +2184,58 @@ class Frame<R> {
if (objectValue === OptionalShortCircuit) return OptionalShortCircuit
if ((objectValue === null || objectValue === undefined) && node.optional) return OptionalShortCircuit
const key = node.computed
? self.toPropertyKey(yield* self.evaluateExpression(propertyNode))
: propertyNode.type === "Identifier"
const keyValue =
!node.computed && propertyNode.type === "Identifier"
? propertyNode.name
: self.toPropertyKey(yield* self.evaluateExpression(propertyNode))
if (objectValue instanceof ToolReference) {
if (typeof key !== "string") {
throw typeError("Tool paths must use string property names.", propertyNode)
}
return new ToolReference([...objectValue.path, key])
}
if (objectValue instanceof Obj) return { target: objectValue, key, receiver: objectValue }
// Strings own length and indexes; every other primitive property reads through the wrapper prototype.
if (typeof objectValue === "string") {
if (key === "length") return { value: objectValue.length }
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
if (index !== undefined) return { value: objectValue[index] }
}
const proto = primitivePrototype(self.ctx.builtins, objectValue)
if (proto !== undefined) return { target: proto, key, receiver: objectValue }
: yield* self.evaluateExpression(propertyNode)
// GetValue applies ToObject to the base before ToPropertyKey, so a nullish base throws before the key's own
// toString runs.
if (objectValue === null || objectValue === undefined) {
throw typeError(`Cannot read properties of ${objectValue} (reading '${String(key)}').`, objectNode)
throw typeError(`Cannot read properties of ${objectValue} (reading '${coerceToString(keyValue)}').`, objectNode)
}
throw typeError("Cannot access a property on a non-object value.", objectNode)
const key = yield* self.toPropertyKey(keyValue, propertyNode)
return self.resolveProperty(objectValue, key, objectNode, propertyNode)
})
}
private resolveProperty(
objectValue: Value,
key: PropertyKey,
objectNode: AstNode,
propertyNode: AstNode,
): MemberReference | ToolReference | { value: Value } {
if (objectValue instanceof ToolReference) {
if (typeof key !== "string") {
throw typeError("Tool paths must use string property names.", propertyNode)
}
return new ToolReference([...objectValue.path, key])
}
if (objectValue instanceof Obj) return { target: objectValue, key, receiver: objectValue }
// Strings own length and indexes; every other primitive property reads through the wrapper prototype.
if (typeof objectValue === "string") {
if (key === "length") return { value: objectValue.length }
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
if (index !== undefined) return { value: objectValue[index] }
}
const proto = primitivePrototype(this.ctx.builtins, objectValue)
if (proto !== undefined) return { target: proto, key, receiver: objectValue }
if (objectValue === null || objectValue === undefined) {
throw typeError(`Cannot read properties of ${objectValue} (reading '${String(key)}').`, objectNode)
}
throw typeError("Cannot access a property on a non-object value.", objectNode)
}
// One destructured property, read the way a member expression would read it (primitives use their prototype).
private destructuredProperty(source: Value, key: PropertyKey, node: AstNode): Value {
const reference = this.resolveProperty(source, key, node, node)
if (reference instanceof ToolReference) return reference
if ("value" in reference) return reference.value
return this.readProperty(reference.target, reference.key, node, reference.receiver)
}
private readReference(reference: MemberReference, node: MemberExpression): Value {
// Reject unknown promise properties so a missing await cannot hide.
if (reference.target instanceof PromiseObj && !has(reference.target, reference.key)) {
@@ -2255,9 +2325,10 @@ class Frame<R> {
throw typeError(`Cannot assign to read only property '${String(key)}'.`, node)
}
// ToPropertyKey: anything else becomes its string form, so `counts[row.category]` works when the field is null.
private toPropertyKey(value: Value): PropertyKey {
if (typeof value === "string" || typeof value === "number" || typeof value === "symbol") return value
return coerceToString(value)
// ToPropertyKey: a data object converts through its own `toString`/`valueOf` first; anything else becomes its
// string form synchronously, so `counts[row.category]` works when the field is null.
private toPropertyKey(value: Value, node: AstNode): Effect.Effect<PropertyKey, unknown, R> {
if (!(value instanceof Obj)) return Effect.succeed(propertyKey(value))
return Effect.map(this.toPrimitive(value, "string", node), propertyKey)
}
}
@@ -27,6 +27,8 @@ const builtins = [
"RegExp",
"Map",
"Set",
"WeakMap",
"WeakSet",
"URL",
"URLSearchParams",
"Headers",
@@ -88,6 +90,8 @@ export const createBuiltins = (): Builtins => {
RegExp: plain(),
Map: plain(),
Set: plain(),
WeakMap: plain(),
WeakSet: plain(),
URL: plain(),
URLSearchParams: plain(),
Headers: plain(),
@@ -356,6 +356,23 @@ export class SetObj extends Wrapper {
}
}
/** Keys are program objects, so a host WeakMap gives the same lifetime rule as JavaScript without any bookkeeping. */
export class WeakMapObj extends Wrapper {
override readonly tag = "WeakMap"
readonly map = new WeakMap<Obj, Value>()
override inspect() {
return "WeakMap { <items unknown> }"
}
}
export class WeakSetObj extends Wrapper {
override readonly tag = "WeakSet"
readonly set = new WeakSet<Obj>()
override inspect() {
return "WeakSet { <items unknown> }"
}
}
export class URLSearchParamsObj extends Wrapper {
override readonly tag = "URLSearchParams"
constructor(
+28 -13
View File
@@ -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"
@@ -49,7 +49,7 @@ const arrayFrom = <R>(ctx: Interpreter<R>, args: Array<Value>): Effect.Effect<Va
const values: Array<Value> = []
for (let index = 0; index < arrayLike.length; index += 1) {
const item = get(arrayLike.source, index)
values.push(apply === undefined ? item : yield* apply([item, index]))
values.push(apply === undefined ? item : yield* apply([item, index], args[2]))
}
return new Arr(proto, values)
}
@@ -59,7 +59,9 @@ const arrayFrom = <R>(ctx: Interpreter<R>, args: Array<Value>): Effect.Effect<Va
const step = yield* cursor.next
if (step.done) return new Arr(proto, values)
values.push(
apply === undefined ? step.value : yield* preserveConsumerError(cursor.close, apply([step.value, index])),
apply === undefined
? step.value
: yield* preserveConsumerError(cursor.close, apply([step.value, index], args[2])),
)
index += 1
}
@@ -146,20 +148,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",
@@ -362,7 +374,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
const values: Array<Value> = []
for (let index = 0; index < length; index += 1) {
if (!(index in target.items)) continue
const mapped = yield* apply([target.items[index], index, target])
const mapped = yield* apply([target.items[index], index, target], args[1])
if (mapped instanceof Arr) values.push(...mapped.items)
else values.push(mapped)
}
@@ -401,7 +413,10 @@ export const callbackMethods = <R, T extends Obj>(
length,
(thisValue, args) => {
const target = self(thisValue, name)
return body(elements(target), target, applyCollectionCallback(ctx, args[0], `${label}.${name}`), args)
const call = applyCollectionCallback(ctx, args[0], `${label}.${name}`)
// reduce and reduceRight take an initial value where the others take a thisArg.
const thisArg = name.startsWith("reduce") ? undefined : args[1]
return body(elements(target), target, (callbackArgs) => call(callbackArgs, thisArg), args)
},
]
return [
+135 -2
View File
@@ -16,6 +16,8 @@ import {
PromiseObj,
SetObj,
type Value,
WeakMapObj,
WeakSetObj,
} from "../interpreter/objects.js"
import { describeValue, isOpaque } from "../interpreter/references.js"
import {
@@ -188,7 +190,7 @@ export const mapGlobal = <R>(ctx: Interpreter<R>) => {
const target = self(thisValue, "forEach")
const apply = applyCollectionCallback(ctx, args[0], "Map.forEach")
return Effect.gen(function* () {
for (const [key, item] of target.map.entries()) yield* apply([item, key, target])
for (const [key, item] of target.map.entries()) yield* apply([item, key, target], args[1])
return undefined
})
},
@@ -386,7 +388,7 @@ export const setGlobal = <R>(ctx: Interpreter<R>) => {
const target = self(thisValue, "forEach")
const apply = applyCollectionCallback(ctx, args[0], "Set.forEach")
return Effect.gen(function* () {
for (const item of target.set.values()) yield* apply([item, item, target])
for (const item of target.set.values()) yield* apply([item, item, target], args[1])
return undefined
})
},
@@ -402,3 +404,134 @@ export const setGlobal = <R>(ctx: Interpreter<R>) => {
define(proto, IteratorSymbol, get(proto, "values"), hidden)
return set
}
// CanBeHeldWeakly: only program objects; tool references are rebuilt on every access, so they could never be found again.
const weakKey = (value: Value, label: string) => {
if (value instanceof Obj) return value
throw typeError(`Invalid value used ${label}: ${describeValue(value)} cannot be held weakly.`)
}
export const weakMapGlobal = <R>(ctx: Interpreter<R>) => {
const builtins = ctx.builtins
const proto = builtins.WeakMap
const weakMap = constructor<R>(builtins, proto, {
name: "WeakMap",
call: requiresNew("WeakMap"),
construct: (args, newTarget) => {
const target = new WeakMapObj(prototypeFrom(newTarget, proto))
if (args[0] === undefined || args[0] === null) return Effect.succeed(target)
return Effect.gen(function* () {
const cursor = yield* ctx.iterate(args[0]!)
if (cursor === undefined) {
throw typeError(
`new WeakMap(...) expects an iterable of [key, value] pairs, received ${describeValue(args[0])}.`,
)
}
while (true) {
const step = yield* cursor.next
if (step.done) return target
yield* preserveConsumerError(
cursor.close,
Effect.sync(() => {
if (!(step.value instanceof Obj)) {
throw typeError("new WeakMap(...) expects [key, value] pairs as entry objects.")
}
target.map.set(weakKey(getOwn(step.value, 0), "as weak map key"), getOwn(step.value, 1))
}),
)
}
})
},
})
const self = (thisValue: Value, name: string) => receiver(WeakMapObj, thisValue, `WeakMap.prototype.${name}`).map
// Lookups pass any key through: the host collection answers false for a non-object, as the spec requires.
const key = (value: Value) => weakKey(value, "as weak map key")
methods(builtins, proto, [
[
"get",
1,
(thisValue, args) => {
const target = self(thisValue, "get")
return args[0] instanceof Obj ? target.get(args[0]) : undefined
},
],
["has", 1, (thisValue, args) => self(thisValue, "has").has(args[0] as Obj)],
["delete", 1, (thisValue, args) => self(thisValue, "delete").delete(args[0] as Obj)],
[
"set",
2,
(thisValue, args) => {
self(thisValue, "set").set(key(args[0]), args[1])
return thisValue
},
],
[
"getOrInsert",
2,
(thisValue, args) => {
const target = self(thisValue, "getOrInsert")
const k = key(args[0])
if (!target.has(k)) target.set(k, args[1])
return target.get(k)
},
],
[
"getOrInsertComputed",
2,
(thisValue, args) => {
const target = self(thisValue, "getOrInsertComputed")
const k = key(args[0])
const apply = applyCollectionCallback(ctx, args[1], "WeakMap.getOrInsertComputed")
if (target.has(k)) return target.get(k)
return Effect.map(apply([k]), (value) => {
target.set(k, value)
return value
})
},
],
])
return weakMap
}
export const weakSetGlobal = <R>(ctx: Interpreter<R>) => {
const builtins = ctx.builtins
const proto = builtins.WeakSet
const weakSet = constructor<R>(builtins, proto, {
name: "WeakSet",
call: requiresNew("WeakSet"),
construct: (args, newTarget) => {
const target = new WeakSetObj(prototypeFrom(newTarget, proto))
if (args[0] === undefined || args[0] === null) return Effect.succeed(target)
return Effect.gen(function* () {
const cursor = yield* ctx.iterate(args[0]!)
if (cursor === undefined) {
throw typeError(`new WeakSet(...) expects a synchronous iterable, received ${describeValue(args[0])}.`)
}
while (true) {
const step = yield* cursor.next
if (step.done) return target
yield* preserveConsumerError(
cursor.close,
Effect.sync(() => {
target.set.add(weakKey(step.value, "in weak set"))
}),
)
}
})
},
})
const self = (thisValue: Value, name: string) => receiver(WeakSetObj, thisValue, `WeakSet.prototype.${name}`).set
methods(builtins, proto, [
["has", 1, (thisValue, args) => self(thisValue, "has").has(args[0] as Obj)],
["delete", 1, (thisValue, args) => self(thisValue, "delete").delete(args[0] as Obj)],
[
"add",
1,
(thisValue, args) => {
self(thisValue, "add").add(weakKey(args[0], "in weak set"))
return thisValue
},
],
])
return weakSet
}
+16 -3
View File
@@ -16,8 +16,11 @@ const constructDate = <R>(ctx: Interpreter<R>, args: Array<Value>, proto: Obj) =
: new DateObj(proto, new Date(coerceToNumber(value)).getTime()),
)
}
const parts = args.map((arg) => coerceToNumber(arg))
return Effect.succeed(new DateObj(proto, new Date(...(parts as [number, number])).getTime()))
// The spec converts at most seven components, in order, so extra arguments never run program code.
return Effect.map(
Effect.forEach(args.slice(0, 7), (arg) => toPrimitiveNumber(ctx, arg), { concurrency: 1 }),
(parts) => new DateObj(proto, new Date(...(parts as [number, number])).getTime()),
)
}
type Getter = keyof {
@@ -79,7 +82,15 @@ export const dateGlobal = <R>(ctx: Interpreter<R>) => {
methods(builtins, date, [
["now", 0, () => Date.now()],
["parse", 1, (_, args) => Date.parse(coerceToString(args[0]))],
["UTC", 7, (_, args) => Date.UTC(...(args.map((arg) => coerceToNumber(arg)) as Parameters<typeof Date.UTC>))],
[
"UTC",
7,
(_, args) =>
Effect.map(
Effect.forEach(args.slice(0, 7), (arg) => toPrimitiveNumber(ctx, arg), { concurrency: 1 }),
(parts) => Date.UTC(...(parts as Parameters<typeof Date.UTC>)),
),
],
])
const self = (thisValue: Value, name: string) => receiver(DateObj, thisValue, `Date.prototype.${name}`)
@@ -125,6 +136,8 @@ export const dateGlobal = <R>(ctx: Interpreter<R>) => {
concurrency: 1,
}),
(values) => {
// Every setter but setTime and setFullYear leaves an invalid Date untouched and answers NaN.
if (Number.isNaN(hosted.getTime()) && name !== "setTime" && !name.endsWith("FullYear")) return NaN
target.time = hosted[name](...(values as [number, number, number, number]))
return target.time
},
+1 -1
View File
@@ -117,7 +117,7 @@ export const headersGlobal = <R>(ctx: Interpreter<R>) => {
const target = self(thisValue, "forEach")
const apply = applyCollectionCallback(ctx, args[0], "Headers.forEach")
return Effect.gen(function* () {
for (const [key, value] of Array.from(target.headers.entries())) yield* apply([value, key, target])
for (const [key, value] of Array.from(target.headers.entries())) yield* apply([value, key, target], args[1])
return undefined
})
},
+19 -17
View File
@@ -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,
+26 -35
View File
@@ -1,8 +1,9 @@
import { constructor, constants, methods } from "../interpreter/native.js"
import { coerceToNumber, coerceToString, type Value } from "../interpreter/objects.js"
import { constructor, constants, type Method, methods } from "../interpreter/native.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 { withPrimitives } from "../interpreter/callback.js"
import { coerce, coercion } from "./value.js"
export const numberGlobal = <R>(ctx: Interpreter<R>) => {
const builtins = ctx.builtins
@@ -26,46 +27,36 @@ 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 => {
if (typeof thisValue === "number") return thisValue
throw typeError(`Number.prototype.${name} requires that 'this' be a Number.`)
}
const optNum = (arg: Value): number | undefined => (arg === undefined ? undefined : coerceToNumber(arg))
// The receiver is checked first, then the one argument converts through ToPrimitive with the number hint.
const formatting = (name: string, op: (value: number, digits: number | undefined) => string): Method => [
name,
1,
(thisValue, args) => {
const value = self(thisValue, name)
return withPrimitives(ctx, "number", [args[0]], ([digits]) =>
op(value, digits === undefined ? undefined : coerceToNumber(digits)),
)
},
]
methods(builtins, builtins.Number, [
["toFixed", 1, (thisValue, args) => self(thisValue, "toFixed").toFixed(optNum(args[0]))],
formatting("toFixed", (value, digits) => value.toFixed(digits)),
["toLocaleString", 0, (thisValue) => self(thisValue, "toLocaleString").toLocaleString("en-US")],
["toExponential", 1, (thisValue, args) => self(thisValue, "toExponential").toExponential(optNum(args[0]))],
[
"toPrecision",
1,
(thisValue, args) => {
const value = self(thisValue, "toPrecision")
const digits = optNum(args[0])
return digits === undefined ? value.toString() : value.toPrecision(digits)
},
],
[
"toString",
1,
(thisValue, args) => {
const value = self(thisValue, "toString")
const radix = optNum(args[0])
if (radix !== undefined && (radix < 2 || radix > 36)) {
throw rangeError("Number.toString radix must be between 2 and 36.")
}
return value.toString(radix)
},
],
formatting("toExponential", (value, digits) => value.toExponential(digits)),
formatting("toPrecision", (value, digits) => (digits === undefined ? value.toString() : value.toPrecision(digits))),
formatting("toString", (value, radix) => {
if (radix !== undefined && (radix < 2 || radix > 36)) {
throw rangeError("Number.toString radix must be between 2 and 36.")
}
return value.toString(radix)
}),
["valueOf", 0, (thisValue) => self(thisValue, "valueOf")],
])
return number
+200 -115
View File
@@ -17,7 +17,13 @@ import {
type Value,
} from "../interpreter/objects.js"
import { containsOpaqueReference, typeofValue } from "../interpreter/references.js"
import { applyCollectionCallback, isSupportedCallback, toPrimitiveString } from "../interpreter/callback.js"
import {
applyCollectionCallback,
type Hint,
isSupportedCallback,
toPrimitiveString,
withPrimitives,
} from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { matchToValue, toHostRegex } from "./regexp.js"
import { coercion } from "./value.js"
@@ -142,34 +148,72 @@ export const stringGlobal = <R>(ctx: Interpreter<R>) => {
args[index] === undefined ? undefined : num(name, args, index)
const optStr = (name: string, args: Array<Value>, index: number): string | undefined =>
args[index] === undefined ? undefined : str(name, args, index)
const rejectRegex = (name: string, args: Array<Value>): void => {
if (args[0] instanceof RegExpObj) {
throw typeError(
`String.${name} cannot take a regular expression; use regex.test(string) or String.search instead.`,
)
}
}
// ToPrimitive in spec order: the receiver, then the arguments the method consumes, one hint per position; the
// rest pass through as they are.
const simple = (
name: string,
length: number,
op: (value: string, args: Array<Value>) => ReturnType<Impl>,
): Method => [name, length, (thisValue, args) => op(self(thisValue, name), args)]
const replace = (name: "replace" | "replaceAll") =>
simple(name, 2, (value, args) => {
if (isSupportedCallback(args[1])) return replaceWithCallback(ctx, value, name, args)
if (typeofValue(args[1]) === "function") {
hints: ReadonlyArray<Hint> = [],
): Method => [
name,
length,
(thisValue, args) => {
if (thisValue === null || thisValue === undefined) {
throw typeError(`String.prototype.${name} called on null or undefined.`)
}
return withPrimitives(
ctx,
["string", ...hints],
[thisValue, ...args.slice(0, hints.length)],
([value, ...primitives]) => op(coerceToString(value), [...primitives, ...args.slice(hints.length)]),
)
},
]
// includes, startsWith, and endsWith reject a RegExp before converting their search string and position.
const searching = (name: string, op: (value: string, search: string, position: number | undefined) => boolean) =>
simple(name, 1, (value, args) => {
if (args[0] instanceof RegExpObj) {
throw typeError(
`String.${name} cannot use this callable as a replacer; wrap it in an arrow function, e.g. (match) => tools.ns.tool(match).`,
`String.${name} cannot take a regular expression; use regex.test(string) or String.search instead.`,
)
}
if (args[0] instanceof RegExpObj) {
const pattern = args[0].regex
const replacement = str(name, args, 1)
if (name === "replaceAll") replaceAllNeedsGlobal(pattern)
return name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement)
}
if (name === "replace") return value.replace(str(name, args, 0), str(name, args, 1))
return value.replaceAll(str(name, args, 0), str(name, args, 1))
return withPrimitives(ctx, ["string", "number"], args.slice(0, 2), (primitives) =>
op(value, str(name, primitives, 0), optNum(name, primitives, 1)),
)
})
// match, matchAll, and search read a RegExp as is and convert anything else to its pattern text.
const withPattern = (args: Array<Value>, op: (pattern: Value) => Value) =>
args[0] instanceof RegExpObj ? op(args[0]) : withPrimitives(ctx, "string", [args[0]], ([text]) => op(text))
const replace = (name: "replace" | "replaceAll") =>
simple(name, 2, (value, args) => {
const pattern = args[0]
const replacer = args[1]
// A RegExp pattern is used as is; a plain one converts to its search string, then a non-callable replacement.
return withPrimitives(
ctx,
"string",
[pattern instanceof RegExpObj ? undefined : pattern, isSupportedCallback(replacer) ? undefined : replacer],
([search, replacement]) => {
if (isSupportedCallback(replacer)) {
return replaceWithCallback(ctx, value, name, [pattern instanceof RegExpObj ? pattern : search, replacer])
}
if (typeofValue(replacer) === "function") {
throw typeError(
`String.${name} cannot use this callable as a replacer; wrap it in an arrow function, e.g. (match) => tools.ns.tool(match).`,
)
}
const primitives = [search, replacement]
if (pattern instanceof RegExpObj) {
const regex = pattern.regex
const text = str(name, primitives, 1)
if (name === "replaceAll") replaceAllNeedsGlobal(regex)
return name === "replace" ? value.replace(regex, text) : value.replaceAll(regex, text)
}
if (name === "replace") return value.replace(str(name, primitives, 0), str(name, primitives, 1))
return value.replaceAll(str(name, primitives, 0), str(name, primitives, 1))
},
)
})
methods(builtins, builtins.String, [
@@ -185,107 +229,148 @@ export const stringGlobal = <R>(ctx: Interpreter<R>) => {
simple("trimEnd", 0, (value) => value.trimEnd()),
simple("trimRight", 0, (value) => value.trimEnd()),
// Locale/options are deliberately unsupported; comparison uses the host default locale.
simple("localeCompare", 1, (value, args) => value.localeCompare(str("localeCompare", args, 0))),
simple("normalize", 0, (value, args) => {
const form = optStr("normalize", args, 0)
try {
return value.normalize(form)
} catch {
throw rangeError(
`String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`,
)
}
}),
simple("localeCompare", 1, (value, args) => value.localeCompare(str("localeCompare", args, 0)), ["string"]),
simple(
"normalize",
0,
(value, args) => {
const form = optStr("normalize", args, 0)
try {
return value.normalize(form)
} catch {
throw rangeError(
`String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`,
)
}
},
["string"],
),
simple("split", 2, (value, args) => {
const wrap = (parts: Array<string>) => new Arr(builtins.Array, parts)
// Native: an undefined separator returns the whole string, not a split on "undefined",
// unless the limit truncates to zero.
const requestedLimit = optNum("split", args, 1)
if (args[0] === undefined) {
return wrap(requestedLimit !== undefined && requestedLimit >>> 0 === 0 ? [] : [value])
}
const parts =
args[0] instanceof RegExpObj
? value.split(args[0].regex, requestedLimit)
: value.split(str("split", args, 0), requestedLimit === undefined ? undefined : requestedLimit >>> 0)
checkArrayLength(parts.length)
return wrap(parts)
const separator = args[0]
// A RegExp separator is used as is; the limit converts before a plain separator does, as in the spec.
return withPrimitives(
ctx,
["number", "string"],
[args[1], separator instanceof RegExpObj ? undefined : separator],
([limit, pattern]) => {
const wrap = (parts: Array<string>) => new Arr(builtins.Array, parts)
// Native: an undefined separator returns the whole string, not a split on "undefined",
// unless the limit truncates to zero.
const requestedLimit = args[1] === undefined ? undefined : num("split", [pattern, limit], 1)
if (separator === undefined) {
return wrap(requestedLimit !== undefined && requestedLimit >>> 0 === 0 ? [] : [value])
}
const parts =
separator instanceof RegExpObj
? value.split(separator.regex, requestedLimit)
: value.split(str("split", [pattern], 0), requestedLimit === undefined ? undefined : requestedLimit >>> 0)
checkArrayLength(parts.length)
return wrap(parts)
},
)
}),
simple("slice", 2, (value, args) => value.slice(optNum("slice", args, 0), optNum("slice", args, 1))),
simple("includes", 1, (value, args) => {
rejectRegex("includes", args)
return value.includes(str("includes", args, 0), optNum("includes", args, 1))
}),
simple("startsWith", 1, (value, args) => {
rejectRegex("startsWith", args)
return value.startsWith(str("startsWith", args, 0), optNum("startsWith", args, 1))
}),
simple("endsWith", 1, (value, args) => {
rejectRegex("endsWith", args)
return value.endsWith(str("endsWith", args, 0), optNum("endsWith", args, 1))
}),
simple("indexOf", 1, (value, args) => value.indexOf(str("indexOf", args, 0), optNum("indexOf", args, 1))),
simple("lastIndexOf", 1, (value, args) =>
value.lastIndexOf(str("lastIndexOf", args, 0), optNum("lastIndexOf", args, 1)),
simple("slice", 2, (value, args) => value.slice(optNum("slice", args, 0), optNum("slice", args, 1)), [
"number",
"number",
]),
searching("includes", (value, search, position) => value.includes(search, position)),
searching("startsWith", (value, search, position) => value.startsWith(search, position)),
searching("endsWith", (value, search, position) => value.endsWith(search, position)),
simple("indexOf", 1, (value, args) => value.indexOf(str("indexOf", args, 0), optNum("indexOf", args, 1)), [
"string",
"number",
]),
simple(
"lastIndexOf",
1,
(value, args) => value.lastIndexOf(str("lastIndexOf", args, 0), optNum("lastIndexOf", args, 1)),
["string", "number"],
),
replace("replace"),
replace("replaceAll"),
simple("match", 1, (value, args) => {
const pattern = toHostRegex(args[0], "match")
const matched = value.match(pattern)
if (matched === null) return null
// Preserve the own `index` and `groups` properties on non-global matches.
if (pattern.global) return new Arr(builtins.Array, [...matched])
return matchToValue(builtins, matched)
}),
simple("matchAll", 1, (value, args) => {
const pattern = toHostRegex(args[0], "matchAll", "g")
if (!pattern.global) {
throw typeError(
`String.matchAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.match for a single match.`,
)
}
const matches: Array<Value> = []
for (const match of value.matchAll(pattern)) {
checkArrayLength(matches.length + 1)
matches.push(matchToValue(builtins, match))
}
return new Arr(builtins.Array, matches)
}),
simple("search", 1, (value, args) => value.search(toHostRegex(args[0], "search"))),
simple("repeat", 1, (value, args) => {
const count = num("repeat", args, 0)
if (!Number.isFinite(count) || count < 0) {
throw rangeError("String.repeat expects a finite non-negative count.")
}
checkStringLength(value.length * count)
return value.repeat(count)
}),
simple("padStart", 1, (value, args) => {
const length = num("padStart", args, 0)
checkStringLength(length)
return value.padStart(length, optStr("padStart", args, 1))
}),
simple("padEnd", 1, (value, args) => {
const length = num("padEnd", args, 0)
checkStringLength(length)
return value.padEnd(length, optStr("padEnd", args, 1))
}),
simple("charAt", 1, (value, args) => value.charAt(optNum("charAt", args, 0) ?? 0)),
simple("at", 1, (value, args) => value.at(optNum("at", args, 0) ?? 0)),
simple("substring", 2, (value, args) =>
value.substring(optNum("substring", args, 0) ?? 0, optNum("substring", args, 1)),
simple("match", 1, (value, args) =>
withPattern(args, (arg) => {
const regex = toHostRegex(arg, "match")
const matched = value.match(regex)
if (matched === null) return null
// Preserve the own `index` and `groups` properties on non-global matches.
if (regex.global) return new Arr(builtins.Array, [...matched])
return matchToValue(builtins, matched)
}),
),
simple("substr", 2, (value, args) => value.substr(optNum("substr", args, 0) ?? 0, optNum("substr", args, 1))),
simple("matchAll", 1, (value, args) =>
withPattern(args, (arg) => {
const regex = toHostRegex(arg, "matchAll", "g")
if (!regex.global) {
throw typeError(
`String.matchAll requires a regular expression with the global (g) flag: write /${regex.source}/${regex.flags}g, or use String.match for a single match.`,
)
}
const matches: Array<Value> = []
for (const match of value.matchAll(regex)) {
checkArrayLength(matches.length + 1)
matches.push(matchToValue(builtins, match))
}
return new Arr(builtins.Array, matches)
}),
),
simple("search", 1, (value, args) => withPattern(args, (arg) => value.search(toHostRegex(arg, "search")))),
simple(
"repeat",
1,
(value, args) => {
const count = num("repeat", args, 0)
if (!Number.isFinite(count) || count < 0) {
throw rangeError("String.repeat expects a finite non-negative count.")
}
checkStringLength(value.length * count)
return value.repeat(count)
},
["number"],
),
simple(
"padStart",
1,
(value, args) => {
const length = num("padStart", args, 0)
checkStringLength(length)
return value.padStart(length, optStr("padStart", args, 1))
},
["number", "string"],
),
simple(
"padEnd",
1,
(value, args) => {
const length = num("padEnd", args, 0)
checkStringLength(length)
return value.padEnd(length, optStr("padEnd", args, 1))
},
["number", "string"],
),
simple("charAt", 1, (value, args) => value.charAt(optNum("charAt", args, 0) ?? 0), ["number"]),
simple("at", 1, (value, args) => value.at(optNum("at", args, 0) ?? 0), ["number"]),
simple(
"substring",
2,
(value, args) => value.substring(optNum("substring", args, 0) ?? 0, optNum("substring", args, 1)),
["number", "number"],
),
simple("substr", 2, (value, args) => value.substr(optNum("substr", args, 0) ?? 0, optNum("substr", args, 1)), [
"number",
"number",
]),
simple("isWellFormed", 0, (value) => value.isWellFormed()),
simple("toWellFormed", 0, (value) => value.toWellFormed()),
simple("charCodeAt", 1, (value, args) => value.charCodeAt(optNum("charCodeAt", args, 0) ?? 0)),
simple("codePointAt", 1, (value, args) => value.codePointAt(optNum("codePointAt", args, 0) ?? 0)),
simple("concat", 1, (value, args) => {
const joined = value.concat(...args.map((_, index) => str("concat", args, index)))
checkStringLength(joined.length)
return joined
}),
simple("charCodeAt", 1, (value, args) => value.charCodeAt(optNum("charCodeAt", args, 0) ?? 0), ["number"]),
simple("codePointAt", 1, (value, args) => value.codePointAt(optNum("codePointAt", args, 0) ?? 0), ["number"]),
simple("concat", 1, (value, args) =>
withPrimitives(ctx, "string", args, (parts) => {
const joined = value.concat(...parts.map((_, index) => str("concat", parts, index)))
checkStringLength(joined.length)
return joined
}),
),
])
define(
builtins.String,
+1 -1
View File
@@ -281,7 +281,7 @@ export const urlSearchParamsGlobal = <R>(ctx: Interpreter<R>) => {
const target = self(thisValue, "forEach")
const apply = applyCollectionCallback(ctx, args[0], "URLSearchParams.forEach")
return Effect.gen(function* () {
for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target])
for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target], args[1])
return undefined
})
},
+12 -7
View File
@@ -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`. */
+5 -1
View File
@@ -16,6 +16,8 @@ import {
Obj,
RegExpObj,
SetObj,
WeakMapObj,
WeakSetObj,
coerceToString,
type Value,
} from "../interpreter/objects.js"
@@ -84,7 +86,9 @@ export const structuredCloneGlobal = <R>(ctx: Interpreter<R>) =>
if (hasOwn(value, "cause")) define(copy, "cause", clone(getOwn(value, "cause")), hidden)
return copy
}
if (isRuntimeReference(value)) throw typeError(`DataCloneError: ${describeValue(value)} could not be cloned.`)
if (isRuntimeReference(value) || value instanceof WeakMapObj || value instanceof WeakSetObj) {
throw typeError(`DataCloneError: ${describeValue(value)} could not be cloned.`)
}
const copy = remember(
value instanceof Arr ? new Arr(builtins.Array, new Array(value.items.length)) : new Obj(builtins.Object),
)
+413
View File
@@ -1571,3 +1571,416 @@ 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")
})
})
describe("object destructuring from primitives", () => {
test("reads through the primitive's prototype like member access", async () => {
expect(
await value(`
const { length, 0: first, toUpperCase } = "abc"
const { toFixed } = 1.5
const {} = true
const { 0: a, ...rest } = "xyz"
const { ...none } = 42
let n
;({ length: n } = "hello")
return [length, first, toUpperCase.call("q"), toFixed.call(2.345, 1), a, rest, none, n]
`),
).toEqual([3, "a", "Q", "2.3", "x", { 1: "y", 2: "z" }, {}, 5])
})
test("only null and undefined sources throw", async () => {
expect((await error(`const { a } = null`)).message).toContain("Cannot destructure null as it is null")
expect((await error(`const {} = undefined`)).message).toContain("Cannot destructure undefined")
expect((await error(`let a; ({ a } = undefined)`)).message).toContain("Cannot destructure undefined")
})
})
describe("Date components convert through ToPrimitive", () => {
test("construction and Date.UTC ask each of the first seven arguments in order", async () => {
expect(
await value(`
const seen = []
const part = (n) => ({ valueOf() { seen.push(n); return n } })
const time = new Date(part(2024), part(1), part(2), part(3), part(4), part(5), part(6), part(99)).getTime()
const utc = Date.UTC(2024, { valueOf() { return 0 } }, 15)
return [seen, time === new Date(2024, 1, 2, 3, 4, 5, 6).getTime(), utc === Date.UTC(2024, 0, 15)]
`),
).toEqual([[2024, 1, 2, 3, 4, 5, 6], true, true])
expect((await error(`new Date(2024, { valueOf() { throw new RangeError("boom") } })`)).message).toContain("boom")
})
test("setters on an invalid Date answer NaN without overwriting a time set during coercion", async () => {
expect(
await value(`
const d = new Date(NaN)
const result = d.setDate({ valueOf() { d.setTime(0); return 1 } })
const y = new Date(NaN)
return [Number.isNaN(result), d.getTime(), y.setFullYear(2020) === Date.UTC(2020, 0, 1) - y.getTimezoneOffset() * 60000]
`),
).toEqual([true, 0, true])
})
})
describe("iteration callbacks receive thisArg", () => {
test("Array, Array.from, Map, Set, URLSearchParams, Headers, and Uint8Array pass it as this", async () => {
expect(
await value(`
const c = { n: 0 }
const count = function () { this.n++ }
;[1, 2].forEach(count, c)
;[1].map(count, c)
;[1].filter(count, c)
;[1].find(count, c)
;[1].findIndex(count, c)
;[1].findLast(count, c)
;[1].findLastIndex(count, c)
;[1].some(count, c)
;[1].every(count, c)
;[1].flatMap(count, c)
Array.from([1], count, c)
Array.from({ length: 1 }, count, c)
new Map([[1, 1]]).forEach(count, c)
new Set([1]).forEach(count, c)
new URLSearchParams("a=1").forEach(count, c)
new Headers({ a: "1" }).forEach(count, c)
new Uint8Array([1]).forEach(count, c)
return c.n
`),
).toBe(18)
expect(await value(`return [1, 2].map(function (x) { return x + this.v }, { v: 10 })`)).toEqual([11, 12])
})
test("arrows keep their lexical this, reduce takes an initial value instead, and opaque values are only bound", async () => {
expect(await value(`return [1].map(() => typeof this, { v: 1 })`)).toEqual(["undefined"])
expect(
await value(`return [1, 2].reduce(function (a, b) { return a + b + (this === undefined ? 0 : 100) }, 0)`),
).toBe(3)
expect(
await value(`
let seen
;[1].forEach(function () { seen = this }, tools.nowhere)
return typeof seen
`),
).toBe("function")
})
})
describe("computed property keys convert through the object's own toString", () => {
test("reads, writes, compound assignment, in, delete, literals, and destructuring share one conversion", async () => {
expect(
await value(`
const key = { toString() { return "id" } }
const o = {}
o[key] = 1
o[key] += 1
const literal = { [key]: "lit" }
const had = key in o
delete literal[key]
return [o.id, had, (({ [key]: v }) => v)(o), literal, o[[1, 2]] === undefined]
`),
).toEqual([2, true, 2, {}, true])
expect(
await value(`
const seen = []
const base = { x: 1 }
base[{ toString() { seen.push(1); return "" } }] ^= 0
base[{ toString() { seen.push(2); return "x" } }]++
return [seen, base[""], base.x]
`),
).toEqual([[1, 2], 0, 2])
})
test("valueOf is the fallback, a symbol result stays a symbol, and conversion failures surface", async () => {
expect(
await value(`
const o = { 7: "seven" }
const sym = { toString() { return Symbol.iterator } }
o[sym] = 1
return [o[{ valueOf() { return 7 }, toString: undefined }], typeof o[Symbol.iterator], Object.keys(o)]
`),
).toEqual(["seven", "number", ["7"]])
expect((await error(`({})[{ toString() { throw new RangeError("bad key") } }]`)).message).toContain("bad key")
expect((await error(`({})[{ toString() { return {} }, valueOf() { return {} } }]`)).message).toContain(
"Cannot convert object to primitive value",
)
expect((await error(`const key = { toString() { return "a" } }; key in 5`)).message).toContain(
"requires a data object on the right-hand side",
)
})
test("a nullish base throws before the key converts, as ToObject precedes ToPropertyKey", async () => {
const failure = await error(`const base = null; base[{ toString() { throw new RangeError("key evaluated") } }]`)
expect(failure.message).toContain("Cannot read properties of null")
})
test("opaque values keep their built-in key form and a tool reference toString is never called", async () => {
expect(
await value(`
const o = { "[object Function]": 1, "[object Promise]": 2 }
return [o[() => 1], o[Promise.resolve("k")]]
`),
).toEqual([1, 2])
expect((await error(`({})[{ toString: tools.nowhere }] = 1`)).message).toContain(
"Cannot convert object to primitive value",
)
})
})
describe("String and Number method arguments convert through ToPrimitive", () => {
test("string positions use the string hint and numeric positions the number hint", async () => {
expect(
await value(`
const s = { toString() { return "b" } }
const n = { valueOf() { return 1 } }
return [
"abc".indexOf(s), "abc".lastIndexOf(s), "abc".includes(s), "abc".startsWith(s, n), "abc".endsWith(s, 2),
"abc".charAt(n), "abc".at({ valueOf() { return -1 } }), "abc".slice(n), "abc".substring(n, 2),
"abc".charCodeAt(n), "a".padStart({ valueOf() { return 3 } }, s), "x".padEnd(3, s), "ab".repeat({ valueOf() { return 2 } }),
"a".concat(s, { valueOf() { return 1 }, toString() { return "T" } }), "b".localeCompare(s),
(1.005).toFixed({ valueOf() { return 2 } }), (255).toString({ valueOf() { return 16 } }),
(1234.5678).toPrecision({ valueOf() { return 6 } }), (12345).toExponential({ valueOf() { return 2 } }),
]
`),
).toEqual([
1,
1,
true,
true,
true,
"b",
"c",
"bc",
"b",
98,
"bba",
"xbb",
"abab",
"abT",
0,
"1.00",
"ff",
"1234.57",
"1.23e+4",
])
})
test("split, replace, match, and search convert a plain pattern but keep a RegExp as is", async () => {
expect(
await value(`
const s = { toString() { return "b" } }
return [
"abc".split(s), "abc".split(/b/, { valueOf() { return 1 } }), "abc".split(undefined, { valueOf() { return undefined } }),
"abc".replace(s, "X"), "abc".replace(/b/, { toString() { return "R" } }), "abc".replaceAll(s, s),
"abc".replace(s, (m) => m.toUpperCase()), "abc".match(s)[0], "abcb".matchAll(s).length, "abc".search(s),
]
`),
).toEqual([["a", "c"], ["a"], [], "aXc", "aRc", "abc", "aBc", "b", 2, 1])
expect((await error(`"abc".includes(/b/)`)).message).toContain("cannot take a regular expression")
})
test("the receiver converts first, then each consumed argument, in spec order; extra arguments are untouched", async () => {
expect(
await value(`
const log = []
const observer = (name, string, number) => ({
toString() { log.push("toString:" + name); return string },
valueOf() { log.push("valueOf:" + name); return number },
})
const padded = String.prototype.padStart.call(observer("receiver", {}, "abc"), observer("maxLength", 11, {}), observer("fillString", {}, "def"))
const extra = "abc".indexOf("b", 1, { valueOf() { throw new Error("extra argument converted") } })
return [padded, log, extra, String.prototype.trim.call({ toString() { return " abc " } })]
`),
).toEqual([
"defdefdeabc",
[
"toString:receiver",
"valueOf:receiver",
"valueOf:maxLength",
"toString:maxLength",
"toString:fillString",
"valueOf:fillString",
],
1,
"abc",
])
})
test("conversion failures surface and opaque arguments still reject", async () => {
expect((await error(`"abc".indexOf({ toString() { throw new RangeError("intostr") } })`)).message).toContain(
"intostr",
)
expect((await error(`(1).toString({ valueOf() { throw new SyntaxError("poison") } })`)).message).toContain("poison")
expect((await error(`(1).toFixed({ toString() { return {} }, valueOf() { return {} } })`)).message).toContain(
"Cannot convert object to primitive value",
)
expect((await error(`"abc".indexOf(tools.nowhere)`)).message).toContain("expects argument 1 to be a data value")
expect((await error(`"abc".indexOf(Promise.resolve("b"))`)).message).toContain(
"expects argument 1 to be a data value",
)
})
})
describe("WeakMap and WeakSet", () => {
test("hold program objects by identity and answer like JS for non-object keys", async () => {
expect(
await value(`
const k = {}
const f = () => 1
const wm = new WeakMap([[k, 1]])
const ws = new WeakSet([k])
return [
wm.set(f, "fn") === wm, wm.get(k), wm.get(f), wm.has({}), wm.get(1), wm.has(1), wm.delete("s"),
wm.getOrInsert(k, 9), wm.getOrInsertComputed({}, (key) => typeof key),
ws.add(f) === ws, ws.has(k), ws.has(f), ws.has(1), ws.delete(k), ws.has(k),
String(wm), wm.size, "clear" in wm, Symbol.iterator in ws, JSON.stringify(wm),
]
`),
).toEqual([
true,
1,
"fn",
false,
null,
false,
false,
1,
"object",
true,
true,
true,
false,
true,
false,
"[object WeakMap]",
null,
false,
false,
"{}",
])
})
test("reject primitive keys, plain calls, bad receivers, and cloning", async () => {
expect((await error(`new WeakMap().set(1, 1)`)).message).toContain("Invalid value used as weak map key")
expect((await error(`new WeakSet([1])`)).message).toContain("Invalid value used in weak set")
expect((await error(`WeakMap()`)).message).toContain("new")
expect((await error(`WeakMap.prototype.get.call(new Map(), {})`)).message).toContain("incompatible receiver")
expect((await error(`structuredClone(new WeakSet())`)).message).toContain("DataCloneError")
})
})
+4 -2
View File
@@ -12,7 +12,8 @@ Without them the runner registers no tests, so CI is unaffected. Licensed under
## Layout
- `manifest.json` — the pinned upstream revision, which upstream directories are copied, and what is left out.
- `manifest.json` — the pinned upstream revision, which upstream directories are copied (every `built-ins` and
`language` directory, about 14,900 files after filtering), and what is left out.
- `built-ins/`, `language/` — the copied files, mirroring upstream `test/`; gitignored.
- `skipped.txt` — vendored files that fail on a known interpreter gap, one `path # reason` per line. They are
skipped, and each gap is listed as unchecked in `interpreter-support.md`.
@@ -26,7 +27,8 @@ Without them the runner registers no tests, so CI is unaffected. Licensed under
manifest marks unsupported, or when its code matches one of the manifest's `boundaries` patterns. The sync checks the
checkout is at the pinned revision, so every machine runs the same files. Boundaries are
intentional limits of the interpreter, not compatibility work: classes, prototype objects, property descriptors,
accessors, boxed primitives, sloppy mode, `eval`, `Symbol()`, and the `$262` host API. If one
accessors, boxed primitives, typed arrays and buffers, `WeakRef` and `FinalizationRegistry`, `Reflect` and `Proxy`, sloppy mode, `eval`,
`Symbol()`, and the `$262` host API. If one
of those decisions changes, delete its entry and re-sync; the tests are upstream, not lost.
## Commands
+5 -24
View File
@@ -1,33 +1,16 @@
{
"revision": "250f204f23a9249ff204be2baec29600faae7b75",
"directories": [
"built-ins/Array/prototype",
"built-ins/Function/prototype/apply",
"built-ins/Function/prototype/bind",
"built-ins/Function/prototype/call",
"built-ins/Iterator",
"built-ins/Object/freeze",
"built-ins/Object/getPrototypeOf",
"built-ins/Object/is",
"built-ins/Object/isExtensible",
"built-ins/Object/isFrozen",
"built-ins/Object/isSealed",
"built-ins/Object/preventExtensions",
"built-ins/String/raw",
"language/arguments-object",
"language/expressions/does-not-equals",
"language/expressions/equals",
"language/expressions/tagged-template",
"language/expressions/this",
"language/statements"
],
"directories": ["built-ins", "language"],
"harness": ["assert.js", "sta.js", "compareArray.js", "doneprintHandle.js"],
"flags": ["module", "raw", "noStrict"],
"boundaries": {
"class": "\\bclass\\s*[A-Za-z_${]",
"accessor properties": "\\b(get|set)\\s+[\\w$\\[][^\\n(]*\\(",
"property descriptors": "Object\\.(defineProperty|defineProperties|getOwnPropertyDescriptors?|getOwnPropertyNames|setPrototypeOf)\\b",
"boxed primitives": "\\bnew\\s+(String|Number|Boolean)\\s*\\(",
"boxed primitives": "\\b(new\\s+(String|Number|Boolean)\\b|Object\\s*\\(\\s*(true|false|-?\\d|['\"]))",
"typed arrays and buffers": "\\b(ArrayBuffer|SharedArrayBuffer|DataView|Int8Array|Uint8ClampedArray|Int16Array|Uint16Array|Int32Array|Uint32Array|Float16Array|Float32Array|Float64Array|BigInt64Array|BigUint64Array)\\b",
"weak collections": "\\b(WeakRef|FinalizationRegistry)\\b",
"Reflect and Proxy": "\\b(Reflect|Proxy)\\b",
"sloppy mode": "\\bwith\\s*\\(",
"eval": "\\b(eval|Function)\\b",
"new.target": "\\bnew\\.target\\b",
@@ -91,8 +74,6 @@
"Uint16Array",
"Uint32Array",
"uint8array-base64",
"WeakMap",
"WeakSet",
"WeakRef",
"FinalizationRegistry",
"Intl-enumeration",
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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])
})
})
@@ -361,6 +361,12 @@ export function createBrowserPage(
visible = value
updateVisibility()
},
// Freezes the shown page so the renderer can paint it under DOM overlays while the view hides.
async capture() {
if (closed || !visible || !content) return
const image = await contents.capturePage()
return image.isEmpty() ? undefined : new Uint8Array(image.toJPEG(90))
},
async execute(command: Browser.Command, signal: AbortSignal): Promise<Browser.Result> {
await ready
abortError(signal)
@@ -288,6 +288,9 @@ export function createBrowserPane(storage: StateStore) {
page.layout(bounds, value.background, value.radius)
page.setVisible(true)
},
async capture(win: BrowserWindow, bindingID: string, tabID: Browser.TabID) {
return (await owned(win, bindingID).pages.get(tabID)?.capture()) ?? null
},
async command(win: BrowserWindow, bindingID: string, command: BrowserPaneCommand) {
const entry = owned(win, bindingID)
await execute(entry, { action: command, files: [] }, new AbortController().signal)
@@ -6,7 +6,7 @@ import { IpcPortHandoff } from "../ipc-transport"
import { Shutdown } from "../lifecycle/shutdown"
import { isRendererUrl } from "../windows/scheme"
import { DesktopStorage } from "../storage"
import { sender } from "./context"
import { sender, type RpcContext } from "./context"
export const eventHandlers = EventRpcs.toLayer(
Effect.gen(function* () {
@@ -25,21 +25,29 @@ export const eventHandlers = EventRpcs.toLayer(
})
const remove = yield* shutdown.add(stop)
yield* Effect.addFinalizer(() => Effect.sync(remove).pipe(Effect.andThen(stop)))
const owner = async (context: RpcContext) => {
const contents = sender(handoff, context)
const win = BrowserWindow.fromWebContents(contents)
if (!win || win.isDestroyed() || win.webContents !== contents || !isRendererUrl(contents.getURL())) {
throw new Error("browser.pane.owner.invalid")
}
browser ??= load()
return { win, pane: await browser }
}
return EventRpcs.of({
DesktopEvents: (_request, context) => ipcEventStream(sender(handoff, context).id),
BrowserPane: ({ request }, context) =>
Effect.tryPromise(async () => {
const contents = sender(handoff, context)
const win = BrowserWindow.fromWebContents(contents)
if (!win || win.isDestroyed() || win.webContents !== contents || !isRendererUrl(contents.getURL())) {
throw new Error("browser.pane.owner.invalid")
}
browser ??= load()
const pane = await browser
if (request.type === "register") return pane.register(win, request.bindingID, request.target)
if (request.type === "layout") return pane.layout(win, request.bindingID, request.layout)
if (request.type === "command") return pane.command(win, request.bindingID, request.command)
return pane.close(win, request.bindingID)
const target = await owner(context)
if (request.type === "register") return target.pane.register(target.win, request.bindingID, request.target)
if (request.type === "layout") return target.pane.layout(target.win, request.bindingID, request.layout)
if (request.type === "command") return target.pane.command(target.win, request.bindingID, request.command)
return target.pane.close(target.win, request.bindingID)
}).pipe(Effect.orDie),
BrowserPaneCapture: (request, context) =>
Effect.tryPromise(async () => {
const target = await owner(context)
return target.pane.capture(target.win, request.bindingID, request.tabID)
}).pipe(Effect.orDie),
})
}),
@@ -4,6 +4,7 @@ import type { DesktopNativeBundle } from "@opencode/app/i18n/desktop-native"
import type { UpdaterState } from "@opencode/app/updater"
import type { WslServersPlatform } from "@opencode/app/wsl/types"
import type { SshPlatform } from "@opencode/app/ssh"
import type { Browser } from "@opencode/plugin-browser/rpc"
import type { BrowserPaneRequest } from "../shared/ipc-rpc/browser"
import type { WindowBootstrap } from "../shared/window-bootstrap"
import type {
@@ -31,6 +32,7 @@ export type ElectronAPI = {
browserPane: {
request(request: BrowserPaneRequest): Promise<void>
send(request: BrowserPaneRequest): void
capture(bindingID: string, tabID: Browser.TabID): Promise<ArrayBuffer | null>
onEvent(callback: (value: { readonly bindingID: string; readonly event: BrowserPaneEvent }) => void): () => void
}
wslServers: WslServersAPI
+2
View File
@@ -50,6 +50,8 @@ export const api: ElectronAPI = {
browserPane: {
request: (request) => invoke("BrowserPane", { request }),
send: (request) => send("BrowserPane", { request }),
capture: (bindingID, tabID) =>
invoke("BrowserPaneCapture", { bindingID, tabID }).then((data) => (data ? toArrayBuffer(data) : null)),
onEvent: (callback) => listen("BrowserPaneEvent", (value) => callback(value)),
},
wslServers: {
@@ -46,6 +46,10 @@ export function createDesktopPlatform(
.catch(() => undefined)
},
command: (command) => ready.then(() => api.browserPane.request({ type: "command", bindingID, command })),
capture: (tabID) =>
ready
.then(() => api.browserPane.capture(bindingID, tabID))
.then((data) => data && new Blob([data], { type: "image/jpeg" })),
close() {
if (closed) return
closed = true
@@ -1,6 +1,7 @@
import { Browser } from "@opencode/plugin-browser/rpc"
import { Schema } from "effect"
import { Rpc } from "effect/unstable/rpc"
import { Transferable } from "effect/unstable/workers"
const text = (maximum: number) => Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(maximum))
const bindingID = text(128)
@@ -42,3 +43,7 @@ export const BrowserPaneEventSchema = Schema.Union([
}),
])
export const BrowserPaneRpc = Rpc.make("BrowserPane", { payload: { request: BrowserPaneRequestSchema } })
export const BrowserPaneCaptureRpc = Rpc.make("BrowserPaneCapture", {
payload: { bindingID, tabID: Browser.TabID },
success: Schema.NullOr(Transferable.Uint8Array),
})
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
import { BrowserPaneEventSchema, BrowserPaneRpc } from "./browser"
import { BrowserPaneCaptureRpc, BrowserPaneEventSchema, BrowserPaneRpc } from "./browser"
import { UpdaterStateSchema } from "./updater"
import { WslServersEventSchema } from "./wsl"
import { SshState } from "@opencode/app/ssh"
@@ -63,4 +63,4 @@ export const DesktopEvent = Schema.Union([
export type DesktopEvent = Schema.Schema.Type<typeof DesktopEvent>
export const DesktopEvents = Rpc.make("DesktopEvents", { success: DesktopEvent, stream: true })
export const EventRpcs = RpcGroup.make(DesktopEvents, BrowserPaneRpc)
export const EventRpcs = RpcGroup.make(DesktopEvents, BrowserPaneRpc, BrowserPaneCaptureRpc)
+4
View File
@@ -71,6 +71,10 @@ async function main() {
void ready.then(() => pane.layout(win, bindingID, layout))
},
command: (command) => ready.then(() => pane.command(win, bindingID, command)),
capture: (tabID) =>
ready
.then(() => pane.capture(win, bindingID, tabID))
.then((data) => data && new Blob([data], { type: "image/jpeg" })),
close: () => {
listeners.delete(bindingID)
void ready.then(() => pane.close(win, bindingID)).catch(() => {})
+5
View File
@@ -333,6 +333,11 @@ async function main() {
await call("tabs.focus", { tabID: second.id })
pane.layout(win, "suite", { tabID: second.id, visible: true, bounds: { x: 0, y: 0, width: 1000, height: 700 } })
const snap = await call("snapshot", { tabID, boxes: true })
const still = await until(() => pane.capture(win, "suite", second.id))
assert(still)
assert.deepEqual(Array.from(still.subarray(0, 2)), [0xff, 0xd8], "The shown page captures as a JPEG still")
assert.equal(await pane.capture(win, "suite", tabID), null, "A hidden page has no still to show")
console.log("PASS browser pane still capture")
const ref = (text: string) => {
const match = snap.content
.split("\n")
+19 -6
View File
@@ -209,11 +209,13 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
})
const options = { baseUrl: input.server.endpoint.url, headers: Service.headers(input.server.endpoint) }
const api = OpenCode.make(options)
const location = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
Effect.map((response) => response.location),
Effect.catch(() => Effect.tryPromise(() => api.location.get())),
const launch = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
Effect.map((response) => ({ location: response.location, fallback: false })),
Effect.catch(() =>
Effect.tryPromise(() => api.location.get()).pipe(Effect.map((location) => ({ location, fallback: true }))),
),
)
const directory = location.directory
const directory = launch.location.directory
const pluginDirectories = yield* Effect.promise(() => localPluginDirectories(process.cwd(), global.config))
const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined
const managed = input.server.service
@@ -403,7 +405,9 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
packages={input.packages}
directories={pluginDirectories}
>
<App />
<App
locationFallback={launch.fallback}
/>
</PluginProvider>
</PanelProvider>
</UpdateNotificationProvider>
@@ -456,7 +460,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
})
})
function App() {
function App(props: { locationFallback: boolean }) {
const log = useLog({ component: "app" })
const app = useTuiApp()
const startup = useTuiStartup()
@@ -473,6 +477,15 @@ function App() {
const event = useEvent()
const client = useClient()
const toast = useToast()
onMount(() => {
if (!props.locationFallback) return
toast.show({
variant: "warning",
title: "Could not open directory",
message: "Using the server default instead. Check the requested directory and its OpenCode config.",
duration: 12_000,
})
})
const updater = useUpdateNotification()
const theme = useTheme()
const { mode, supports, setMode, locked, lock, unlock, afterPaint } = useThemes()
+24 -10
View File
@@ -44,7 +44,7 @@ function segment(value: string) {
return value
}
function createStorage(root: string, channel: string) {
export function createStorage(root: string, channel: string) {
const entries = new Map<string, { readonly value: Entry<object>; readonly reload: () => void }>()
const memories = new Map<string, MemoryEntry<object>>()
const pending = new Set<Promise<void>>()
@@ -110,18 +110,32 @@ function createStorage(root: string, channel: string) {
},
}
let reload: ReturnType<typeof setTimeout> | undefined
const watcher = watch(directory, () => {
clearTimeout(reload)
// Atomic writes notify for the temporary file before its final rename, and some
// platforms coalesce the rename event. Reload after the event burst has settled.
reload = setTimeout(() => entries.forEach((entry) => entry.reload()), 50)
})
let reloadTimer: ReturnType<typeof setTimeout> | undefined
let watcher: ReturnType<typeof watch> | undefined
try {
watcher = watch(directory, () => {
clearTimeout(reloadTimer)
// Atomic writes notify for the temporary file before its final rename, and some
// platforms coalesce the rename event. Reload after the event burst has settled.
reloadTimer = setTimeout(() => entries.forEach((entry) => entry.reload()), 50)
})
watcher.on("error", (error) => {
clearTimeout(reloadTimer)
watcher?.close()
watcher = undefined
console.error("Storage directory watcher failed, live-reload disabled", { directory, error })
})
} catch (error) {
// fs.watch throws synchronously (e.g. ENOSPC when the inotify watch limit is
// exhausted). Losing cross-process live-reload is recoverable; crashing the
// whole TUI over it is not, so degrade instead of propagating.
console.error("Failed to watch storage directory, live-reload disabled", { directory, error })
}
return {
storage,
close: () => {
clearTimeout(reload)
watcher.close()
clearTimeout(reloadTimer)
watcher?.close()
},
}
}
+30
View File
@@ -924,6 +924,11 @@ test.each([false, true])("uses the resolved launch directory for new prompts (fa
await setup.ready
await setup.waitForFrame((frame) => frame.includes("Build · Remote Model Provider"))
if (fallback) {
await setup.waitForFrame((frame) => frame.includes("Could not open directory"))
} else {
expect(setup.captureCharFrame()).not.toContain("Could not open directory")
}
setup.mockInput.pressKey("F6")
await setup.renderOnce()
await setup.mockInput.typeText("REMOTE_READY")
@@ -951,6 +956,31 @@ test.each([false, true])("uses the resolved launch directory for new prompts (fa
).toBe(true)
})
test.each([100, 44])("shows a failed launch location in the TUI at width %s", async (width) => {
await using state = await tmpdir()
const requested = process.cwd()
const fallback = { directory, project: { id: "project", directory, canonical: directory } }
const requests: URL[] = []
await using setup = await createAppFixture({
width,
state: state.path,
fetch: (url) => {
requests.push(url)
if (url.pathname === "/api/fs/list") return new Response(null, { status: 500 })
if (url.pathname === "/api/location") return json(fallback)
return undefined
},
})
await setup.ready
const frame = await setup.waitForFrame((frame) => frame.includes("Could not open directory"))
expect(frame).toContain("OpenCode")
expect(frame).toContain("config.")
expect(requests[0]?.searchParams.get("location[directory]")).toBe(requested)
expect(requests.some((url) => url.pathname === "/api/location" && !url.searchParams.has("location[directory]"))).toBe(
true,
)
})
test("error investigations repeatedly seed editable home drafts without creating sessions", async () => {
const cwd = process.cwd()
const location = { directory: cwd, project: { id: "project", directory: cwd } }
+37
View File
@@ -0,0 +1,37 @@
import { afterEach, expect, spyOn, test } from "bun:test"
import * as fs from "fs"
import { mkdtempSync, rmSync } from "fs"
import { tmpdir } from "os"
import path from "path"
import { createStorage } from "../../src/context/storage"
afterEach(() => {
spyOn(fs, "watch").mockRestore()
})
test("createStorage degrades gracefully when fs.watch throws (e.g. ENOSPC)", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "storage-test-"))
// fs.watch throws synchronously when inotify_add_watch fails (e.g. the watch
// limit is exhausted). Simulate that scoped to this test only.
spyOn(fs, "watch").mockImplementation(() => {
throw Object.assign(new Error("ENOSPC: no space left on device, watch '/some/dir'"), { code: "ENOSPC" })
})
try {
let result: ReturnType<typeof createStorage> | undefined
expect(() => {
result = createStorage(dir, "next")
}).not.toThrow()
// storage should still be usable even though the live-reload watcher failed to attach
const [store, update] = result!.storage.store("kv", { initial: { count: 0 } })
expect(store.count).toBe(0)
await update((draft) => {
draft.count = 1
})
expect(store.count).toBe(1)
expect(() => result!.close()).not.toThrow()
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
+21 -1
View File
@@ -155,6 +155,26 @@ export interface MenuSubTriggerProps extends ComponentProps<typeof DropdownMenu.
badge?: JSX.Element | string
}
export interface MenuSubProps extends ComponentProps<typeof DropdownMenu.Sub> {
placement?:
| "top"
| "top-start"
| "top-end"
| "bottom"
| "bottom-start"
| "bottom-end"
| "left"
| "left-start"
| "left-end"
| "right"
| "right-start"
| "right-end"
}
function MenuSub(props: MenuSubProps) {
return <DropdownMenu.Sub {...props} />
}
function MenuSubTrigger(props: ParentProps<MenuSubTriggerProps>) {
const ctx = useMenuContext()
const [s, r] = splitProps(props, ["class", "classList", "children", "shortcut", "badge"])
@@ -282,7 +302,7 @@ export const Menu = Object.assign(MenuRoot, {
Group: DropdownMenu.Group,
GroupLabel: MenuGroupLabel,
Separator: MenuSeparator,
Sub: DropdownMenu.Sub,
Sub: MenuSub,
SubTrigger: MenuSubTrigger,
SubContent: MenuSubContent,
Context: MenuContext,