Skip to content

Commit e7686db

Browse files
authored
feat(effect-zod): translate Schema.check filters into zod .superRefine + promote LSP refinement to Effect layer (#23173)
1 parent 47f553f commit e7686db

4 files changed

Lines changed: 190 additions & 19 deletions

File tree

packages/opencode/src/config/lsp.ts

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,24 +20,26 @@ export const Entry = Schema.Union([
2020
}),
2121
]).pipe(withStatics((s) => ({ zod: zod(s) })))
2222

23-
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]).pipe(
24-
withStatics((s) => ({
25-
zod: zod(s).refine(
26-
(data) => {
27-
if (typeof data === "boolean") return true
28-
const serverIds = new Set(Object.values(LSPServer).map((server) => server.id))
29-
30-
return Object.entries(data).every(([id, config]) => {
31-
if (config.disabled) return true
32-
if (serverIds.has(id)) return true
33-
return Boolean(config.extensions)
34-
})
35-
},
36-
{
37-
error: "For custom LSP servers, 'extensions' array is required.",
38-
},
39-
),
40-
})),
23+
/**
24+
* For custom (non-builtin) LSP server entries, `extensions` is required so the
25+
* client knows which files the server should attach to. Builtin server IDs and
26+
* explicitly disabled entries are exempt.
27+
*/
28+
export const requiresExtensionsForCustomServers = Schema.makeFilter<boolean | Record<string, Schema.Schema.Type<typeof Entry>>>(
29+
(data) => {
30+
if (typeof data === "boolean") return undefined
31+
const serverIds = new Set(Object.values(LSPServer).map((server) => server.id))
32+
const ok = Object.entries(data).every(([id, config]) => {
33+
if ("disabled" in config && config.disabled) return true
34+
if (serverIds.has(id)) return true
35+
return "extensions" in config && Boolean(config.extensions)
36+
})
37+
return ok ? undefined : "For custom LSP servers, 'extensions' array is required."
38+
},
4139
)
4240

41+
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])
42+
.check(requiresExtensionsForCustomServers)
43+
.pipe(withStatics((s) => ({ zod: zod(s) })))
44+
4345
export type Info = Schema.Schema.Type<typeof Info>

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,34 @@ function walk(ast: SchemaAST.AST): z.ZodTypeAny {
1616
const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined
1717
if (override) return override
1818

19-
const out = body(ast)
19+
let out = body(ast)
20+
for (const check of ast.checks ?? []) {
21+
out = applyCheck(out, check, ast)
22+
}
2023
const desc = SchemaAST.resolveDescription(ast)
2124
const ref = SchemaAST.resolveIdentifier(ast)
2225
const next = desc ? out.describe(desc) : out
2326
return ref ? next.meta({ ref }) : next
2427
}
2528

