summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/opencode/src/util/effect-zod.ts48
-rw-r--r--packages/opencode/test/util/effect-zod.test.ts87
2 files changed, 129 insertions, 6 deletions
diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts
index 227a70844..cdcd99c97 100644
--- a/packages/opencode/src/util/effect-zod.ts
+++ b/packages/opencode/src/util/effect-zod.ts
@@ -40,7 +40,12 @@ function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny {
// Declarations fall through to body(), not encoded(). User-level
// Schema.decodeTo / Schema.transform attach encoding to non-Declaration
// nodes, where we do apply the transform.
- const hasTransform = ast.encoding?.length && ast._tag !== "Declaration"
+ //
+ // Schema.withDecodingDefault also attaches encoding, but we want `.default(v)`
+ // on the inner Zod rather than a transform wrapper — so optional ASTs whose
+ // encoding resolves a default from Option.none() route through body()/opt().
+ const hasEncoding = ast.encoding?.length && ast._tag !== "Declaration"
+ const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined)
const base = hasTransform ? encoded(ast) : body(ast)
const out = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base
const desc = SchemaAST.resolveDescription(ast)
@@ -217,10 +222,43 @@ function body(ast: SchemaAST.AST): z.ZodTypeAny {
function opt(ast: SchemaAST.AST): z.ZodTypeAny {
if (ast._tag !== "Union") return fail(ast)
const items = ast.types.filter((item) => item._tag !== "Undefined")
- if (items.length === 1) return walk(items[0]).optional()
- if (items.length > 1)
- return z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>]).optional()
- return z.undefined().optional()
+ const inner =
+ items.length === 1
+ ? walk(items[0])
+ : items.length > 1
+ ? z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>])
+ : z.undefined()
+ // Schema.withDecodingDefault attaches an encoding `Link` whose transformation
+ // decode Getter resolves `Option.none()` to `Option.some(default)`. Invoke
+ // it to extract the default and emit `.default(...)` instead of `.optional()`.
+ const fallback = extractDefault(ast)
+ if (fallback !== undefined) return inner.default(fallback.value)
+ return inner.optional()
+}
+
+type DecodeLink = {
+ readonly transformation: {
+ readonly decode: {
+ readonly run: (
+ input: Option.Option<unknown>,
+ options: SchemaAST.ParseOptions,
+ ) => Effect.Effect<Option.Option<unknown>, unknown>
+ }
+ }
+}
+
+function extractDefault(ast: SchemaAST.AST): { value: unknown } | undefined {
+ const encoding = (ast as { encoding?: ReadonlyArray<DecodeLink> }).encoding
+ if (!encoding?.length) return undefined
+ // Walk the chain of encoding Links in order; the first Getter that produces
+ // a value from Option.none wins. withDecodingDefault always puts its
+ // defaulting Link adjacent to the optional Union.
+ for (const link of encoding) {
+ const probe = Effect.runSyncExit(link.transformation.decode.run(Option.none(), {}))
+ if (probe._tag !== "Success") continue
+ if (Option.isSome(probe.value)) return { value: probe.value.value }
+ }
+ return undefined
}
function union(ast: SchemaAST.Union): z.ZodTypeAny {
diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts
index 1d999e979..7ce43af5f 100644
--- a/packages/opencode/test/util/effect-zod.test.ts
+++ b/packages/opencode/test/util/effect-zod.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
-import { Schema, SchemaGetter } from "effect"
+import { Effect, Schema, SchemaGetter } from "effect"
import z from "zod"
import { zod, ZodOverride } from "../../src/util/effect-zod"
@@ -669,4 +669,89 @@ describe("util.effect-zod", () => {
expect(shape.properties.port.exclusiveMinimum).toBe(0)
})
})
+
+ describe("Schema.optionalWith defaults", () => {
+ test("parsing undefined returns the default value", () => {
+ const schema = zod(
+ Schema.Struct({
+ mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
+ }),
+ )
+ expect(schema.parse({})).toEqual({ mode: "ctrl-x" })
+ expect(schema.parse({ mode: undefined })).toEqual({ mode: "ctrl-x" })
+ })
+
+ test("parsing a real value returns that value (default does not fire)", () => {
+ const schema = zod(
+ Schema.Struct({
+ mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
+ }),
+ )
+ expect(schema.parse({ mode: "ctrl-y" })).toEqual({ mode: "ctrl-y" })
+ })
+
+ test("default on a number field", () => {
+ const schema = zod(
+ Schema.Struct({
+ count: Schema.Number.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(42))),
+ }),
+ )
+ expect(schema.parse({})).toEqual({ count: 42 })
+ expect(schema.parse({ count: 7 })).toEqual({ count: 7 })
+ })
+
+ test("multiple defaulted fields inside a struct", () => {
+ const schema = zod(
+ Schema.Struct({
+ leader: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
+ quit: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-c"))),
+ inner: Schema.String,
+ }),
+ )
+ expect(schema.parse({ inner: "hi" })).toEqual({
+ leader: "ctrl-x",
+ quit: "ctrl-c",
+ inner: "hi",
+ })
+ expect(schema.parse({ leader: "a", quit: "b", inner: "c" })).toEqual({
+ leader: "a",
+ quit: "b",
+ inner: "c",
+ })
+ })
+
+ test("JSON Schema output includes the default key", () => {
+ const schema = zod(
+ Schema.Struct({
+ mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
+ }),
+ )
+ const shape = json(schema) as any
+ expect(shape.properties.mode.default).toBe("ctrl-x")
+ })
+
+ test("default referencing a computed value resolves when evaluated", () => {
+ // Simulates `keybinds.ts` style of per-platform defaults: the default is
+ // produced by an Effect that computes a value at decode time.
+ const platform = "darwin"
+ const fallback = platform === "darwin" ? "cmd-k" : "ctrl-k"
+ const schema = zod(
+ Schema.Struct({
+ command_palette: Schema.String.pipe(
+ Schema.optional,
+ Schema.withDecodingDefault(Effect.sync(() => fallback)),
+ ),
+ }),
+ )
+ expect(schema.parse({})).toEqual({ command_palette: "cmd-k" })
+ const shape = json(schema) as any
+ expect(shape.properties.command_palette.default).toBe("cmd-k")
+ })
+
+ test("plain Schema.optional (no default) still emits .optional() (regression)", () => {
+ const schema = zod(Schema.Struct({ foo: Schema.optional(Schema.String) }))
+ expect(schema.parse({})).toEqual({})
+ expect(schema.parse({ foo: "hi" })).toEqual({ foo: "hi" })
+ })
+ })
})