diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts new file mode 100644 index 00000000000..c1407594ca3 --- /dev/null +++ b/packages/opencode/src/util/effect-zod.ts @@ -0,0 +1,92 @@ +import { Schema, SchemaAST } from "effect" +import z from "zod" + +export function zod(schema: S): z.ZodType> { + return walk(schema.ast) as z.ZodType> +} + +function walk(ast: SchemaAST.AST): z.ZodTypeAny { + const out = body(ast) + const desc = SchemaAST.resolveDescription(ast) + const ref = SchemaAST.resolveIdentifier(ast) + const next = desc ? out.describe(desc) : out + return ref ? next.meta({ ref }) : next +} + +function body(ast: SchemaAST.AST): z.ZodTypeAny { + if (SchemaAST.isOptional(ast)) return opt(ast) + + switch (ast._tag) { + case "String": + return z.string() + case "Number": + return z.number() + case "Boolean": + return z.boolean() + case "Null": + return z.null() + case "Undefined": + return z.undefined() + case "Any": + case "Unknown": + return z.unknown() + case "Never": + return z.never() + case "Literal": + return z.literal(ast.literal) + case "Union": + return union(ast) + case "Objects": + return object(ast) + case "Arrays": + return array(ast) + case "Declaration": + return decl(ast) + default: + return fail(ast) + } +} + +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]).optional() + return z.undefined().optional() +} + +function union(ast: SchemaAST.Union): z.ZodTypeAny { + const items = ast.types.map(walk) + if (items.length === 1) return items[0] + if (items.length < 2) return fail(ast) + return z.union(items as [z.ZodTypeAny, z.ZodTypeAny, ...Array]) +} + +function object(ast: SchemaAST.Objects): z.ZodTypeAny { + if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) { + const sig = ast.indexSignatures[0] + if (sig.parameter._tag !== "String") return fail(ast) + return z.record(z.string(), walk(sig.type)) + } + + if (ast.indexSignatures.length > 0) return fail(ast) + + return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)]))) +} + +function array(ast: SchemaAST.Arrays): z.ZodTypeAny { + if (ast.elements.length > 0) return fail(ast) + if (ast.rest.length !== 1) return fail(ast) + return z.array(walk(ast.rest[0])) +} + +function decl(ast: SchemaAST.Declaration): z.ZodTypeAny { + if (ast.typeParameters.length !== 1) return fail(ast) + return walk(ast.typeParameters[0]) +} + +function fail(ast: SchemaAST.AST): never { + const ref = SchemaAST.resolveIdentifier(ast) + throw new Error(`unsupported effect schema: ${ref ?? ast._tag}`) +} diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts new file mode 100644 index 00000000000..4004ca2d231 --- /dev/null +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" + +import { zod } from "../../src/util/effect-zod" + +describe("util.effect-zod", () => { + test("converts class schemas for route dto shapes", () => { + class Method extends Schema.Class("ProviderAuthMethod")({ + type: Schema.Union([Schema.Literal("oauth"), Schema.Literal("api")]), + label: Schema.String, + }) {} + + const out = zod(Method) + + expect(out.meta()?.ref).toBe("ProviderAuthMethod") + expect( + out.parse({ + type: "oauth", + label: "OAuth", + }), + ).toEqual({ + type: "oauth", + label: "OAuth", + }) + }) + + test("converts structs with optional fields, arrays, and records", () => { + const out = zod( + Schema.Struct({ + foo: Schema.optional(Schema.String), + bar: Schema.Array(Schema.Number), + baz: Schema.Record(Schema.String, Schema.Boolean), + }), + ) + + expect( + out.parse({ + bar: [1, 2], + baz: { ok: true }, + }), + ).toEqual({ + bar: [1, 2], + baz: { ok: true }, + }) + expect( + out.parse({ + foo: "hi", + bar: [1], + baz: { ok: false }, + }), + ).toEqual({ + foo: "hi", + bar: [1], + baz: { ok: false }, + }) + }) + + test("throws for unsupported tuple schemas", () => { + expect(() => zod(Schema.Tuple([Schema.String, Schema.Number]))).toThrow("unsupported effect schema") + }) +})