29+
function applyCheck(out: z.ZodTypeAny, check: SchemaAST.Check<any>, ast: SchemaAST.AST): z.ZodTypeAny {
30+
if (check._tag === "FilterGroup") {
31+
return check.checks.reduce((acc, sub) => applyCheck(acc, sub, ast), out)
32+
}
33+
return out.superRefine((value, ctx) => {
34+
const issue = check.run(value, ast, {} as any)
35+
if (!issue) return
36+
const message = issueMessage(issue) ?? (check.annotations as any)?.message ?? "Validation failed"
37+
ctx.addIssue({ code: "custom", message })
38+
})
39+
}
40+
41+
function issueMessage(issue: any): string | undefined {
42+
if (typeof issue?.annotations?.message === "string") return issue.annotations.message
43+
if (typeof issue?.message === "string") return issue.message
44+
return undefined
45+
}
46+
2647
function body(ast: SchemaAST.AST): z.ZodTypeAny {
2748
if (SchemaAST.isOptional(ast)) return opt(ast)
2849

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { Schema } from "effect"
3+
import { ConfigLSP } from "../../src/config/lsp"
4+
5+
// The LSP config refinement enforces: any custom (non-builtin) LSP server
6+
// entry must declare an `extensions` array so the client knows which files
7+
// the server should attach to. Builtin server IDs and explicitly disabled
8+
// entries are exempt.
9+
//
10+
// Both validation paths must honor this rule:
11+
// - `Schema.decodeUnknownSync(ConfigLSP.Info)` (Effect layer)
12+
// - `ConfigLSP.Info.zod.parse(...)` (derived Zod)
13+
//
14+
// `typescript` is a builtin server id (see src/lsp/server.ts).
15+
describe("ConfigLSP.Info refinement", () => {
16+
const decodeEffect = Schema.decodeUnknownSync(ConfigLSP.Info)
17+
18+
describe("accepted inputs", () => {
19+
test("true and false pass (top-level toggle)", () => {
20+
expect(decodeEffect(true)).toBe(true)
21+
expect(decodeEffect(false)).toBe(false)
22+
expect(ConfigLSP.Info.zod.parse(true)).toBe(true)
23+
expect(ConfigLSP.Info.zod.parse(false)).toBe(false)
24+
})
25+
26+
test("builtin server with no extensions passes", () => {
27+
const input = { typescript: { command: ["typescript-language-server", "--stdio"] } }
28+
expect(decodeEffect(input)).toEqual(input)
29+
expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
30+
})
31+
32+
test("custom server WITH extensions passes", () => {
33+
const input = {
34+
"my-lsp": { command: ["my-lsp-bin"], extensions: [".ml"] },
35+
}
36+
expect(decodeEffect(input)).toEqual(input)
37+
expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
38+
})
39+
40+
test("disabled custom server passes (no extensions needed)", () => {
41+
const input = { "my-lsp": { disabled: true as const } }
42+
expect(decodeEffect(input)).toEqual(input)
43+
expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
44+
})
45+
46+
test("mix of builtin and custom with extensions passes", () => {
47+
const input = {
48+
typescript: { command: ["typescript-language-server", "--stdio"] },
49+
"my-lsp": { command: ["my-lsp-bin"], extensions: [".ml"] },
50+
}
51+
expect(decodeEffect(input)).toEqual(input)
52+
expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
53+
})
54+
})
55+
56+
describe("rejected inputs", () => {
57+
const expectedMessage = "For custom LSP servers, 'extensions' array is required."
58+
59+
test("custom server WITHOUT extensions fails via Effect decode", () => {
60+
expect(() => decodeEffect({ "my-lsp": { command: ["my-lsp-bin"] } })).toThrow(expectedMessage)
61+
})
62+
63+
test("custom server WITHOUT extensions fails via derived Zod", () => {
64+
const result = ConfigLSP.Info.zod.safeParse({ "my-lsp": { command: ["my-lsp-bin"] } })
65+
expect(result.success).toBe(false)
66+
expect(result.error!.issues.some((i) => i.message === expectedMessage)).toBe(true)
67+
})
68+
69+
test("custom server with empty extensions array fails (extensions must be non-empty-truthy)", () => {
70+
// Boolean(['']) is true, so a non-empty array of strings is fine.
71+
// Boolean([]) is also true in JS, so empty arrays are accepted by the
72+
// refinement. This test documents current behavior.
73+
const input = { "my-lsp": { command: ["my-lsp-bin"], extensions: [] } }
74+
expect(decodeEffect(input)).toEqual(input)
75+
expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
76+
})
77+
78+
test("custom server without extensions mixed with a valid builtin still fails", () => {
79+
const input = {
80+
typescript: { command: ["typescript-language-server", "--stdio"] },
81+
"my-lsp": { command: ["my-lsp-bin"] },
82+
}
83+
expect(() => decodeEffect(input)).toThrow(expectedMessage)
84+
expect(ConfigLSP.Info.zod.safeParse(input).success).toBe(false)
85+
})
86+
})
87+
})

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,4 +186,65 @@ describe("util.effect-zod", () => {
186186
const schema = json(zod(Parent)) as any
187187
expect(schema.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" })
188188
})
189+
190+
describe("Schema.check translation", () => {
191+
test("filter returning string triggers refinement with that message", () => {
192+
const isEven = Schema.makeFilter((n: number) =>
193+
n % 2 === 0 ? undefined : "expected an even number",
194+
)
195+
const schema = zod(Schema.Number.check(isEven))
196+
197+
expect(schema.parse(4)).toBe(4)
198+
const result = schema.safeParse(3)
199+
expect(result.success).toBe(false)
200+
expect(result.error!.issues[0].message).toBe("expected an even number")
201+
})
202+
203+
test("filter returning false triggers refinement with fallback message", () => {
204+
const nonEmpty = Schema.makeFilter((s: string) => s.length > 0)
205+
const schema = zod(Schema.String.check(nonEmpty))
206+
207+
expect(schema.parse("hi")).toBe("hi")
208+
const result = schema.safeParse("")
209+
expect(result.success).toBe(false)
210+
expect(result.error!.issues[0].message).toMatch(/./)
211+
})
212+
213+
test("filter returning undefined passes validation", () => {
214+
const alwaysOk = Schema.makeFilter(() => undefined)
215+
const schema = zod(Schema.Number.check(alwaysOk))
216+
217+
expect(schema.parse(42)).toBe(42)
218+
})
219+
220+
test("annotations.message on the filter is used when filter returns false", () => {
221+
const positive = Schema.makeFilter(
222+
(n: number) => n > 0,
223+
{ message: "must be positive" },
224+
)
225+
const schema = zod(Schema.Number.check(positive))
226+
227+
const result = schema.safeParse(-1)
228+
expect(result.success).toBe(false)
229+
expect(result.error!.issues[0].message).toBe("must be positive")
230+
})
231+
232+
test("cross-field check on a record flags missing key", () => {
233+
const hasKey = Schema.makeFilter(
234+
(data: Record<string, { enabled: boolean }>) =>
235+
"required" in data ? undefined : "missing 'required' key",
236+
)
237+
const schema = zod(
238+
Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })).check(hasKey),
239+
)
240+
241+
expect(schema.parse({ required: { enabled: true } })).toEqual({
242+
required: { enabled: true },
243+
})
244+
245+
const result = schema.safeParse({ other: { enabled: true } })
246+
expect(result.success).toBe(false)
247+
expect(result.error!.issues[0].message).toBe("missing 'required' key")
248+
})
249+
})
189250
})

0 commit comments

Comments
 (0)