Skip to content

Commit 5980b0a

Browse files
authored
feat(effect-zod): add tuple support; migrate config/plugin to Effect Schema (#23178)
1 parent 89029a2 commit 5980b0a

5 files changed

Lines changed: 48 additions & 12 deletions

File tree

packages/opencode/src/cli/cmd/tui/config/tui-schema.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const TuiInfo = z
3131
$schema: z.string().optional(),
3232
theme: z.string().optional(),
3333
keybinds: KeybindOverride.optional(),
34-
plugin: ConfigPlugin.Spec.array().optional(),
34+
plugin: ConfigPlugin.Spec.zod.array().optional(),
3535
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
3636
})
3737
.extend(TuiOptions.shape)

packages/opencode/src/config/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export const Info = z
113113
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
114114
),
115115
// User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged.
116-
plugin: ConfigPlugin.Spec.array().optional(),
116+
plugin: ConfigPlugin.Spec.zod.array().optional(),
117117
share: z
118118
.enum(["manual", "auto", "disabled"])
119119
.optional()

packages/opencode/src/config/plugin.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
import { Glob } from "@opencode-ai/shared/util/glob"
2-
import z from "zod"
2+
import { Schema } from "effect"
33
import { pathToFileURL } from "url"
44
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
5+
import { zod } from "@/util/effect-zod"
6+
import { withStatics } from "@/util/schema"
57
import path from "path"
68

7-
const Options = z.record(z.string(), z.unknown())
8-
export type Options = z.infer<typeof Options>
9+
export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) })))
10+
export type Options = Schema.Schema.Type<typeof Options>
911

1012
// Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options.
1113
// It answers "what should we load?" but says nothing about where that value came from.
12-
export const Spec = z.union([z.string(), z.tuple([z.string(), Options])])
13-
export type Spec = z.infer<typeof Spec>
14+
export const Spec = Schema.Union([
15+
Schema.String,
16+
Schema.mutable(Schema.Tuple([Schema.String, Options])),
17+
]).pipe(withStatics((s) => ({ zod: zod(s) })))
18+
export type Spec = Schema.Schema.Type<typeof Spec>
1419

1520
export type Scope = "global" | "local"
1621

packages/opencode/src/util/effect-zod.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,16 @@ function object(ast: SchemaAST.Objects): z.ZodTypeAny {
119119
}
120120

121121
function array(ast: SchemaAST.Arrays): z.ZodTypeAny {
122-
if (ast.elements.length > 0) return fail(ast)
123-
if (ast.rest.length !== 1) return fail(ast)
124-
return z.array(walk(ast.rest[0]))
122+
// Pure variadic arrays: { elements: [], rest: [item] }
123+
if (ast.elements.length === 0) {
124+
if (ast.rest.length !== 1) return fail(ast)
125+
return z.array(walk(ast.rest[0]))
126+
}
127+
// Fixed-length tuples: { elements: [a, b, ...], rest: [] }
128+
// Tuples with a variadic tail (...rest) are not yet supported.
129+
if (ast.rest.length > 0) return fail(ast)
130+
const items = ast.elements.map(walk)
131+
return z.tuple(items as [z.ZodTypeAny, ...Array<z.ZodTypeAny>])
125132
}
126133

127134
function decl(ast: SchemaAST.Declaration): z.ZodTypeAny {

packages/opencode/test/util/effect-zod.test.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,32 @@ describe("util.effect-zod", () => {
6161
})
6262
})
6363

64-
test("throws for unsupported tuple schemas", () => {
65-
expect(() => zod(Schema.Tuple([Schema.String, Schema.Number]))).toThrow("unsupported effect schema")
64+
describe("Tuples", () => {
65+
test("fixed-length tuple parses matching array", () => {
66+
const out = zod(Schema.Tuple([Schema.String, Schema.Number]))
67+
expect(out.parse(["a", 1])).toEqual(["a", 1])
68+
expect(out.safeParse(["a"]).success).toBe(false)
69+
expect(out.safeParse(["a", "b"]).success).toBe(false)
70+
})
71+
72+
test("single-element tuple parses a one-element array", () => {
73+
const out = zod(Schema.Tuple([Schema.Boolean]))
74+
expect(out.parse([true])).toEqual([true])
75+
expect(out.safeParse([true, false]).success).toBe(false)
76+
})
77+
78+
test("tuple inside a union picks the right branch", () => {
79+
const out = zod(Schema.Union([Schema.String, Schema.Tuple([Schema.String, Schema.Number])]))
80+
expect(out.parse("hello")).toBe("hello")
81+
expect(out.parse(["foo", 42])).toEqual(["foo", 42])
82+
expect(out.safeParse(["foo"]).success).toBe(false)
83+
})
84+
85+
test("plain arrays still work (no element positions)", () => {
86+
const out = zod(Schema.Array(Schema.String))
87+
expect(out.parse(["a", "b", "c"])).toEqual(["a", "b", "c"])
88+
expect(out.parse([])).toEqual([])
89+
})
6690
})
6791

6892
test("string literal unions produce z.enum with enum in JSON Schema", () => {

0 commit comments

Comments
 (0)