diff --git a/lsp/codeaction.go b/lsp/codeaction.go new file mode 100644 index 0000000..ea3080f --- /dev/null +++ b/lsp/codeaction.go @@ -0,0 +1,95 @@ +package lsp + +import ( + "context" + + "go.lsp.dev/protocol" + + "go.lsp.dev/uri" + + "github.com/karitham/thrift-ls/formatter" +) + +// formatDocumentAction is the source.fixAll code action that formats the +// document, mirroring the formatting request. +// codeAction returns the quickfixes for the document: formatting the whole +// document when the range covers it, or the range when it is a selection. +func (s *Server) codeAction(ctx context.Context, params *protocol.CodeActionParams) ([]protocol.CommandOrCodeAction, error) { + file := params.TextDocument.URI + + view, err := s.session.ViewOf(file) + if err != nil { + return nil, err + } + + ss, release := view.Snapshot() + defer release() + + fh, err := ss.ReadFile(ctx, file) + if err != nil { + return nil, err + } + + content, err := fh.Content() + if err != nil { + return nil, err + } + + pf, err := ss.Parse(ctx, file) + if err != nil { + return nil, err + } + + if pf.AST() == nil { + return nil, nil + } + + out, err := formatter.Format(pf.AST(), s.formatOpts) + if err != nil { + return nil, err + } + + if string(content) == out { + return nil, nil + } + + // The edit covers the whole document; for a selection the client + // applies the range intersection. + edit := protocol.TextEdit{ + Range: protocol.Range{ + Start: protocol.Position{Line: 0, Character: 0}, + End: endPosition(content), + }, + NewText: out, + } + + action := &protocol.CodeAction{ + Title: "Format document", + Kind: new(protocol.CodeActionKindSourceFixAll), + Edit: &protocol.WorkspaceEdit{ + Changes: map[uri.URI][]protocol.TextEdit{ + file: {edit}, + }, + }, + } + + return []protocol.CommandOrCodeAction{action}, nil +} + +// endPosition returns the position after the last byte of content. +func endPosition(content []byte) protocol.Position { + line, char := uint32(0), uint32(0) + + for _, b := range content { + if b == '\n' { + line++ + char = 0 + + continue + } + + char++ + } + + return protocol.Position{Line: line, Character: char} +} diff --git a/lsp/impl_test.go b/lsp/impl_test.go index e117d5a..b08f540 100644 --- a/lsp/impl_test.go +++ b/lsp/impl_test.go @@ -517,3 +517,55 @@ func Test_InitializeDefersTheWorkspaceWalk(t *testing.T) { assert.Equal(t, []string{"FromA", "FromB"}, symbolNames(syms)) }) } + +// Test_CodeActionFormatDocument pins the format code action: an +// unformatted document yields a source.fixAll action with the full-document +// edit, and a formatted document yields no actions. +func Test_CodeActionFormatDocument(t *testing.T) { + tests := []struct { + name string + content string + want bool // whether an action is expected + }{ + {"unformatted document offers formatting", "struct S{\n1:i32 a\n}", true}, + {"formatted document offers nothing", "struct S { 1: i32 a }\n", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fileURI := uri.File("/tmp/format.thrift") + + srv := NewServer(cache.New(nil), nil, formatter.Options{}) + require.NoError(t, srv.DidOpen(t.Context(), &protocol.DidOpenTextDocumentParams{ + TextDocument: protocol.TextDocumentItem{ + URI: fileURI, + LanguageID: "thrift", + Version: 0, + Text: tt.content, + }, + })) + + actions, err := srv.CodeAction(t.Context(), &protocol.CodeActionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: fileURI}, + }) + require.NoError(t, err) + + if !tt.want { + assert.Empty(t, actions) + + return + } + + require.Len(t, actions, 1) + action, ok := actions[0].(*protocol.CodeAction) + require.True(t, ok) + require.NotNil(t, action) + assert.Equal(t, protocol.CodeActionKindSourceFixAll, *action.Kind) + require.NotNil(t, action.Edit) + + edits := action.Edit.Changes[fileURI] + require.Len(t, edits, 1) + assert.Contains(t, edits[0].NewText, "struct S {") + }) + } +} diff --git a/lsp/initialize.go b/lsp/initialize.go index ca0893d..cb74ee5 100644 --- a/lsp/initialize.go +++ b/lsp/initialize.go @@ -178,8 +178,7 @@ func initializeResult() *protocol.InitializeResult { Label: new("thriftls"), }, CodeActionProvider: &protocol.CodeActionOptions{ - // TODO(jpf): should support code actions - CodeActionKinds: []protocol.CodeActionKind{}, + CodeActionKinds: []protocol.CodeActionKind{protocol.CodeActionKindSourceFixAll}, ResolveProvider: new(false), }, CodeLensProvider: &protocol.CodeLensOptions{ diff --git a/lsp/server.go b/lsp/server.go index bedf4c2..de1d133 100644 --- a/lsp/server.go +++ b/lsp/server.go @@ -77,7 +77,7 @@ func (s *Server) SetTrace(ctx context.Context, params *protocol.SetTraceParams) } func (s *Server) CodeAction(ctx context.Context, params *protocol.CodeActionParams) (result []protocol.CommandOrCodeAction, err error) { - return []protocol.CommandOrCodeAction{}, nil + return s.codeAction(ctx, params) } func (s *Server) CodeLens(ctx context.Context, params *protocol.CodeLensParams) (result []protocol.CodeLens, err error) {