Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b1bffd9d7 | |||
| e770415fd5 | |||
| 96dc560833 | |||
| eb4ff91c2d | |||
| a81c04fd31 | |||
| 63e2054f50 | |||
| 09a38f1984 |
@@ -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
|
||||
|
||||
@@ -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<unknown> | CodeModeRegExp | CodeModeURL
|
||||
|
||||
@@ -341,6 +341,8 @@ export class Interpreter<R> {
|
||||
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<R> {
|
||||
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<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateWhileStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
private evaluateWhileStatement(
|
||||
node: AstNode,
|
||||
labels?: ReadonlySet<string>,
|
||||
): Effect.Effect<StatementResult, unknown, R> {
|
||||
const testNode = getNode(node, "test")
|
||||
const bodyNode = getNode(node, "body")
|
||||
|
||||
@@ -507,10 +515,12 @@ export class Interpreter<R> {
|
||||
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<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateDoWhileStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
private evaluateDoWhileStatement(
|
||||
node: AstNode,
|
||||
labels?: ReadonlySet<string>,
|
||||
): Effect.Effect<StatementResult, unknown, R> {
|
||||
const bodyNode = getNode(node, "body")
|
||||
const testNode = getNode(node, "test")
|
||||
|
||||
@@ -533,10 +546,12 @@ export class Interpreter<R> {
|
||||
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<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateForStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
private evaluateForStatement(
|
||||
node: AstNode,
|
||||
labels?: ReadonlySet<string>,
|
||||
): Effect.Effect<StatementResult, unknown, R> {
|
||||
this.scopes.push()
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
@@ -593,9 +611,12 @@ export class Interpreter<R> {
|
||||
}
|
||||
|
||||
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<R> {
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop())))
|
||||
}
|
||||
|
||||
private evaluateForOfStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
private evaluateForOfStatement(
|
||||
node: AstNode,
|
||||
labels?: ReadonlySet<string>,
|
||||
): Effect.Effect<StatementResult, unknown, R> {
|
||||
if (getBoolean(node, "await")) {
|
||||
throw new InterpreterRuntimeError("for await...of is not supported.", node)
|
||||
}
|
||||
@@ -667,10 +691,12 @@ export class Interpreter<R> {
|
||||
}
|
||||
|
||||
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<R> {
|
||||
return undefined
|
||||
}
|
||||
|
||||
private evaluateForInStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
private evaluateForInStatement(
|
||||
node: AstNode,
|
||||
labels?: ReadonlySet<string>,
|
||||
): Effect.Effect<StatementResult, unknown, R> {
|
||||
const left = getNode(node, "left")
|
||||
const declared = loopDeclaration(left, "for...in")
|
||||
if (declared?.lexical) this.scopes.push()
|
||||
@@ -748,10 +777,12 @@ export class Interpreter<R> {
|
||||
}
|
||||
|
||||
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<R> {
|
||||
|
||||
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<StatementResult, unknown, R> {
|
||||
const labels = new Set<string>()
|
||||
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<StatementResult, unknown, R> {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
+62
-21
@@ -1,5 +1,26 @@
|
||||
export * as Patch from "./patch"
|
||||
|
||||
import { Result, Schema } from "effect"
|
||||
|
||||
export class BoundaryError extends Schema.TaggedErrorClass<BoundaryError>()("Patch.BoundaryError", {
|
||||
boundary: Schema.Literals(["first", "last"]),
|
||||
}) {
|
||||
override get message() {
|
||||
return `The ${this.boundary} line of the patch must be '${this.boundary === "first" ? "*** Begin Patch" : "*** End Patch"}'`
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidHunkError extends Schema.TaggedErrorClass<InvalidHunkError>()("Patch.InvalidHunkError", {
|
||||
line: Schema.String,
|
||||
lineNumber: Schema.Number,
|
||||
}) {
|
||||
override get message() {
|
||||
return `Invalid hunk at line ${this.lineNumber}: '${this.line}' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'`
|
||||
}
|
||||
}
|
||||
|
||||
export type ParseError = BoundaryError | InvalidHunkError
|
||||
|
||||
export type Hunk =
|
||||
| { readonly type: "add"; readonly path: string; readonly contents: string }
|
||||
| { readonly type: "delete"; readonly path: string }
|
||||
@@ -22,29 +43,33 @@ export interface FileUpdate {
|
||||
readonly bom: boolean
|
||||
}
|
||||
|
||||
export function parse(patchText: string): ReadonlyArray<Hunk> {
|
||||
const lines = stripHeredoc(patchText.trim()).split("\n")
|
||||
export function parse(patchText: string): Result.Result<ReadonlyArray<Hunk>, ParseError> {
|
||||
const lines = stripHeredoc(patchText.trim())
|
||||
.split("\n")
|
||||
.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line))
|
||||
const begin = lines.findIndex((line) => line.trim() === "*** Begin Patch")
|
||||
const end = lines.findIndex((line) => line.trim() === "*** End Patch")
|
||||
if (begin === -1 || end === -1 || begin >= end) throw new Error("Invalid patch format: missing Begin/End markers")
|
||||
if (begin === -1) return Result.fail(new BoundaryError({ boundary: "first" }))
|
||||
if (end === -1 || begin >= end) return Result.fail(new BoundaryError({ boundary: "last" }))
|
||||
|
||||
const hunks: Hunk[] = []
|
||||
let index = begin + 1
|
||||
while (index < end) {
|
||||
const line = lines[index]!
|
||||
if (line.startsWith("*** Add File:")) {
|
||||
const path = line.slice("*** Add File:".length).trim()
|
||||
const header = line.trim()
|
||||
if (header.startsWith("*** Add File:")) {
|
||||
const path = header.slice("*** Add File:".length).trim()
|
||||
if (!path) {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
const parsed = parseAdd(lines, index + 1)
|
||||
const parsed = parseAdd(lines, index + 1, end)
|
||||
hunks.push({ type: "add", path, contents: parsed.content })
|
||||
index = parsed.next
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("*** Delete File:")) {
|
||||
const path = line.slice("*** Delete File:".length).trim()
|
||||
if (header.startsWith("*** Delete File:")) {
|
||||
const path = header.slice("*** Delete File:".length).trim()
|
||||
if (!path) {
|
||||
index++
|
||||
continue
|
||||
@@ -53,8 +78,8 @@ export function parse(patchText: string): ReadonlyArray<Hunk> {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("*** Update File:")) {
|
||||
const path = line.slice("*** Update File:".length).trim()
|
||||
if (header.startsWith("*** Update File:")) {
|
||||
const path = header.slice("*** Update File:".length).trim()
|
||||
if (!path) {
|
||||
index++
|
||||
continue
|
||||
@@ -65,14 +90,22 @@ export function parse(patchText: string): ReadonlyArray<Hunk> {
|
||||
movePath = lines[next]!.slice("*** Move to:".length).trim()
|
||||
next++
|
||||
}
|
||||
const parsed = parseUpdate(lines, next)
|
||||
const parsed = parseUpdate(lines, next, end)
|
||||
hunks.push({ type: "update", path, movePath, chunks: parsed.chunks })
|
||||
index = parsed.next
|
||||
continue
|
||||
}
|
||||
index++
|
||||
}
|
||||
return hunks
|
||||
if (hunks.length === 0) {
|
||||
const invalid = lines.findIndex((line, index) => index > begin && index < end && line.trim() !== "")
|
||||
if (invalid !== -1) {
|
||||
return Result.fail(
|
||||
new InvalidHunkError({ line: lines[invalid]!.trim(), lineNumber: invalid + 1 }),
|
||||
)
|
||||
}
|
||||
}
|
||||
return Result.succeed(hunks)
|
||||
}
|
||||
|
||||
export function derive(path: string, chunks: ReadonlyArray<UpdateFileChunk>, original: string): FileUpdate {
|
||||
@@ -92,20 +125,20 @@ export function joinBom(text: string, bom: boolean) {
|
||||
return bom ? `\uFEFF${stripped}` : stripped
|
||||
}
|
||||
|
||||
function parseAdd(lines: ReadonlyArray<string>, start: number) {
|
||||
function parseAdd(lines: ReadonlyArray<string>, start: number, end: number) {
|
||||
const content: string[] = []
|
||||
let index = start
|
||||
while (index < lines.length && !lines[index]!.startsWith("***")) {
|
||||
while (index < end && !lines[index]!.startsWith("***")) {
|
||||
if (lines[index]!.startsWith("+")) content.push(lines[index]!.slice(1))
|
||||
index++
|
||||
}
|
||||
return { content: content.join("\n"), next: index }
|
||||
}
|
||||
|
||||
function parseUpdate(lines: ReadonlyArray<string>, start: number) {
|
||||
function parseUpdate(lines: ReadonlyArray<string>, start: number, end: number) {
|
||||
const chunks: UpdateFileChunk[] = []
|
||||
let index = start
|
||||
while (index < lines.length && !lines[index]!.startsWith("***")) {
|
||||
while (index < end && !lines[index]!.startsWith("***")) {
|
||||
if (!lines[index]!.startsWith("@@")) {
|
||||
index++
|
||||
continue
|
||||
@@ -115,7 +148,7 @@ function parseUpdate(lines: ReadonlyArray<string>, start: number) {
|
||||
const newLines: string[] = []
|
||||
let endOfFile = false
|
||||
index++
|
||||
while (index < lines.length && !lines[index]!.startsWith("@@") && !lines[index]!.startsWith("***")) {
|
||||
while (index < end && !lines[index]!.startsWith("@@") && !lines[index]!.startsWith("***")) {
|
||||
const line = lines[index]!
|
||||
if (line.startsWith(" ")) {
|
||||
oldLines.push(line.slice(1))
|
||||
@@ -124,6 +157,10 @@ function parseUpdate(lines: ReadonlyArray<string>, start: number) {
|
||||
else if (line.startsWith("+")) newLines.push(line.slice(1))
|
||||
index++
|
||||
}
|
||||
if (lines[index]?.trim() === "*** End of File") {
|
||||
endOfFile = true
|
||||
index++
|
||||
}
|
||||
chunks.push({ oldLines, newLines, changeContext, endOfFile: endOfFile || undefined })
|
||||
}
|
||||
return { chunks, next: index }
|
||||
@@ -159,11 +196,15 @@ function computeReplacements(lines: ReadonlyArray<string>, path: string, chunks:
|
||||
|
||||
function seek(lines: ReadonlyArray<string>, pattern: ReadonlyArray<string>, start: number, eof = false) {
|
||||
if (pattern.length === 0) return -1
|
||||
for (const compare of [exact, rstrip, trim, normalized]) {
|
||||
if (eof) {
|
||||
const offset = lines.length - pattern.length
|
||||
if (offset >= start && matches(lines, pattern, offset, compare)) return offset
|
||||
if (eof) {
|
||||
const offset = lines.length - pattern.length
|
||||
if (offset < start) return -1
|
||||
for (const compare of [exact, rstrip, trim, normalized]) {
|
||||
if (matches(lines, pattern, offset, compare)) return offset
|
||||
}
|
||||
return -1
|
||||
}
|
||||
for (const compare of [exact, rstrip, trim, normalized]) {
|
||||
for (let offset = start; offset <= lines.length - pattern.length; offset++) {
|
||||
if (matches(lines, pattern, offset, compare)) return offset
|
||||
}
|
||||
|
||||
@@ -97,10 +97,11 @@ export const Plugin = {
|
||||
callID: context.callID,
|
||||
}
|
||||
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
|
||||
const hunks = yield* Effect.try({
|
||||
try: () => Patch.parse(input.patchText),
|
||||
catch: (cause) => new ToolFailure({ message: `patch verification failed: ${String(cause)}` }),
|
||||
})
|
||||
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `patch verification failed: ${error.message}` }),
|
||||
),
|
||||
)
|
||||
if (hunks.length === 0) {
|
||||
const normalized = input.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
|
||||
if (normalized === "*** Begin Patch\n*** End Patch") {
|
||||
@@ -110,6 +111,7 @@ export const Plugin = {
|
||||
}
|
||||
const prepared: Prepared[] = []
|
||||
const targets: Target[] = []
|
||||
const updates = new Map<string, string>()
|
||||
for (const hunk of hunks) {
|
||||
yield* Effect.gen(function* () {
|
||||
const target = resolveTarget(location, hunk.path)
|
||||
@@ -153,16 +155,34 @@ export const Plugin = {
|
||||
prepared.push({ ...hunk, target, before: original.replace(/^\uFEFF/, ""), after: "" })
|
||||
return
|
||||
}
|
||||
const stats = yield* fs
|
||||
.stat(target.canonical)
|
||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!stats || stats.type === "Directory") {
|
||||
return yield* new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update: ${target.canonical}`,
|
||||
})
|
||||
}
|
||||
const content = yield* fs.readFile(target.canonical)
|
||||
const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(content)
|
||||
const previous = updates.get(target.canonical)
|
||||
const original =
|
||||
previous ??
|
||||
(yield* Effect.gen(function* () {
|
||||
const stats = yield* fs.stat(target.canonical).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (stats.type === "Directory") {
|
||||
return yield* new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: path is a directory`,
|
||||
})
|
||||
}
|
||||
return new TextDecoder("utf-8", { ignoreBOM: true }).decode(
|
||||
yield* fs.readFile(target.canonical).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}))
|
||||
const before = original.replace(/^\uFEFF/, "")
|
||||
const update = yield* Effect.try({
|
||||
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
||||
@@ -192,6 +212,7 @@ export const Plugin = {
|
||||
after: update.content,
|
||||
moveTarget,
|
||||
})
|
||||
if (!moveTarget) updates.set(target.canonical, Patch.joinBom(update.content, update.bom))
|
||||
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail(hunk.path, error))))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Patch } from "@opencode-ai/core/patch"
|
||||
import { Result } from "effect"
|
||||
|
||||
const parse = (input: string) => Result.getOrThrow(Patch.parse(input))
|
||||
|
||||
describe("Patch", () => {
|
||||
test("parses add, update, and delete hunks", () => {
|
||||
expect(
|
||||
Patch.parse(
|
||||
parse(
|
||||
"*** Begin Patch\n*** Add File: add.txt\n+added\n*** Update File: update.txt\n@@ section\n-old\n+new\n*** Delete File: delete.txt\n*** End Patch",
|
||||
),
|
||||
).toEqual([
|
||||
@@ -21,7 +24,7 @@ describe("Patch", () => {
|
||||
|
||||
test("parses a file move", () => {
|
||||
expect(
|
||||
Patch.parse(
|
||||
parse(
|
||||
"*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n-old\n+new\n*** End Patch",
|
||||
),
|
||||
).toEqual([
|
||||
@@ -34,22 +37,97 @@ describe("Patch", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("rejects invalid patch format", () => {
|
||||
expect(() => Patch.parse("This is not a valid patch")).toThrow("Invalid patch format")
|
||||
test("identifies the missing patch boundary", () => {
|
||||
expect(() => parse("This is not a valid patch")).toThrow(
|
||||
"The first line of the patch must be '*** Begin Patch'",
|
||||
)
|
||||
expect(() => parse("*** Begin Patch\n*** Add File: add.txt\n+added")).toThrow(
|
||||
"The last line of the patch must be '*** End Patch'",
|
||||
)
|
||||
})
|
||||
|
||||
test("strips a heredoc wrapper", () => {
|
||||
expect(Patch.parse("cat <<'EOF'\n*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch\nEOF")).toEqual([
|
||||
expect(parse("cat <<'EOF'\n*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch\nEOF")).toEqual([
|
||||
{ type: "add", path: "add.txt", contents: "added" },
|
||||
])
|
||||
})
|
||||
|
||||
test("strips a heredoc wrapper without cat", () => {
|
||||
expect(Patch.parse("<<EOF\n*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch\nEOF")).toEqual([
|
||||
expect(parse("<<EOF\n*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch\nEOF")).toEqual([
|
||||
{ type: "add", path: "add.txt", contents: "added" },
|
||||
])
|
||||
})
|
||||
|
||||
test("parses a whitespace-padded hunk header", () => {
|
||||
expect(parse("*** Begin Patch\n *** Update File: foo.txt\n@@\n-old\n+new\n*** End Patch")).toEqual([
|
||||
{
|
||||
type: "update",
|
||||
path: "foo.txt",
|
||||
movePath: undefined,
|
||||
chunks: [{ oldLines: ["old"], newLines: ["new"], changeContext: undefined, endOfFile: undefined }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("parses leading and trailing whitespace around patch markers", () => {
|
||||
expect(parse(" *** Begin Patch\n*** Update File: file.txt\n@@\n-one\n+two\n*** End Patch ")).toEqual([
|
||||
{
|
||||
type: "update",
|
||||
path: "file.txt",
|
||||
movePath: undefined,
|
||||
chunks: [{ oldLines: ["one"], newLines: ["two"], changeContext: undefined, endOfFile: undefined }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("parses whitespace on the inner sides of patch marker lines", () => {
|
||||
expect(parse("*** Begin Patch \n*** Update File: file.txt\n@@\n-one\n+two\n *** End Patch")).toEqual([
|
||||
{
|
||||
type: "update",
|
||||
path: "file.txt",
|
||||
movePath: undefined,
|
||||
chunks: [{ oldLines: ["one"], newLines: ["two"], changeContext: undefined, endOfFile: undefined }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("strips one carriage return from CRLF patch lines", () => {
|
||||
expect(parse("*** Begin Patch\r\n*** Update File: file.txt\r\n@@\r\n-old\r\n+new\r\n*** End Patch\r\n")).toEqual([
|
||||
{
|
||||
type: "update",
|
||||
path: "file.txt",
|
||||
movePath: undefined,
|
||||
chunks: [{ oldLines: ["old"], newLines: ["new"], changeContext: undefined, endOfFile: undefined }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves an extra carriage return in CRLF patch lines", () => {
|
||||
expect(parse("*** Begin Patch\r\n*** Update File: file.txt\r\n@@\r\n-old\r\r\n+new\r\n*** End Patch\r\n")).toEqual([
|
||||
{
|
||||
type: "update",
|
||||
path: "file.txt",
|
||||
movePath: undefined,
|
||||
chunks: [{ oldLines: ["old\r"], newLines: ["new"], changeContext: undefined, endOfFile: undefined }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves the end-of-file marker", () => {
|
||||
expect(
|
||||
parse(
|
||||
"*** Begin Patch\n*** Update File: file.txt\n@@\n+quux\n*** End of File\n\n*** End Patch",
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
type: "update",
|
||||
path: "file.txt",
|
||||
movePath: undefined,
|
||||
chunks: [{ oldLines: [], newLines: ["quux"], changeContext: undefined, endOfFile: true }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("derives fuzzy line updates while preserving BOM", () => {
|
||||
const update = Patch.derive("update.txt", [{ oldLines: [" old "], newLines: ["new"] }], "\uFEFFold\n")
|
||||
expect(update).toEqual({ content: "new\n", bom: true })
|
||||
@@ -111,14 +189,24 @@ describe("Patch", () => {
|
||||
).toBe("marker\nmiddle\nmarker changed\nend\n")
|
||||
})
|
||||
|
||||
test("does not fall back to a non-EOF match", () => {
|
||||
expect(() =>
|
||||
Patch.derive(
|
||||
"update.txt",
|
||||
[{ oldLines: ["marker", "end"], newLines: ["changed", "end"], endOfFile: true }],
|
||||
"marker\nend\nmiddle\n",
|
||||
),
|
||||
).toThrow("Failed to find expected lines")
|
||||
})
|
||||
|
||||
test("matches V1 lenient parsing of malformed hunk bodies", () => {
|
||||
expect(Patch.parse("*** Begin Patch\n*** Add File: add.txt\nmissing plus\n*** End Patch")).toEqual([
|
||||
expect(parse("*** Begin Patch\n*** Add File: add.txt\nmissing plus\n*** End Patch")).toEqual([
|
||||
{ type: "add", path: "add.txt", contents: "" },
|
||||
])
|
||||
expect(Patch.parse("*** Begin Patch\n*** Update File: update.txt\n*** End Patch")).toEqual([
|
||||
expect(parse("*** Begin Patch\n*** Update File: update.txt\n*** End Patch")).toEqual([
|
||||
{ type: "update", path: "update.txt", movePath: undefined, chunks: [] },
|
||||
])
|
||||
expect(Patch.parse("*** Begin Patch\n*** Delete File: delete.txt\nunexpected body\n*** End Patch")).toEqual([
|
||||
expect(parse("*** Begin Patch\n*** Delete File: delete.txt\nunexpected body\n*** End Patch")).toEqual([
|
||||
{ type: "delete", path: "delete.txt" },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -392,14 +392,32 @@ describe("PatchTool", () => {
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
const target = path.join(directory, "tail.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(target, "alpha\nlast\n"))
|
||||
yield* Effect.promise(() => fs.writeFile(target, "first\nsecond"))
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call(
|
||||
"*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch",
|
||||
"*** Begin Patch\n*** Update File: tail.txt\n@@\n first\n-second\n+second updated\n*** End of File\n*** End Patch",
|
||||
),
|
||||
)
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("alpha\nend\n")
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("first\nsecond updated\n")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("applies an end-of-file chunk to the final duplicate", () =>
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
const target = path.join(directory, "duplicates.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(target, "marker\nend\nmiddle\nmarker\nend\n"))
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call(
|
||||
"*** Begin Patch\n*** Update File: duplicates.txt\n@@\n-marker\n-end\n+marker changed\n+end\n*** End of File\n*** End Patch",
|
||||
),
|
||||
)
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe(
|
||||
"marker\nend\nmiddle\nmarker changed\nend\n",
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -433,9 +451,13 @@ describe("PatchTool", () => {
|
||||
it.live("rejects invalid patch format", () =>
|
||||
withTempTool((_directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* executeTool(registry, call("invalid patch"))).toMatchObject({
|
||||
expect(yield* executeTool(registry, call("invalid patch"))).toEqual({
|
||||
type: "error",
|
||||
value: expect.stringContaining("patch verification failed"),
|
||||
value: "patch verification failed: The first line of the patch must be '*** Begin Patch'",
|
||||
})
|
||||
expect(yield* executeTool(registry, call("*** Begin Patch\n*** Add File: foo\n+hello"))).toEqual({
|
||||
type: "error",
|
||||
value: "patch verification failed: The last line of the patch must be '*** End Patch'",
|
||||
})
|
||||
}),
|
||||
),
|
||||
@@ -460,7 +482,11 @@ describe("PatchTool", () => {
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"),
|
||||
),
|
||||
).toEqual({ type: "error", value: "patch verification failed: no hunks found" })
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value:
|
||||
"patch verification failed: Invalid hunk at line 2: '*** Frobnicate File: foo' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'",
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -479,6 +505,22 @@ describe("PatchTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("applies successive update operations to one file", () =>
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
const target = path.join(directory, "successive.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(target, "a\nb\n"))
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call(
|
||||
"*** Begin Patch\n*** Update File: successive.txt\n@@\n-a\n+A\n*** Update File: successive.txt\n@@\n-b\n+B\n*** End Patch",
|
||||
),
|
||||
)
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("A\nB\n")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not invent a first-line diff for BOM files", () =>
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -623,7 +665,7 @@ describe("PatchTool", () => {
|
||||
)
|
||||
|
||||
it.live("rejects an update when the target file is missing", () =>
|
||||
withTempTool((_directory, registry) =>
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* executeTool(
|
||||
@@ -632,7 +674,23 @@ describe("PatchTool", () => {
|
||||
),
|
||||
).toMatchObject({
|
||||
type: "error",
|
||||
value: expect.stringContaining("patch verification failed: Failed to read file to update"),
|
||||
value: expect.stringContaining(
|
||||
`patch verification failed: Failed to read file to update ${path.join(directory, "missing.txt")}: `,
|
||||
),
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("identifies a directory used as an update target", () =>
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "nested")))
|
||||
expect(
|
||||
yield* executeTool(registry, call("*** Begin Patch\n*** Update File: nested\n@@\n-old\n+new\n*** End Patch")),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value: `patch verification failed: Failed to read file to update ${path.join(directory, "nested")}: path is a directory`,
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -10,6 +10,7 @@ import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerRe
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { createServer } from "node:http"
|
||||
import { ServerAuth } from "./auth"
|
||||
import { isAllowedCorsOrigin } from "./cors"
|
||||
import { authorizedRequest } from "./middleware/authorization"
|
||||
import { withoutParentSpan } from "./request-tracing"
|
||||
import { createRoutes } from "./routes"
|
||||
@@ -48,7 +49,12 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
|
||||
const application = yield* Ref.make(Option.none<App>())
|
||||
// Request fibers may continue inbound trace context, but must not inherit the server startup parent.
|
||||
yield* bound.http
|
||||
.serve(dispatch(password, status, application, shutdown), HttpMiddleware.logger)
|
||||
.serve(
|
||||
dispatch(password, status, application, shutdown).pipe(
|
||||
HttpMiddleware.cors({ allowedOrigins: isAllowedCorsOrigin, maxAge: 86_400 }),
|
||||
),
|
||||
HttpMiddleware.logger,
|
||||
)
|
||||
.pipe(withoutParentSpan)
|
||||
if (lifecycle)
|
||||
yield* lifecycle.onListen(bound.http.address, Deferred.succeed(shutdown, undefined).pipe(Effect.asVoid)).pipe(
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerProcess } from "../src/process"
|
||||
|
||||
it.live("allows browser preflight requests without credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* ServerProcess.start<never, never>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
database: { path: ":memory:" },
|
||||
})
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/health", HttpServer.formatAddress(server.address)), {
|
||||
method: "OPTIONS",
|
||||
headers: {
|
||||
origin: "http://localhost:3000",
|
||||
"access-control-request-method": "GET",
|
||||
"access-control-request-headers": "authorization",
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(204)
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
|
||||
expect(response.headers.get("access-control-allow-headers")).toBe("authorization")
|
||||
|
||||
const health = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/health", HttpServer.formatAddress(server.address)), {
|
||||
headers: {
|
||||
authorization: `Basic ${btoa("opencode:secret")}`,
|
||||
origin: "http://localhost:3000",
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(health.status).toBe(200)
|
||||
expect(health.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
|
||||
}),
|
||||
)
|
||||
Reference in New Issue
Block a user