Files
mathew/frontend/app/utils/lean/lsp.ts
Aran Roig 6df7950161
Some checks failed
Build and Deploy Nuxt / build (push) Failing after 41s
Ai slop
2026-10-03 14:13:08 +02:00

117 lines
4.0 KiB
TypeScript

/* A very small JSON-RPC 2.0 client over WebSocket — the dialect the lean4web
* server forwards between the browser and a real Lean language server
* (`lake serve`): one JSON message per frame, in either direction.
*
* The wiki is a deliberate minimal client: it opens one document, tracks the
* cursor, and asks for goals. The Lean server nonetheless sends a few
* *requests* its way — registering file watchers, asking to refresh caches,
* probing for configuration. There is nothing to register or refresh here,
* so those are acknowledged with `null` (a `workspace/configuration` probe
* with `null` per item) rather than left hanging. */
export interface RpcError {
code: number
message: string
data?: unknown
}
interface RpcMessage {
jsonrpc: '2.0'
id?: number | string
method?: string
params?: unknown
result?: unknown
error?: RpcError
}
export class RpcSocket {
private ws: WebSocket
private nextId = 1
private pending = new Map<number, { resolve: (v: unknown) => void, reject: (e: Error) => void }>()
private closed = false
readonly whenOpen: Promise<void>
constructor(
url: string,
private handlers: {
onNotification?: (method: string, params: unknown) => void
onClose?: (info: { clean: boolean }) => void
} = {},
) {
this.ws = new WebSocket(url)
this.whenOpen = new Promise((resolve, reject) => {
this.ws.addEventListener('open', () => resolve(), { once: true })
this.ws.addEventListener('error', () => reject(new Error('websocket failed to open')), { once: true })
})
this.ws.addEventListener('message', (ev: MessageEvent) => {
let msg: RpcMessage
try {
msg = JSON.parse(String(ev.data))
}
catch { return }
if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return
if (msg.id !== undefined && msg.method) { this.answerServer(msg); return }
if (msg.id !== undefined) {
const waiter = this.pending.get(Number(msg.id))
if (!waiter) return
this.pending.delete(Number(msg.id))
if (msg.error) waiter.reject(new Error(`${msg.method ?? 'request'}: ${msg.error.message}`))
else waiter.resolve(msg.result)
return
}
if (msg.method) this.handlers.onNotification?.(msg.method, msg.params)
})
this.ws.addEventListener('close', (ev: CloseEvent) => {
this.closed = true
const err = new Error('lean server closed the connection')
for (const waiter of this.pending.values()) waiter.reject(err)
this.pending.clear()
this.handlers.onClose?.({ clean: ev.wasClean })
})
}
get open() {
return this.ws.readyState === WebSocket.OPEN
}
async request<T>(method: string, params?: unknown): Promise<T> {
if (this.closed || this.ws.readyState !== WebSocket.OPEN) throw new Error('not connected to the lean server')
const id = this.nextId++
const wire: RpcMessage = { jsonrpc: '2.0', id, method }
if (params !== undefined) wire.params = params
this.ws.send(JSON.stringify(wire))
return new Promise<T>((resolve, reject) => {
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject })
})
}
notify(method: string, params?: unknown) {
if (this.closed || this.ws.readyState !== WebSocket.OPEN) return
const wire: RpcMessage = { jsonrpc: '2.0', method }
if (params !== undefined) wire.params = params
this.ws.send(JSON.stringify(wire))
}
close() {
this.closed = true
try { this.ws.close() }
catch { /* already gone */ }
}
/* capability registrations, cache refreshes, configuration probes: the
* honest answer from a single-document client is "nothing to do" */
private answerServer(msg: RpcMessage) {
let result: unknown = null
if (msg.method === 'workspace/configuration') {
const items = (msg.params as { items?: unknown[] } | undefined)?.items
result = Array.isArray(items) ? items.map(() => null) : null
}
if (this.closed || this.ws.readyState !== WebSocket.OPEN) return
this.ws.send(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result }))
}
}