From a81c04fd31b4e7bc2c1ad435e8469c948ccc91e0 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:36:24 -0500 Subject: [PATCH] feat(codemode): support labeled control flow (#38035) --- packages/codemode/interpreter-support.md | 2 +- packages/codemode/src/interpreter/model.ts | 4 +- packages/codemode/src/interpreter/runtime.ts | 75 ++++-- .../test/labeled-control-test262.test.ts | 249 ++++++++++++++++++ 4 files changed, 312 insertions(+), 18 deletions(-) create mode 100644 packages/codemode/test/labeled-control-test262.test.ts diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index ceb5c205aa8..69789914cd7 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -69,7 +69,7 @@ ultimate source of truth. - [x] Unlabeled `break` and `continue`. - [x] `try`, `catch`, optional catch bindings, and `finally`. - [x] `throw` with arbitrary values. -- [ ] Labeled statements, labeled `break`, and labeled `continue`. +- [x] Labeled statements, labeled `break`, and labeled `continue`. - [ ] `for await...of` and async iteration. ## Functions and callbacks diff --git a/packages/codemode/src/interpreter/model.ts b/packages/codemode/src/interpreter/model.ts index 2ceca39fbc9..72e6de8be75 100644 --- a/packages/codemode/src/interpreter/model.ts +++ b/packages/codemode/src/interpreter/model.ts @@ -31,8 +31,8 @@ export type Binding = { export type StatementResult = | { kind: "none" } | { kind: "return"; value: unknown } - | { kind: "break" } - | { kind: "continue" } + | { kind: "break"; label?: string } + | { kind: "continue"; label?: string } export type MemberReference = { target: SafeObject | Array | CodeModeRegExp | CodeModeURL diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 851ec30b3c6..9a3b95024f2 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -341,6 +341,8 @@ export class Interpreter { return this.evaluateIfStatement(node) case "SwitchStatement": return this.evaluateSwitchStatement(node) + case "LabeledStatement": + return this.evaluateLabeledStatement(node) case "WhileStatement": return this.evaluateWhileStatement(node) case "DoWhileStatement": @@ -488,7 +490,10 @@ export class Interpreter { for (let index = start; index < cases.length; index += 1) { for (const statementValue of getArray(cases[index]!, "consequent")) { const result = yield* self.evaluateStatement(asNode(statementValue, "consequent")) - if (result.kind === "break") return { kind: "none" } satisfies StatementResult + if (result.kind === "break") { + if (result.label === undefined) return { kind: "none" } satisfies StatementResult + return result + } if (result.kind === "return" || result.kind === "continue") return result } } @@ -497,7 +502,10 @@ export class Interpreter { }) } - private evaluateWhileStatement(node: AstNode): Effect.Effect { + private evaluateWhileStatement( + node: AstNode, + labels?: ReadonlySet, + ): Effect.Effect { const testNode = getNode(node, "test") const bodyNode = getNode(node, "body") @@ -507,10 +515,12 @@ export class Interpreter { const result = yield* self.evaluateStatement(bodyNode) if (result.kind === "continue") { + if (result.label !== undefined && !labels?.has(result.label)) return result continue } if (result.kind === "break") { + if (result.label !== undefined && !labels?.has(result.label)) return result return { kind: "none" } satisfies StatementResult } @@ -523,7 +533,10 @@ export class Interpreter { }) } - private evaluateDoWhileStatement(node: AstNode): Effect.Effect { + private evaluateDoWhileStatement( + node: AstNode, + labels?: ReadonlySet, + ): Effect.Effect { const bodyNode = getNode(node, "body") const testNode = getNode(node, "test") @@ -533,10 +546,12 @@ export class Interpreter { const result = yield* self.evaluateStatement(bodyNode) if (result.kind === "continue") { + if (result.label !== undefined && !labels?.has(result.label)) return result continue } if (result.kind === "break") { + if (result.label !== undefined && !labels?.has(result.label)) return result return { kind: "none" } satisfies StatementResult } @@ -549,7 +564,10 @@ export class Interpreter { }) } - private evaluateForStatement(node: AstNode): Effect.Effect { + private evaluateForStatement( + node: AstNode, + labels?: ReadonlySet, + ): Effect.Effect { this.scopes.push() const self = this return Effect.gen(function* () { @@ -593,9 +611,12 @@ export class Interpreter { } if (result.kind === "break") { + if (result.label !== undefined && !labels?.has(result.label)) return result return { kind: "none" } satisfies StatementResult } + if (result.kind === "continue" && result.label !== undefined && !labels?.has(result.label)) return result + nextIteration() if (updateNode) { yield* self.evaluateExpression(updateNode) @@ -610,7 +631,10 @@ export class Interpreter { }).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop()))) } - private evaluateForOfStatement(node: AstNode): Effect.Effect { + private evaluateForOfStatement( + node: AstNode, + labels?: ReadonlySet, + ): Effect.Effect { if (getBoolean(node, "await")) { throw new InterpreterRuntimeError("for await...of is not supported.", node) } @@ -667,10 +691,12 @@ export class Interpreter { } if (result.kind === "break") { + if (result.label !== undefined && !labels?.has(result.label)) return result return { kind: "none" } satisfies StatementResult } if (result.kind === "continue") { + if (result.label !== undefined && !labels?.has(result.label)) return result continue } } @@ -698,7 +724,10 @@ export class Interpreter { return undefined } - private evaluateForInStatement(node: AstNode): Effect.Effect { + private evaluateForInStatement( + node: AstNode, + labels?: ReadonlySet, + ): Effect.Effect { const left = getNode(node, "left") const declared = loopDeclaration(left, "for...in") if (declared?.lexical) this.scopes.push() @@ -748,10 +777,12 @@ export class Interpreter { } if (result.kind === "break") { + if (result.label !== undefined && !labels?.has(result.label)) return result return { kind: "none" } satisfies StatementResult } if (result.kind === "continue") { + if (result.label !== undefined && !labels?.has(result.label)) return result continue } } @@ -768,22 +799,36 @@ export class Interpreter { private evaluateBreakStatement(node: AstNode): StatementResult { const labelNode = getOptionalNode(node, "label") - - if (labelNode) { - throw new InterpreterRuntimeError("Labeled break is not supported in v1.", node) - } - - return { kind: "break" } + return labelNode ? { kind: "break", label: getString(labelNode, "name") } : { kind: "break" } } private evaluateContinueStatement(node: AstNode): StatementResult { const labelNode = getOptionalNode(node, "label") + return labelNode ? { kind: "continue", label: getString(labelNode, "name") } : { kind: "continue" } + } - if (labelNode) { - throw new InterpreterRuntimeError("Labeled continue is not supported in v1.", node) + private evaluateLabeledStatement(node: AstNode): Effect.Effect { + const labels = new Set() + let body = node + while (body.type === "LabeledStatement") { + labels.add(getString(getNode(body, "label"), "name")) + body = getNode(body, "body") } - return { kind: "continue" } + const evaluated = (() => { + if (body.type === "WhileStatement") return this.evaluateWhileStatement(body, labels) + if (body.type === "DoWhileStatement") return this.evaluateDoWhileStatement(body, labels) + if (body.type === "ForStatement") return this.evaluateForStatement(body, labels) + if (body.type === "ForOfStatement") return this.evaluateForOfStatement(body, labels) + if (body.type === "ForInStatement") return this.evaluateForInStatement(body, labels) + return this.evaluateStatement(body) + })() + + return Effect.map(evaluated, (result) => + result.kind === "break" && result.label !== undefined && labels.has(result.label) + ? ({ kind: "none" } satisfies StatementResult) + : result, + ) } private evaluateThrowStatement(node: AstNode): Effect.Effect { diff --git a/packages/codemode/test/labeled-control-test262.test.ts b/packages/codemode/test/labeled-control-test262.test.ts new file mode 100644 index 00000000000..37866b17cb4 --- /dev/null +++ b/packages/codemode/test/labeled-control-test262.test.ts @@ -0,0 +1,249 @@ +/* + * Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75: + * - test/language/statements/labeled/continue.js + * - test/language/statements/break/S12.8_A4_T1.js + * - test/language/statements/continue/simple-and-labeled.js + * - test/language/statements/continue/labeled-continue.js + * - test/language/statements/continue/nested-let-bound-for-loops-labeled-continue.js + * + * Copyright (C) 2009 the Sputnik authors. All rights reserved. + * Copyright (C) 2014, 2016 the V8 project authors. All rights reserved. + * Test262 portions are governed by the BSD license in LICENSE.test262. + */ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { CodeMode } from "../src/index.js" + +const execute = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} })) + +const value = async (code: string) => { + const result = await execute(code) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} + +describe("Test262 labeled break adaptations", () => { + test("break exits the matching labeled statement", async () => { + expect( + await value(` + const visited = [] + target: { + visited.push(1) + break target + visited.push(2) + } + visited.push(3) + return visited + `), + ).toEqual([1, 3]) + }) + + test("break crosses nested loops and switches without being consumed early", async () => { + expect( + await value(` + const visited = [] + outer: for (const item of [1, 2, 3]) { + switch (item) { + case 2: + break outer + default: + visited.push(item) + } + } + return visited + `), + ).toEqual([1]) + }) + + test("break targets every supported loop form", async () => { + expect( + await value(` + const counts = { while: 0, doWhile: 0, for: 0, forOf: 0, forIn: 0 } + whileLabel: while (true) { + counts.while++ + break whileLabel + } + doLabel: do { + counts.doWhile++ + break doLabel + } while (true) + forLabel: for (;;) { + counts.for++ + break forLabel + } + forOfLabel: for (const item of [1, 2]) { + counts.forOf++ + break forOfLabel + } + forInLabel: for (const key in { a: 1, b: 2 }) { + counts.forIn++ + break forInLabel + } + return counts + `), + ).toEqual({ while: 1, doWhile: 1, for: 1, forOf: 1, forIn: 1 }) + }) + + test("nested labels consume only their matching break", async () => { + expect( + await value(` + const visited = [] + outer: { + inner: { + visited.push(1) + break outer + } + visited.push(2) + } + visited.push(3) + return visited + `), + ).toEqual([1, 3]) + }) +}) + +describe("Test262 labeled continue adaptations", () => { + test("continue targets its labeled for loop", async () => { + expect( + await value(` + let count = 0 + label: for (let index = 0; index < 10; index++) { + count++ + continue label + } + return count + `), + ).toBe(10) + }) + + test("continue escapes an inner loop for the targeted outer loop", async () => { + expect( + await value(` + let count = 0 + outer: for (let x = 0; x < 10; x++) { + let y = 0 + while (true) { + y++ + count++ + if (y === 1) continue outer + throw new Error("inner loop consumed the outer continue") + } + } + return count + `), + ).toBe(10) + }) + + test("multiple labels can target the same iteration statement", async () => { + expect( + await value(` + const visited = [] + outer: inner: for (const item of [1, 2, 3]) { + visited.push(item) + continue outer + } + return visited + `), + ).toEqual([1, 2, 3]) + }) + + test("labeled continue works for every supported loop form", async () => { + expect( + await value(` + const counts = { while: 0, doWhile: 0, forOf: 0, forIn: 0 } + let index = 0 + whileLabel: while (index++ < 2) { + counts.while++ + continue whileLabel + } + + index = 0 + doLabel: do { + counts.doWhile++ + if (index++ < 1) continue doLabel + } while (index < 2) + + forOfLabel: for (const item of [1, 2]) { + counts.forOf += item + continue forOfLabel + } + + forInLabel: for (const key in { a: 1, b: 2 }) { + counts.forIn++ + continue forInLabel + } + return counts + `), + ).toEqual({ while: 2, doWhile: 2, forOf: 3, forIn: 2 }) + }) + + test("labeled continues preserve per-iteration lexical bindings", async () => { + expect( + await value(` + const classic = [] + classicLabel: for (let index = 0; index < 3; index++) { + classic.push(() => index) + continue classicLabel + } + + const nested = [] + nestedLabel: for (let outer = 0; outer < 3; outer++) { + for (let inner = 0; inner < 2; inner++) { + nested.push(() => outer) + continue nestedLabel + } + } + + const forOf = [] + forOfLabel: for (const item of [1, 2, 3]) { + forOf.push(() => item) + continue forOfLabel + } + return [classic.map((read) => read()), nested.map((read) => read()), forOf.map((read) => read())] + `), + ).toEqual([ + [0, 1, 2], + [0, 1, 2], + [1, 2, 3], + ]) + }) + + test("finally completions override labeled loop control", async () => { + expect( + await value(` + const visited = [] + breakWins: for (const item of [1, 2, 3]) { + try { + continue breakWins + } finally { + visited.push(item) + break breakWins + } + } + + continueWins: for (const item of [4, 5, 6]) { + try { + break continueWins + } finally { + visited.push(item) + continue continueWins + } + } + return visited + `), + ).toEqual([1, 4, 5, 6]) + }) + + test("a label on a non-iteration statement is not a continue target", async () => { + const result = await execute(` + do { + target: { + continue target + } + } while (false) + `) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.kind).toBe("ParseError") + }) +})