From fbdb76e6c26fe994a954fb7df16a163ad04260ad Mon Sep 17 00:00:00 2001 From: Stanislav Gumeniuk Date: Sat, 11 Jul 2026 23:26:06 +0300 Subject: [PATCH] Migrate JSON-RPC layer to gumeniukcom/golang-jsonrpc2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces creachadair/jrpc2 with github.com/gumeniukcom/golang-jsonrpc2/v2 (jsonrpcstdio transport + typed method registration). The Assigner switch becomes declarative RegisterTyped calls; each handler drops its HasParams/UnmarshalParams boilerplate and takes typed params directly. publishDiagnostics pushes through the transport's per-connection Pusher from the request context instead of a stored *jrpc2.Server, so the SetServer wiring disappears. Parity kept: Content-Length framing (channel.LSP equivalent), all 17 methods with identical names and param types, shutdown's null result, notification silence, clean-EOF lifecycle, server push. Dispatch is now strictly sequential and in-order (the transport default) — stronger than jrpc2's notification barrier and what LSP ordering assumes; a generous 15-minute per-request bound replaces jrpc2's absence of one (the library defaults to 30s, too tight for the first didOpen's dagger session). Behavior notes: handler error texts stay in server logs (clients receive stable generic codes; formatting's document-not-found keeps -32602 via RPCError), wire-level trace logging is replaced by handler-error logging, and handler panics are now recovered and answered instead of crashing. Tests migrated to drive the new dispatcher in-process with a test Pusher; all assertions unchanged. Verified against a live LSP session (initialize/didOpen with publishDiagnostics push/hover/shutdown). --- cmd/dang/main.go | 58 ++++---- go.mod | 5 +- go.sum | 12 +- pkg/lsp/deprecations_test.go | 8 +- pkg/lsp/diagnostics_test.go | 135 +++++++++++------- pkg/lsp/handle_initialize.go | 13 +- pkg/lsp/handle_shutdown.go | 5 +- pkg/lsp/handle_text_document_code_action.go | 13 +- pkg/lsp/handle_text_document_completion.go | 12 +- pkg/lsp/handle_text_document_definition.go | 13 +- pkg/lsp/handle_text_document_did_change.go | 13 +- pkg/lsp/handle_text_document_did_close.go | 13 +- pkg/lsp/handle_text_document_did_open.go | 13 +- pkg/lsp/handle_text_document_did_save.go | 13 +- pkg/lsp/handle_text_document_formatting.go | 16 +-- pkg/lsp/handle_text_document_hover.go | 12 +- pkg/lsp/handle_text_document_rename.go | 12 +- ...ndle_workspace_did_change_configuration.go | 5 +- ..._workspace_did_change_workspace_folders.go | 13 +- pkg/lsp/handle_workspace_symbol.go | 13 +- pkg/lsp/handle_workspace_workspace_folders.go | 5 +- pkg/lsp/handler.go | 79 ++++------ 22 files changed, 176 insertions(+), 305 deletions(-) diff --git a/cmd/dang/main.go b/cmd/dang/main.go index 7d04926e..1a307ec6 100644 --- a/cmd/dang/main.go +++ b/cmd/dang/main.go @@ -2,16 +2,18 @@ package main import ( "context" + "encoding/json" "fmt" "io" "log/slog" "os" "runtime/pprof" "strings" + "time" "github.com/charmbracelet/fang" - "github.com/creachadair/jrpc2" - "github.com/creachadair/jrpc2/channel" + jsonrpc "github.com/gumeniukcom/golang-jsonrpc2/v2" + "github.com/gumeniukcom/golang-jsonrpc2/v2/jsonrpcstdio" "github.com/spf13/cobra" "github.com/vito/dang/v2/pkg/dang" "github.com/vito/dang/v2/pkg/ioctx" @@ -213,38 +215,38 @@ func runLSP(ctx context.Context, cfg Config) error { ctx = dang.ContextWithServices(ctx, services) handler := lsp.NewHandler(ctx) - srv := jrpc2.NewServer(handler, &jrpc2.ServerOptions{ - AllowPush: true, - Logger: func(text string) { logger.Debug(text) }, + rpc := jsonrpc.New() + // jrpc2 imposed no per-request deadline; the new library defaults to + // 30s, which the first didOpen in a project (dagger session + schema + // introspection) can exceed. Keep a generous bound instead of none. + rpc.SetDefaultTimeOut(15 * time.Minute) + // Handler errors are logged here (clients receive stable generic + // codes; detail stays server-side). Full wire tracing, which jrpc2's + // Logger option provided, is intentionally not reproduced. + rpc.Use(func(method string, next jsonrpc.RPCMethod) jsonrpc.RPCMethod { + return func(ctx context.Context, data json.RawMessage) (json.RawMessage, int, error) { + res, code, err := next(ctx, data) + if err != nil { + logger.DebugContext(ctx, "jsonrpc handler error", "method", method, "code", code, "error", err) + } + return res, code, err + } }) + if err := handler.Register(rpc); err != nil { + return err + } - // Store server reference in handler for callbacks - handler.SetServer(srv) - - // Start handling requests - srv.Start(channel.LSP(stdrwc{}, stdrwc{})) + // Start handling requests over stdio with LSP Content-Length framing. + // The transport's default dispatch is strictly sequential and in-order — + // stronger than jrpc2's notification barrier (which only ordered + // notifications before later calls), and what LSP's ordering rules + // assume. + err := jsonrpcstdio.Serve(ctx, rpc, jsonrpcstdio.FramingContentLength, os.Stdin, os.Stdout) - logger.InfoContext(ctx, "LSP server closed", "error", srv.Wait()) + logger.InfoContext(ctx, "LSP server closed", "error", err) return nil } -type stdrwc struct{} - -func (stdrwc) Read(p []byte) (int, error) { - return os.Stdin.Read(p) -} - -func (stdrwc) Write(p []byte) (int, error) { - return os.Stdout.Write(p) -} - -func (stdrwc) Close() error { - if err := os.Stdin.Close(); err != nil { - return err - } - return os.Stdout.Close() -} - func fmtCmd() *cobra.Command { var ( write bool diff --git a/go.mod b/go.mod index 0292b05b..35bbf3a1 100644 --- a/go.mod +++ b/go.mod @@ -9,9 +9,9 @@ require ( github.com/Khan/genqlient v0.8.1 github.com/charmbracelet/fang v0.4.4 github.com/charmbracelet/x/ansi v0.11.6 - github.com/creachadair/jrpc2 v1.3.3 github.com/dagger/otel-go v1.43.1-0.20260515012101-af7cd0684887 github.com/dagger/testctx v0.1.2 + github.com/gumeniukcom/golang-jsonrpc2/v2 v2.6.1 github.com/iancoleman/strcase v0.3.0 github.com/kr/pretty v0.3.1 github.com/neovim/go-client v1.2.2-0.20220118223211-7c85d516f28c @@ -27,6 +27,8 @@ require ( require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect github.com/mattn/go-pointer v0.0.1 // indirect golang.org/x/sys v0.44.0 // indirect ) @@ -43,7 +45,6 @@ require ( github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect - github.com/creachadair/mds v0.25.4 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect diff --git a/go.sum b/go.sum index 0aa3b500..e159f83d 100644 --- a/go.sum +++ b/go.sum @@ -47,10 +47,6 @@ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creachadair/jrpc2 v1.3.3 h1:v+qxzRhHBInD5JFFmCyQ5l0gq60Sneg3zH+QraT+2q8= -github.com/creachadair/jrpc2 v1.3.3/go.mod h1:79Ws3bltA8gWyDLVSzKsLnGZJWirKuTCnS7nbewwWzQ= -github.com/creachadair/mds v0.25.4 h1:rUQqf9ihePG8fhppXohLSY+AdOljU4oYyWVDhWiWhyE= -github.com/creachadair/mds v0.25.4/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/dagger/otel-go v1.43.1-0.20260515012101-af7cd0684887 h1:9cKvKJkRxqeWfAT8novpc0oASXCOa338Jvb9urpRUd0= github.com/dagger/otel-go v1.43.1-0.20260515012101-af7cd0684887/go.mod h1:vv6aXBitZfuTD1oGatJcbUBP+UAdl4DepQxw0yJicgQ= @@ -60,8 +56,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= -github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= -github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -79,18 +73,24 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/gumeniukcom/golang-jsonrpc2/v2 v2.6.1 h1:LNSCTTVA285dIV5cgM/7f42HFro9pfijrPvn13jdoAI= +github.com/gumeniukcom/golang-jsonrpc2/v2 v2.6.1/go.mod h1:6HXRlmLyIbjV031PjtUKXhlSbd94Rx7yuVhlUrXPjm0= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0= github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= diff --git a/pkg/lsp/deprecations_test.go b/pkg/lsp/deprecations_test.go index c8d85738..eb4532bd 100644 --- a/pkg/lsp/deprecations_test.go +++ b/pkg/lsp/deprecations_test.go @@ -68,11 +68,11 @@ func (LSPSuite) TestDeprecationCodeActionReplacesCallee(ctx context.Context, t * // Request actions for a cursor sitting inside the toJSON token. cursor := lsp.Position{Line: 0, Character: len("let s = ") + 2} var actions []codeActionResult - require.NoError(t, h.client.CallResult(ctx, "textDocument/codeAction", lsp.CodeActionParams{ + h.call(ctx, t, "textDocument/codeAction", lsp.CodeActionParams{ TextDocument: lsp.TextDocumentIdentifier{URI: uri}, Range: lsp.Range{Start: cursor, End: cursor}, Context: lsp.CodeActionContext{Only: []lsp.CodeActionKind{lsp.QuickFix}}, - }, &actions)) + }, &actions) require.Len(t, actions, 1) action := actions[0] @@ -100,11 +100,11 @@ func (LSPSuite) TestDeprecationCodeActionSkipsUnrelatedRange(ctx context.Context pos := lsp.Position{Line: 1, Character: 0} var actions []codeActionResult - require.NoError(t, h.client.CallResult(ctx, "textDocument/codeAction", lsp.CodeActionParams{ + h.call(ctx, t, "textDocument/codeAction", lsp.CodeActionParams{ TextDocument: lsp.TextDocumentIdentifier{URI: uri}, Range: lsp.Range{Start: pos, End: pos}, Context: lsp.CodeActionContext{}, - }, &actions)) + }, &actions) require.Empty(t, actions, "no deprecated call overlaps the requested range") } diff --git a/pkg/lsp/diagnostics_test.go b/pkg/lsp/diagnostics_test.go index df341954..1dcc58c5 100644 --- a/pkg/lsp/diagnostics_test.go +++ b/pkg/lsp/diagnostics_test.go @@ -2,94 +2,125 @@ package lsp_test import ( "context" + "encoding/json" "fmt" "net/url" "os" "path/filepath" "strings" - "testing" "time" - "github.com/creachadair/jrpc2" - "github.com/creachadair/jrpc2/channel" "github.com/dagger/testctx" + jsonrpc "github.com/gumeniukcom/golang-jsonrpc2/v2" "github.com/stretchr/testify/require" "github.com/vito/dang/v2/pkg/dang" "github.com/vito/dang/v2/pkg/lsp" ) type lspHarness struct { - client *jrpc2.Client - server *jrpc2.Server + rpc *jsonrpc.JSONRPC + pusher *testPusher diagnostics chan lsp.PublishDiagnosticsParams notifyErrs chan error } +// testPusher captures server-initiated notifications the handlers push via +// jsonrpc.PusherFromContext, standing in for a bidirectional transport. +type testPusher struct { + h *lspHarness +} + +func (p *testPusher) Notify(ctx context.Context, method string, params any) error { + if method != "textDocument/publishDiagnostics" { + return nil + } + + raw, err := json.Marshal(params) + if err != nil { + p.h.notifyErrs <- fmt.Errorf("marshal %s: %w", method, err) + return nil + } + var diagnosticsParams lsp.PublishDiagnosticsParams + if err := json.Unmarshal(raw, &diagnosticsParams); err != nil { + p.h.notifyErrs <- fmt.Errorf("unmarshal %s: %w", method, err) + return nil + } + p.h.diagnostics <- diagnosticsParams + return nil +} + func newLSPHarness(ctx context.Context, t *testctx.T, root string) *lspHarness { t.Helper() - clientCh, serverCh := channel.Direct() - h := &lspHarness{ diagnostics: make(chan lsp.PublishDiagnosticsParams, 16), notifyErrs: make(chan error, 16), } + h.pusher = &testPusher{h: h} services := &dang.ServiceRegistry{} ctx = dang.ContextWithServices(ctx, services) t.Cleanup(services.StopAll) handler := lsp.NewHandler(ctx) - h.server = jrpc2.NewServer(handler, &jrpc2.ServerOptions{ - AllowPush: true, - Logger: func(text string) { - if testing.Verbose() { - t.Logf("lsp server: %s", text) - } - }, - }) - handler.SetServer(h.server) - h.server.Start(serverCh) - - h.client = jrpc2.NewClient(clientCh, &jrpc2.ClientOptions{ - Logger: func(text string) { - if testing.Verbose() { - t.Logf("lsp client: %s", text) - } - }, - OnNotify: func(req *jrpc2.Request) { - if req.Method() != "textDocument/publishDiagnostics" { - return - } - - var params lsp.PublishDiagnosticsParams - if err := req.UnmarshalParams(¶ms); err != nil { - h.notifyErrs <- fmt.Errorf("unmarshal %s: %w", req.Method(), err) - return - } - h.diagnostics <- params - }, - }) - - t.Cleanup(func() { - if err := h.client.Close(); err != nil { - t.Logf("closing LSP client: %v", err) - } - h.server.Stop() - if err := h.server.Wait(); err != nil && !channel.IsErrClosing(err) { - t.Logf("LSP server stopped: %v", err) - } - }) + h.rpc = jsonrpc.New() + require.NoError(t, handler.Register(h.rpc)) var initResult lsp.InitializeResult - require.NoError(t, h.client.CallResult(ctx, "initialize", lsp.InitializeParams{ + h.call(ctx, t, "initialize", lsp.InitializeParams{ RootURI: fileURI(t, root), - }, &initResult)) - require.NoError(t, h.client.Notify(ctx, "initialized", map[string]any{})) + }, &initResult) + h.notify(ctx, t, "initialized", map[string]any{}) return h } +// dispatch sends a single JSON-RPC message through the dispatcher with the +// harness pusher installed, mirroring what a bidirectional transport does. +func (h *lspHarness) dispatch(ctx context.Context, t *testctx.T, method string, params any, withID bool) json.RawMessage { + t.Helper() + + req := map[string]any{ + "jsonrpc": "2.0", + "method": method, + } + if params != nil { + req["params"] = params + } + if withID { + req["id"] = 1 + } + raw, err := json.Marshal(req) + require.NoError(t, err) + + return h.rpc.HandleRPCJSONRawMessage(jsonrpc.ContextWithPusher(ctx, h.pusher), raw) +} + +func (h *lspHarness) call(ctx context.Context, t *testctx.T, method string, params any, result any) { + t.Helper() + + raw := h.dispatch(ctx, t, method, params, true) + require.NotEmpty(t, raw, "expected a response for %s", method) + + var resp struct { + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` + } + require.NoError(t, json.Unmarshal(raw, &resp)) + if len(resp.Error) > 0 && string(resp.Error) != "null" { + t.Fatalf("rpc error calling %s: %s", method, resp.Error) + } + if result != nil { + require.NoError(t, json.Unmarshal(resp.Result, result)) + } +} + +func (h *lspHarness) notify(ctx context.Context, t *testctx.T, method string, params any) { + t.Helper() + + h.dispatch(ctx, t, method, params, false) +} + func (h *lspHarness) open(ctx context.Context, t *testctx.T, path string) lsp.DocumentURI { t.Helper() @@ -97,14 +128,14 @@ func (h *lspHarness) open(ctx context.Context, t *testctx.T, path string) lsp.Do require.NoError(t, err) uri := fileURI(t, path) - require.NoError(t, h.client.Notify(ctx, "textDocument/didOpen", lsp.DidOpenTextDocumentParams{ + h.notify(ctx, t, "textDocument/didOpen", lsp.DidOpenTextDocumentParams{ TextDocument: lsp.TextDocumentItem{ URI: uri, LanguageID: "dang", Version: 1, Text: string(contents), }, - })) + }) return uri } diff --git a/pkg/lsp/handle_initialize.go b/pkg/lsp/handle_initialize.go index ddbf867c..3d234727 100644 --- a/pkg/lsp/handle_initialize.go +++ b/pkg/lsp/handle_initialize.go @@ -7,20 +7,9 @@ import ( "os" "os/exec" "path/filepath" - - "github.com/creachadair/jrpc2" ) -func (h *langHandler) handleInitialize(ctx context.Context, req *jrpc2.Request) (any, error) { - if !req.HasParams() { - return nil, jrpc2.Errorf(jrpc2.InvalidParams, "missing parameters") - } - - var params InitializeParams - if err := req.UnmarshalParams(¶ms); err != nil { - return nil, err - } - +func (h *langHandler) handleInitialize(ctx context.Context, params InitializeParams) (any, error) { rootPath, err := fromURI(params.RootURI) if err != nil { return nil, err diff --git a/pkg/lsp/handle_shutdown.go b/pkg/lsp/handle_shutdown.go index 52696021..3a50f5c0 100644 --- a/pkg/lsp/handle_shutdown.go +++ b/pkg/lsp/handle_shutdown.go @@ -2,11 +2,10 @@ package lsp import ( "context" - - "github.com/creachadair/jrpc2" + "encoding/json" ) -func (h *langHandler) handleShutdown(ctx context.Context, req *jrpc2.Request) (any, error) { +func (h *langHandler) handleShutdown(ctx context.Context, params json.RawMessage) (any, error) { // Service processes are cleaned up via the ServiceRegistry. return nil, nil } diff --git a/pkg/lsp/handle_text_document_code_action.go b/pkg/lsp/handle_text_document_code_action.go index e96f446c..a732ccbc 100644 --- a/pkg/lsp/handle_text_document_code_action.go +++ b/pkg/lsp/handle_text_document_code_action.go @@ -4,20 +4,9 @@ import ( "context" "fmt" "log/slog" - - "github.com/creachadair/jrpc2" ) -func (h *langHandler) handleTextDocumentCodeAction(ctx context.Context, req *jrpc2.Request) (any, error) { - if !req.HasParams() { - return nil, jrpc2.Errorf(jrpc2.InvalidParams, "missing parameters") - } - - var params CodeActionParams - if err := req.UnmarshalParams(¶ms); err != nil { - return nil, err - } - +func (h *langHandler) handleTextDocumentCodeAction(ctx context.Context, params CodeActionParams) (any, error) { // Always return a (possibly empty) array rather than null, so clients don't // treat a no-op as an error. actions := []CodeAction{} diff --git a/pkg/lsp/handle_text_document_completion.go b/pkg/lsp/handle_text_document_completion.go index b17460f6..f2d8971d 100644 --- a/pkg/lsp/handle_text_document_completion.go +++ b/pkg/lsp/handle_text_document_completion.go @@ -3,21 +3,11 @@ package lsp import ( "context" - "github.com/creachadair/jrpc2" "github.com/vito/dang/v2/pkg/dang" "github.com/vito/dang/v2/pkg/hm" ) -func (h *langHandler) handleTextDocumentCompletion(ctx context.Context, req *jrpc2.Request) (any, error) { - if !req.HasParams() { - return nil, jrpc2.Errorf(jrpc2.InvalidParams, "missing parameters") - } - - var params CompletionParams - if err := req.UnmarshalParams(¶ms); err != nil { - return nil, err - } - +func (h *langHandler) handleTextDocumentCompletion(ctx context.Context, params CompletionParams) (any, error) { f := h.waitForFile(params.TextDocument.URI) if f == nil { return []CompletionItem{}, nil diff --git a/pkg/lsp/handle_text_document_definition.go b/pkg/lsp/handle_text_document_definition.go index 45ab691a..2b7d042f 100644 --- a/pkg/lsp/handle_text_document_definition.go +++ b/pkg/lsp/handle_text_document_definition.go @@ -3,20 +3,9 @@ package lsp import ( "context" "strings" - - "github.com/creachadair/jrpc2" ) -func (h *langHandler) handleTextDocumentDefinition(ctx context.Context, req *jrpc2.Request) (any, error) { - if !req.HasParams() { - return nil, jrpc2.Errorf(jrpc2.InvalidParams, "missing parameters") - } - - var params DocumentDefinitionParams - if err := req.UnmarshalParams(¶ms); err != nil { - return nil, err - } - +func (h *langHandler) handleTextDocumentDefinition(ctx context.Context, params DocumentDefinitionParams) (any, error) { f := h.waitForFile(params.TextDocument.URI) if f == nil { return nil, nil diff --git a/pkg/lsp/handle_text_document_did_change.go b/pkg/lsp/handle_text_document_did_change.go index 71acad6f..962aac02 100644 --- a/pkg/lsp/handle_text_document_did_change.go +++ b/pkg/lsp/handle_text_document_did_change.go @@ -3,20 +3,9 @@ package lsp import ( "context" "log/slog" - - "github.com/creachadair/jrpc2" ) -func (h *langHandler) handleTextDocumentDidChange(ctx context.Context, req *jrpc2.Request) (any, error) { - if !req.HasParams() { - return nil, jrpc2.Errorf(jrpc2.InvalidParams, "missing parameters") - } - - var params DidChangeTextDocumentParams - if err := req.UnmarshalParams(¶ms); err != nil { - return nil, err - } - +func (h *langHandler) handleTextDocumentDidChange(ctx context.Context, params DidChangeTextDocumentParams) (any, error) { if len(params.ContentChanges) == 0 { return nil, nil } diff --git a/pkg/lsp/handle_text_document_did_close.go b/pkg/lsp/handle_text_document_did_close.go index a10919a6..9515f6e2 100644 --- a/pkg/lsp/handle_text_document_did_close.go +++ b/pkg/lsp/handle_text_document_did_close.go @@ -2,19 +2,8 @@ package lsp import ( "context" - - "github.com/creachadair/jrpc2" ) -func (h *langHandler) handleTextDocumentDidClose(ctx context.Context, req *jrpc2.Request) (any, error) { - if !req.HasParams() { - return nil, jrpc2.Errorf(jrpc2.InvalidParams, "missing parameters") - } - - var params DidCloseTextDocumentParams - if err := req.UnmarshalParams(¶ms); err != nil { - return nil, err - } - +func (h *langHandler) handleTextDocumentDidClose(ctx context.Context, params DidCloseTextDocumentParams) (any, error) { return nil, h.closeFile(params.TextDocument.URI) } diff --git a/pkg/lsp/handle_text_document_did_open.go b/pkg/lsp/handle_text_document_did_open.go index 7cbc986e..0eb37054 100644 --- a/pkg/lsp/handle_text_document_did_open.go +++ b/pkg/lsp/handle_text_document_did_open.go @@ -3,20 +3,9 @@ package lsp import ( "context" "log/slog" - - "github.com/creachadair/jrpc2" ) -func (h *langHandler) handleTextDocumentDidOpen(ctx context.Context, req *jrpc2.Request) (any, error) { - if !req.HasParams() { - return nil, jrpc2.Errorf(jrpc2.InvalidParams, "missing parameters") - } - - var params DidOpenTextDocumentParams - if err := req.UnmarshalParams(¶ms); err != nil { - return nil, err - } - +func (h *langHandler) handleTextDocumentDidOpen(ctx context.Context, params DidOpenTextDocumentParams) (any, error) { if err := h.openFile(params.TextDocument.URI, params.TextDocument.LanguageID, params.TextDocument.Version); err != nil { return nil, err } diff --git a/pkg/lsp/handle_text_document_did_save.go b/pkg/lsp/handle_text_document_did_save.go index 198eb680..d5b1738f 100644 --- a/pkg/lsp/handle_text_document_did_save.go +++ b/pkg/lsp/handle_text_document_did_save.go @@ -2,19 +2,8 @@ package lsp import ( "context" - - "github.com/creachadair/jrpc2" ) -func (h *langHandler) handleTextDocumentDidSave(ctx context.Context, req *jrpc2.Request) (any, error) { - if !req.HasParams() { - return nil, jrpc2.Errorf(jrpc2.InvalidParams, "missing parameters") - } - - var params DidSaveTextDocumentParams - if err := req.UnmarshalParams(¶ms); err != nil { - return nil, err - } - +func (h *langHandler) handleTextDocumentDidSave(ctx context.Context, params DidSaveTextDocumentParams) (any, error) { return nil, h.saveFile(params.TextDocument.URI) } diff --git a/pkg/lsp/handle_text_document_formatting.go b/pkg/lsp/handle_text_document_formatting.go index 1fb071c9..f00b555b 100644 --- a/pkg/lsp/handle_text_document_formatting.go +++ b/pkg/lsp/handle_text_document_formatting.go @@ -2,25 +2,17 @@ package lsp import ( "context" + "fmt" - "github.com/creachadair/jrpc2" + jsonrpc "github.com/gumeniukcom/golang-jsonrpc2/v2" "github.com/vito/dang/v2/pkg/dang" ) -func (h *langHandler) handleTextDocumentFormatting(ctx context.Context, req *jrpc2.Request) (any, error) { - if !req.HasParams() { - return nil, jrpc2.Errorf(jrpc2.InvalidParams, "missing parameters") - } - - var params DocumentFormattingParams - if err := req.UnmarshalParams(¶ms); err != nil { - return nil, err - } - +func (h *langHandler) handleTextDocumentFormatting(ctx context.Context, params DocumentFormattingParams) (any, error) { // Wait for file to be fully processed f := h.waitForFile(params.TextDocument.URI) if f == nil { - return nil, jrpc2.Errorf(jrpc2.InvalidParams, "document not found: %v", params.TextDocument.URI) + return nil, jsonrpc.NewRPCError(jsonrpc.InvalidParamsErrorCode, fmt.Errorf("document not found: %v", params.TextDocument.URI)) } // Format the file content diff --git a/pkg/lsp/handle_text_document_hover.go b/pkg/lsp/handle_text_document_hover.go index 996a157f..0331ad2f 100644 --- a/pkg/lsp/handle_text_document_hover.go +++ b/pkg/lsp/handle_text_document_hover.go @@ -7,21 +7,11 @@ import ( "sort" "strings" - "github.com/creachadair/jrpc2" "github.com/vito/dang/v2/pkg/dang" "github.com/vito/dang/v2/pkg/hm" ) -func (h *langHandler) handleTextDocumentHover(ctx context.Context, req *jrpc2.Request) (any, error) { - if !req.HasParams() { - return nil, jrpc2.Errorf(jrpc2.InvalidParams, "missing parameters") - } - - var params HoverParams - if err := req.UnmarshalParams(¶ms); err != nil { - return nil, err - } - +func (h *langHandler) handleTextDocumentHover(ctx context.Context, params HoverParams) (any, error) { f := h.waitForFile(params.TextDocument.URI) if f == nil { return nil, nil diff --git a/pkg/lsp/handle_text_document_rename.go b/pkg/lsp/handle_text_document_rename.go index fd605e43..932d170e 100644 --- a/pkg/lsp/handle_text_document_rename.go +++ b/pkg/lsp/handle_text_document_rename.go @@ -4,20 +4,10 @@ import ( "context" "log/slog" - "github.com/creachadair/jrpc2" "github.com/vito/dang/v2/pkg/dang" ) -func (h *langHandler) handleTextDocumentRename(ctx context.Context, req *jrpc2.Request) (any, error) { - if !req.HasParams() { - return nil, jrpc2.Errorf(jrpc2.InvalidParams, "missing parameters") - } - - var params RenameParams - if err := req.UnmarshalParams(¶ms); err != nil { - return nil, err - } - +func (h *langHandler) handleTextDocumentRename(ctx context.Context, params RenameParams) (any, error) { slog.InfoContext(ctx, "rename request", "uri", params.TextDocument.URI, "position", params.Position, "newName", params.NewName) f := h.waitForFile(params.TextDocument.URI) diff --git a/pkg/lsp/handle_workspace_did_change_configuration.go b/pkg/lsp/handle_workspace_did_change_configuration.go index fdf7f859..ee061a5c 100644 --- a/pkg/lsp/handle_workspace_did_change_configuration.go +++ b/pkg/lsp/handle_workspace_did_change_configuration.go @@ -2,10 +2,9 @@ package lsp import ( "context" - - "github.com/creachadair/jrpc2" + "encoding/json" ) -func (h *langHandler) handleWorkspaceDidChangeConfiguration(ctx context.Context, req *jrpc2.Request) (any, error) { +func (h *langHandler) handleWorkspaceDidChangeConfiguration(ctx context.Context, params json.RawMessage) (any, error) { return nil, nil } diff --git a/pkg/lsp/handle_workspace_did_change_workspace_folders.go b/pkg/lsp/handle_workspace_did_change_workspace_folders.go index 2a7b1ccf..c6cd1ee0 100644 --- a/pkg/lsp/handle_workspace_did_change_workspace_folders.go +++ b/pkg/lsp/handle_workspace_did_change_workspace_folders.go @@ -2,20 +2,9 @@ package lsp import ( "context" - - "github.com/creachadair/jrpc2" ) -func (h *langHandler) handleWorkspaceDidChangeWorkspaceFolders(ctx context.Context, req *jrpc2.Request) (any, error) { - if !req.HasParams() { - return nil, jrpc2.Errorf(jrpc2.InvalidParams, "missing parameters") - } - - var params DidChangeWorkspaceFoldersParams - if err := req.UnmarshalParams(¶ms); err != nil { - return nil, err - } - +func (h *langHandler) handleWorkspaceDidChangeWorkspaceFolders(ctx context.Context, params DidChangeWorkspaceFoldersParams) (any, error) { for _, folder := range params.Event.Added { path, err := fromURI(folder.URI) if err != nil { diff --git a/pkg/lsp/handle_workspace_symbol.go b/pkg/lsp/handle_workspace_symbol.go index 57e1853b..7457b9b1 100644 --- a/pkg/lsp/handle_workspace_symbol.go +++ b/pkg/lsp/handle_workspace_symbol.go @@ -4,20 +4,9 @@ import ( "context" "log/slog" "strings" - - "github.com/creachadair/jrpc2" ) -func (h *langHandler) handleWorkspaceSymbol(ctx context.Context, req *jrpc2.Request) (any, error) { - if !req.HasParams() { - return nil, jrpc2.Errorf(jrpc2.InvalidParams, "missing parameters") - } - - var params WorkspaceSymbolParams - if err := req.UnmarshalParams(¶ms); err != nil { - return nil, err - } - +func (h *langHandler) handleWorkspaceSymbol(ctx context.Context, params WorkspaceSymbolParams) (any, error) { slog.InfoContext(ctx, "workspace symbol request", "query", params.Query) var symbols []SymbolInformation diff --git a/pkg/lsp/handle_workspace_workspace_folders.go b/pkg/lsp/handle_workspace_workspace_folders.go index 6f254690..9b0fcb55 100644 --- a/pkg/lsp/handle_workspace_workspace_folders.go +++ b/pkg/lsp/handle_workspace_workspace_folders.go @@ -2,11 +2,10 @@ package lsp import ( "context" - - "github.com/creachadair/jrpc2" + "encoding/json" ) -func (h *langHandler) handleWorkspaceWorkspaceFolders(ctx context.Context, req *jrpc2.Request) (any, error) { +func (h *langHandler) handleWorkspaceWorkspaceFolders(ctx context.Context, params json.RawMessage) (any, error) { h.mu.Lock() folderPaths := append([]string(nil), h.folders...) h.mu.Unlock() diff --git a/pkg/lsp/handler.go b/pkg/lsp/handler.go index 868dbb76..aef36819 100644 --- a/pkg/lsp/handler.go +++ b/pkg/lsp/handler.go @@ -2,6 +2,7 @@ package lsp import ( "context" + "encoding/json" "errors" "fmt" "io/fs" @@ -15,7 +16,7 @@ import ( "time" "unicode" - "github.com/creachadair/jrpc2" + jsonrpc "github.com/gumeniukcom/golang-jsonrpc2/v2" "github.com/vito/dang/v2/pkg/dang" "github.com/vito/dang/v2/pkg/hm" ) @@ -35,15 +36,9 @@ func NewHandler(rootCtx context.Context) *langHandler { return handler } -// SetServer sets the server instance for the handler. -func (h *langHandler) SetServer(srv *jrpc2.Server) { - h.server = srv -} - type langHandler struct { rootCtx context.Context files map[DocumentURI]*File - server *jrpc2.Server rootPath string folders []string @@ -734,7 +729,8 @@ func (h *langHandler) symbolKind(node dang.Node) CompletionItemKind { } func (h *langHandler) publishDiagnostics(ctx context.Context, uri DocumentURI, diagnostics []Diagnostic, version int) { - if h.server == nil { + pusher, ok := jsonrpc.PusherFromContext(ctx) + if !ok { return } @@ -742,7 +738,7 @@ func (h *langHandler) publishDiagnostics(ctx context.Context, uri DocumentURI, d diagnostics = []Diagnostic{} } - err := h.server.Notify(ctx, "textDocument/publishDiagnostics", &PublishDiagnosticsParams{ + err := pusher.Notify(ctx, "textDocument/publishDiagnostics", &PublishDiagnosticsParams{ URI: uri, Diagnostics: diagnostics, Version: version, @@ -879,46 +875,27 @@ func (h *langHandler) addFolder(folder string) { } } -// Assign implements jrpc2.Assigner -func (h *langHandler) Assign(ctx context.Context, method string) jrpc2.Handler { - switch method { - case "initialize": - return h.handleInitialize - case "initialized": - return func(ctx context.Context, req *jrpc2.Request) (any, error) { - return nil, nil - } - case "shutdown": - return h.handleShutdown - case "textDocument/didOpen": - return h.handleTextDocumentDidOpen - case "textDocument/didChange": - return h.handleTextDocumentDidChange - case "textDocument/didSave": - return h.handleTextDocumentDidSave - case "textDocument/didClose": - return h.handleTextDocumentDidClose - case "textDocument/completion": - return h.handleTextDocumentCompletion - case "textDocument/definition": - return h.handleTextDocumentDefinition - case "textDocument/hover": - return h.handleTextDocumentHover - case "textDocument/codeAction": - return h.handleTextDocumentCodeAction - case "textDocument/rename": - return h.handleTextDocumentRename - case "textDocument/formatting": - return h.handleTextDocumentFormatting - case "workspace/symbol": - return h.handleWorkspaceSymbol - case "workspace/didChangeConfiguration": - return h.handleWorkspaceDidChangeConfiguration - case "workspace/workspaceFolders": - return h.handleWorkspaceWorkspaceFolders - case "workspace/didChangeWorkspaceFolders": - return h.handleWorkspaceDidChangeWorkspaceFolders - } - - return nil +// Register wires every LSP method onto the dispatcher; typed registration +// replaces the previous jrpc2.Assigner implementation. +func (h *langHandler) Register(rpc *jsonrpc.JSONRPC) error { + return errors.Join( + jsonrpc.RegisterTyped(rpc, "initialize", h.handleInitialize), + jsonrpc.RegisterTyped(rpc, "initialized", + func(ctx context.Context, _ json.RawMessage) (any, error) { return nil, nil }), + jsonrpc.RegisterTyped(rpc, "shutdown", h.handleShutdown), + jsonrpc.RegisterTyped(rpc, "textDocument/didOpen", h.handleTextDocumentDidOpen), + jsonrpc.RegisterTyped(rpc, "textDocument/didChange", h.handleTextDocumentDidChange), + jsonrpc.RegisterTyped(rpc, "textDocument/didSave", h.handleTextDocumentDidSave), + jsonrpc.RegisterTyped(rpc, "textDocument/didClose", h.handleTextDocumentDidClose), + jsonrpc.RegisterTyped(rpc, "textDocument/completion", h.handleTextDocumentCompletion), + jsonrpc.RegisterTyped(rpc, "textDocument/definition", h.handleTextDocumentDefinition), + jsonrpc.RegisterTyped(rpc, "textDocument/hover", h.handleTextDocumentHover), + jsonrpc.RegisterTyped(rpc, "textDocument/codeAction", h.handleTextDocumentCodeAction), + jsonrpc.RegisterTyped(rpc, "textDocument/rename", h.handleTextDocumentRename), + jsonrpc.RegisterTyped(rpc, "textDocument/formatting", h.handleTextDocumentFormatting), + jsonrpc.RegisterTyped(rpc, "workspace/symbol", h.handleWorkspaceSymbol), + jsonrpc.RegisterTyped(rpc, "workspace/didChangeConfiguration", h.handleWorkspaceDidChangeConfiguration), + jsonrpc.RegisterTyped(rpc, "workspace/workspaceFolders", h.handleWorkspaceWorkspaceFolders), + jsonrpc.RegisterTyped(rpc, "workspace/didChangeWorkspaceFolders", h.handleWorkspaceDidChangeWorkspaceFolders), + ) }