Migrate runtime validators to Effect Schema (#26975)

This commit is contained in:
Kit Langton
2026-05-11 21:41:56 -04:00
committed by GitHub
parent 9e8274d2da
commit 1007630347
4 changed files with 176 additions and 161 deletions

View File

@@ -1,33 +1,36 @@
import { Database } from "bun:sqlite"
import os from "node:os"
import path from "node:path"
import z from "zod"
import { Option, Schema } from "effect"
import { Filesystem } from "@/util/filesystem"
import type { EditorSelection } from "./editor"
const ZedEditorRowSchema = z.object({
item_kind: z.string(),
editor_id: z.number().nullable(),
workspace_id: z.number(),
workspace_paths: z.string().nullable(),
timestamp: z.string(),
buffer_path: z.string().nullable(),
const ZedEditorRowSchema = Schema.Struct({
item_kind: Schema.String,
editor_id: Schema.NullOr(Schema.Number),
workspace_id: Schema.Number,
workspace_paths: Schema.NullOr(Schema.String),
timestamp: Schema.String,
buffer_path: Schema.NullOr(Schema.String),
})
const ZedSelectionRowSchema = z.object({
selection_start: z.number().nullable(),
selection_end: z.number().nullable(),
const ZedSelectionRowSchema = Schema.Struct({
selection_start: Schema.NullOr(Schema.Number),
selection_end: Schema.NullOr(Schema.Number),
})
const ZedEditorContentsSchema = z.object({
contents: z.string().nullable(),
const ZedEditorContentsSchema = Schema.Struct({
contents: Schema.NullOr(Schema.String),
})
const decodeZedEditorRow = Schema.decodeUnknownOption(ZedEditorRowSchema)
const decodeZedSelectionRow = Schema.decodeUnknownOption(ZedSelectionRowSchema)
const decodeZedEditorContents = Schema.decodeUnknownOption(ZedEditorContentsSchema)
const utf8 = new TextEncoder()
type ZedEditorRow = z.infer<typeof ZedEditorRowSchema>
type ZedEditorRow = Schema.Schema.Type<typeof ZedEditorRowSchema>
type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number }
type ZedSelectionRow = z.infer<typeof ZedSelectionRowSchema>
export type ZedSelectionResult =
| { type: "selection"; selection: EditorSelection }
@@ -107,8 +110,8 @@ function queryZedActiveEditor(dbPath: string, cwd: string) {
.all()
const rows = raw.flatMap((row) => {
const parsed = ZedEditorRowSchema.safeParse(row)
return parsed.success ? [parsed.data] : []
const parsed = decodeZedEditorRow(row)
return Option.isSome(parsed) ? [parsed.value] : []
})
if (raw.length > 0 && rows.length === 0) return { type: "unavailable" as const }
@@ -143,8 +146,8 @@ function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) {
.all({ $editorID: row.editor_id, $workspaceID: row.workspace_id })
const selections = raw.flatMap((selection) => {
const parsed = ZedSelectionRowSchema.safeParse(selection)
return parsed.success ? [parsed.data] : []
const parsed = decodeZedSelectionRow(selection)
return Option.isSome(parsed) ? [parsed.value] : []
})
if (raw.length > 0 && selections.length === 0) return { type: "unavailable" as const }
@@ -160,7 +163,7 @@ function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
let db: Database | undefined
try {
db = new Database(dbPath, { readonly: true })
const parsed = ZedEditorContentsSchema.safeParse(
const parsed = decodeZedEditorContents(
db
.query(
`select contents
@@ -169,8 +172,8 @@ function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
)
.get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }),
)
if (!parsed.success) return { type: "unavailable" as const }
return { type: "contents" as const, contents: parsed.data.contents }
if (Option.isNone(parsed)) return { type: "unavailable" as const }
return { type: "contents" as const, contents: parsed.value.contents }
} catch {
return { type: "unavailable" as const }
} finally {

View File

@@ -3,92 +3,102 @@ import os from "node:os"
import path from "node:path"
import { onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import z from "zod"
import { Option, Schema, SchemaGetter } from "effect"
import { isRecord } from "@/util/record"
import { createSimpleContext } from "./helper"
import { resolveZedDbPath, resolveZedSelection } from "./editor-zed"
const MCP_PROTOCOL_VERSION = "2025-11-25"
const JsonRpcMessageSchema = z.object({
id: z.union([z.number(), z.string(), z.null()]).optional(),
method: z.string().optional(),
params: z.unknown().optional(),
result: z.unknown().optional(),
error: z
.object({
code: z.number().optional(),
message: z.string().optional(),
})
.optional(),
const JsonRpcMessageSchema = Schema.Struct({
id: Schema.optional(Schema.Union([Schema.Number, Schema.String, Schema.Null])),
method: Schema.optional(Schema.String),
params: Schema.optional(Schema.Unknown),
result: Schema.optional(Schema.Unknown),
error: Schema.optional(
Schema.Struct({
code: Schema.optional(Schema.Number),
message: Schema.optional(Schema.String),
}),
),
})
const PositionSchema = z.object({
line: z.number(),
character: z.number(),
const PositionSchema = Schema.Struct({
line: Schema.Number,
character: Schema.Number,
})
const EditorSelectionRangeSchema = z.object({
text: z.string(),
selection: z.object({
const EditorSelectionRangeSchema = Schema.Struct({
text: Schema.String,
selection: Schema.Struct({
start: PositionSchema,
end: PositionSchema,
}),
})
const EditorSelectionSchema = z
.union([
z.object({
filePath: z.string(),
source: z.enum(["websocket", "zed"]).optional(),
ranges: z.array(EditorSelectionRangeSchema).min(1),
}),
z.object({
text: z.string(),
filePath: z.string(),
source: z.enum(["websocket", "zed"]).optional(),
selection: z.object({
start: PositionSchema,
end: PositionSchema,
}),
}),
])
.transform((value) =>
"ranges" in value
? value
: {
filePath: value.filePath,
source: value.source,
ranges: [
{
text: value.text,
selection: value.selection,
},
],
},
)
const EditorMentionSchema = z.object({
filePath: z.string(),
lineStart: z.number(),
lineEnd: z.number(),
const EditorSelectionRangesSchema = Schema.Struct({
filePath: Schema.String,
source: Schema.optional(Schema.Literals(["websocket", "zed"])),
ranges: Schema.mutable(Schema.Array(EditorSelectionRangeSchema).check(Schema.isMinLength(1))),
})
const EditorServerInfoSchema = z.object({
protocolVersion: z.string().optional(),
serverInfo: z
.object({
name: z.string().optional(),
version: z.string().optional(),
})
.optional(),
const EditorSelectionSchema = Schema.Union([
EditorSelectionRangesSchema,
Schema.Struct({
text: Schema.String,
filePath: Schema.String,
source: Schema.optional(Schema.Literals(["websocket", "zed"])),
selection: Schema.Struct({
start: PositionSchema,
end: PositionSchema,
}),
}),
]).pipe(
Schema.decodeTo(EditorSelectionRangesSchema, {
decode: SchemaGetter.transform((value) =>
"ranges" in value
? value
: {
filePath: value.filePath,
source: value.source,
ranges: [
{
text: value.text,
selection: value.selection,
},
],
},
),
encode: SchemaGetter.passthrough({ strict: false }),
}),
)
const EditorMentionSchema = Schema.Struct({
filePath: Schema.String,
lineStart: Schema.Number,
lineEnd: Schema.Number,
})
type JsonRpcMessage = z.infer<typeof JsonRpcMessageSchema>
export type EditorSelection = z.infer<typeof EditorSelectionSchema>
export type EditorMention = z.infer<typeof EditorMentionSchema>
const EditorServerInfoSchema = Schema.Struct({
protocolVersion: Schema.optional(Schema.String),
serverInfo: Schema.optional(
Schema.Struct({
name: Schema.optional(Schema.String),
version: Schema.optional(Schema.String),
}),
),
})
const decodeJsonRpcMessage = Schema.decodeUnknownOption(JsonRpcMessageSchema)
const decodeEditorSelection = Schema.decodeUnknownOption(EditorSelectionSchema)
const decodeEditorMention = Schema.decodeUnknownOption(EditorMentionSchema)
const decodeEditorServerInfo = Schema.decodeUnknownOption(EditorServerInfoSchema)
type JsonRpcMessage = Schema.Schema.Type<typeof JsonRpcMessageSchema>
export type EditorSelection = Schema.Schema.Type<typeof EditorSelectionSchema>
export type EditorMention = Schema.Schema.Type<typeof EditorMentionSchema>
export type EditorLabelState = "pending" | "sent" | "none"
type EditorServerInfo = z.infer<typeof EditorServerInfoSchema>
type EditorServerInfo = Schema.Schema.Type<typeof EditorServerInfoSchema>
type EditorConnection = {
url: string
@@ -214,16 +224,15 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
const message = parseMessage(event.data)
if (!message) return
const selection =
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
if (selection?.success) {
setSelection({ ...selection.data, source: "websocket" })
const selection = message.method === "selection_changed" ? decodeEditorSelection(message.params) : Option.none()
if (Option.isSome(selection)) {
setSelection({ ...selection.value, source: "websocket" })
return
}
const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
if (mention?.success) {
mentionListeners.forEach((listener) => listener(mention.data))
const mention = message.method === "at_mentioned" ? decodeEditorMention(message.params) : Option.none()
if (Option.isSome(mention)) {
mentionListeners.forEach((listener) => listener(mention.value))
return
}
@@ -235,9 +244,9 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
pending.delete(message.id)
if (message.error) return
const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined
if (initialize?.success) {
setStore("server", initialize.data)
const initialize = method === "initialize" ? decodeEditorServerInfo(message.result) : Option.none()
if (Option.isSome(initialize)) {
setStore("server", initialize.value)
send({ method: "notifications/initialized" })
return
}
@@ -447,7 +456,7 @@ function parseMessage(value: unknown) {
if (typeof value !== "string") return
try {
return JsonRpcMessageSchema.parse(JSON.parse(value))
return Option.getOrUndefined(decodeJsonRpcMessage(JSON.parse(value)))
} catch {
return
}