Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 30 additions & 28 deletions cmd/dang/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
)
Expand All @@ -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
Expand Down
12 changes: 6 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand All @@ -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=
Expand Down
8 changes: 4 additions & 4 deletions pkg/lsp/deprecations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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")
}
135 changes: 83 additions & 52 deletions pkg/lsp/diagnostics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,109 +2,140 @@ 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(&params); 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()

contents, err := os.ReadFile(path)
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
}

Expand Down
Loading