From 1e461c188041cecf07f6e39ac25503a1ac1b0513 Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Thu, 4 Jul 2024 14:17:21 +0100 Subject: [PATCH] feat: provide support for completion *TextEdit | *InsertReplaceEdit --- connectionlogger.go | 65 +++++++++++ context.go | 9 ++ language.go | 51 ++++++++- language_test.go | 269 +++++++++++++++++++++++++++++++++++++++----- 4 files changed, 362 insertions(+), 32 deletions(-) create mode 100644 connectionlogger.go diff --git a/connectionlogger.go b/connectionlogger.go new file mode 100644 index 00000000..31757925 --- /dev/null +++ b/connectionlogger.go @@ -0,0 +1,65 @@ +package protocol + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "go.lsp.dev/jsonrpc2" +) + +func NewConnectionLogger(w io.Writer, next jsonrpc2.Conn) *ConnectionLogger { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return &ConnectionLogger{ + w: w, + enc: enc, + next: next, + } +} + +type ConnectionLogger struct { + w io.Writer + enc *json.Encoder + next jsonrpc2.Conn +} + +func (cl *ConnectionLogger) Call(ctx context.Context, method string, params, result interface{}) (jsonrpc2.ID, error) { + io.WriteString(cl.w, fmt.Sprintf("-> %s\n", method)) + cl.enc.Encode(params) + var res json.RawMessage + id, err := cl.next.Call(ctx, method, params, &res) + if err != nil { + io.WriteString(cl.w, fmt.Sprintf("<- %s %v\n", method, err)) + return id, err + } + if res != nil { + io.WriteString(cl.w, fmt.Sprintf("<- %s\n", method)) + cl.enc.Encode(res) + } + err = json.Unmarshal(res, &result) + return id, err +} + +func (cl *ConnectionLogger) Notify(ctx context.Context, method string, params interface{}) error { + io.WriteString(cl.w, fmt.Sprintf("-> %s\n", method)) + cl.enc.Encode(params) + return cl.next.Notify(ctx, method, params) +} + +func (cl *ConnectionLogger) Go(ctx context.Context, handler jsonrpc2.Handler) { + cl.next.Go(ctx, handler) +} + +func (cl *ConnectionLogger) Close() error { + return cl.next.Close() +} + +func (cl *ConnectionLogger) Done() <-chan struct{} { + return cl.next.Done() +} + +func (cl *ConnectionLogger) Err() error { + return cl.next.Err() +} diff --git a/context.go b/context.go index d12bcd80..733c90dd 100644 --- a/context.go +++ b/context.go @@ -33,3 +33,12 @@ func LoggerFromContext(ctx context.Context) *zap.Logger { func WithClient(ctx context.Context, client Client) context.Context { return context.WithValue(ctx, ctxClient, client) } + +// ClientFromContext extracts Client from context. +func ClientFromContext(ctx context.Context) Client { + client, ok := ctx.Value(ctxClient).(Client) + if !ok { + return nil + } + return client +} diff --git a/language.go b/language.go index 221d72aa..075fee50 100644 --- a/language.go +++ b/language.go @@ -5,6 +5,8 @@ package protocol import ( "strconv" + + "github.com/segmentio/encoding/json" ) // CompletionParams params of Completion request. @@ -257,7 +259,52 @@ type CompletionItem struct { // contained and starting at the same position. // // @since 3.16.0 additional type "InsertReplaceEdit". - TextEdit *TextEdit `json:"textEdit,omitempty"` // *TextEdit | *InsertReplaceEdit + TextEdit *TextEditOrInsertReplaceEdit `json:"textEdit,omitempty"` // *TextEdit | *InsertReplaceEdit +} + +type TextEditOrInsertReplaceEdit struct { + TextEdit *TextEdit + InsertReplaceEdit *InsertReplaceEdit +} + +func (t *TextEditOrInsertReplaceEdit) MarshalJSON() ([]byte, error) { + if t.TextEdit != nil { + return json.Marshal(t.TextEdit) + } + return json.Marshal(t.InsertReplaceEdit) +} + +type textEditAndInsertReplaceEdit struct { + // NewText is in both types. + NewText string `json:"newText"` + + // Range is only present in TextEdit. + Range *Range `json:"range"` + + // Insert is only present in InsertReplaceEdit. + Insert Range `json:"insert"` + // Replace is only present in InsertReplaceEdit. + Replace Range `json:"replace"` +} + +func (t *TextEditOrInsertReplaceEdit) UnmarshalJSON(data []byte) error { + var teaire textEditAndInsertReplaceEdit + if err := json.Unmarshal(data, &teaire); err != nil { + return err + } + if teaire.Range != nil { + t.TextEdit = &TextEdit{ + NewText: teaire.NewText, + Range: *teaire.Range, + } + return nil + } + t.InsertReplaceEdit = &InsertReplaceEdit{ + NewText: teaire.NewText, + Insert: teaire.Insert, + Replace: teaire.Replace, + } + return nil } // CompletionItemKind is the completion item kind values the client supports. When this @@ -324,6 +371,7 @@ const ( ) // String implements fmt.Stringer. +// //nolint:cyclop func (k CompletionItemKind) String() string { switch k { @@ -730,6 +778,7 @@ const ( ) // String implements fmt.Stringer. +// //nolint:cyclop func (k SymbolKind) String() string { switch k { diff --git a/language_test.go b/language_test.go index 78f82f4c..37ef8268 100644 --- a/language_test.go +++ b/language_test.go @@ -4,6 +4,7 @@ package protocol import ( + "bytes" "fmt" "strings" "testing" @@ -302,18 +303,20 @@ func TestCompletionList(t *testing.T) { Label: "Detail", Preselect: true, SortText: "00000", - TextEdit: &TextEdit{ - Range: Range{ - Start: Position{ - Line: 255, - Character: 4, - }, - End: Position{ - Line: 255, - Character: 10, + TextEdit: &TextEditOrInsertReplaceEdit{ + TextEdit: &TextEdit{ + Range: Range{ + Start: Position{ + Line: 255, + Character: 4, + }, + End: Position{ + Line: 255, + Character: 10, + }, }, + NewText: "Detail: ${1:},", }, - NewText: "Detail: ${1:},", }, }, }, @@ -573,11 +576,132 @@ func TestCompletionItem(t *testing.T) { t.Parallel() const ( - want = `{"additionalTextEdits":[{"range":{"start":{"line":255,"character":4},"end":{"line":255,"character":10}},"newText":"Detail: ${1:},"}],"command":{"title":"exec echo","command":"echo","arguments":["hello"]},"commitCharacters":["a"],"tags":[1],"data":"testData","deprecated":true,"detail":"string","documentation":"Detail a human-readable string with additional information about this item, like type or symbol information.","filterText":"Detail","insertText":"testInsert","insertTextFormat":2,"insertTextMode":1,"kind":5,"label":"Detail","preselect":true,"sortText":"00000","textEdit":{"range":{"start":{"line":255,"character":4},"end":{"line":255,"character":10}},"newText":"Detail: ${1:},"}}` - wantNilAll = `{"label":"Detail"}` + wantTextEdit = `{ + "additionalTextEdits": [ + { + "range": { + "start": { + "line": 255, + "character": 4 + }, + "end": { + "line": 255, + "character": 10 + } + }, + "newText": "Detail: ${1:}," + } + ], + "command": { + "title": "exec echo", + "command": "echo", + "arguments": [ + "hello" + ] + }, + "commitCharacters": [ + "a" + ], + "tags": [ + 1 + ], + "data": "testData", + "deprecated": true, + "detail": "string", + "documentation": "Detail a human-readable string with additional information about this item, like type or symbol information.", + "filterText": "Detail", + "insertText": "testInsert", + "insertTextFormat": 2, + "insertTextMode": 1, + "kind": 5, + "label": "Detail", + "preselect": true, + "sortText": "00000", + "textEdit": { + "range": { + "start": { + "line": 255, + "character": 4 + }, + "end": { + "line": 255, + "character": 10 + } + }, + "newText": "Detail: ${1:}," + } +}` + wantInsertReplaceEdit = `{ + "additionalTextEdits": [ + { + "range": { + "start": { + "line": 255, + "character": 4 + }, + "end": { + "line": 255, + "character": 10 + } + }, + "newText": "Detail: ${1:}," + } + ], + "command": { + "title": "exec echo", + "command": "echo", + "arguments": [ + "hello" + ] + }, + "commitCharacters": [ + "a" + ], + "tags": [ + 1 + ], + "data": "testData", + "deprecated": true, + "detail": "string", + "documentation": "Detail a human-readable string with additional information about this item, like type or symbol information.", + "filterText": "Detail", + "insertText": "testInsert", + "insertTextFormat": 2, + "insertTextMode": 1, + "kind": 5, + "label": "Detail", + "preselect": true, + "sortText": "00000", + "textEdit": { + "newText": "Detail: ${1:},", + "insert": { + "start": { + "line": 105, + "character": 65 + }, + "end": { + "line": 105, + "character": 72 + } + }, + "replace": { + "start": { + "line": 105, + "character": 65 + }, + "end": { + "line": 105, + "character": 76 + } + } + } +}` + wantNilAll = `{ + "label": "Detail" +}` wantInvalid = `{"items":[]}` ) - wantType := CompletionItem{ + wantTypeTextEdit := CompletionItem{ AdditionalTextEdits: []TextEdit{ { Range: Range{ @@ -614,18 +738,83 @@ func TestCompletionItem(t *testing.T) { Label: "Detail", Preselect: true, SortText: "00000", - TextEdit: &TextEdit{ - Range: Range{ - Start: Position{ - Line: 255, - Character: 4, + TextEdit: &TextEditOrInsertReplaceEdit{ + TextEdit: &TextEdit{ + Range: Range{ + Start: Position{ + Line: 255, + Character: 4, + }, + End: Position{ + Line: 255, + Character: 10, + }, }, - End: Position{ - Line: 255, - Character: 10, + NewText: "Detail: ${1:},", + }, + }, + } + wantTypeInsertReplaceEdit := CompletionItem{ + AdditionalTextEdits: []TextEdit{ + { + Range: Range{ + Start: Position{ + Line: 255, + Character: 4, + }, + End: Position{ + Line: 255, + Character: 10, + }, + }, + NewText: "Detail: ${1:},", + }, + }, + Command: &Command{ + Title: "exec echo", + Command: "echo", + Arguments: []interface{}{"hello"}, + }, + CommitCharacters: []string{"a"}, + Tags: []CompletionItemTag{ + CompletionItemTagDeprecated, + }, + Data: "testData", + Deprecated: true, + Detail: "string", + Documentation: "Detail a human-readable string with additional information about this item, like type or symbol information.", + FilterText: "Detail", + InsertText: "testInsert", + InsertTextFormat: InsertTextFormatSnippet, + InsertTextMode: InsertTextModeAsIs, + Kind: CompletionItemKindField, + Label: "Detail", + Preselect: true, + SortText: "00000", + TextEdit: &TextEditOrInsertReplaceEdit{ + InsertReplaceEdit: &InsertReplaceEdit{ + NewText: "Detail: ${1:},", + Insert: Range{ + Start: Position{ + Line: 105, + Character: 65, + }, + End: Position{ + Line: 105, + Character: 72, + }, + }, + Replace: Range{ + Start: Position{ + Line: 105, + Character: 65, + }, + End: Position{ + Line: 105, + Character: 76, + }, }, }, - NewText: "Detail: ${1:},", }, } wantTypeNilAll := CompletionItem{ @@ -641,9 +830,16 @@ func TestCompletionItem(t *testing.T) { wantErr bool }{ { - name: "Valid", - field: wantType, - want: want, + name: "ValidTextEdit", + field: wantTypeTextEdit, + want: wantTextEdit, + wantMarshalErr: false, + wantErr: false, + }, + { + name: "ValidInsertReplaceEdit", + field: wantTypeInsertReplaceEdit, + want: wantInsertReplaceEdit, wantMarshalErr: false, wantErr: false, }, @@ -656,7 +852,7 @@ func TestCompletionItem(t *testing.T) { }, { name: "Invalid", - field: wantType, + field: wantTypeTextEdit, want: wantInvalid, wantMarshalErr: false, wantErr: true, @@ -667,10 +863,14 @@ func TestCompletionItem(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got, err := json.Marshal(&tt.field) + b := new(bytes.Buffer) + enc := json.NewEncoder(b) + enc.SetIndent("", " ") + err := enc.Encode(&tt.field) if (err != nil) != tt.wantMarshalErr { t.Fatal(err) } + got := strings.TrimSpace(b.String()) if diff := cmp.Diff(tt.want, string(got)); (diff != "") != tt.wantErr { t.Errorf("%s: wantErr: %t\n(-want +got)\n%s", tt.name, tt.wantErr, diff) @@ -688,9 +888,16 @@ func TestCompletionItem(t *testing.T) { wantErr bool }{ { - name: "Valid", - field: want, - want: wantType, + name: "ValidTextEdit", + field: wantTextEdit, + want: wantTypeTextEdit, + wantUnmarshalErr: false, + wantErr: false, + }, + { + name: "ValidInsertReplaceEdit", + field: wantInsertReplaceEdit, + want: wantTypeInsertReplaceEdit, wantUnmarshalErr: false, wantErr: false, }, @@ -704,7 +911,7 @@ func TestCompletionItem(t *testing.T) { { name: "Invalid", field: wantInvalid, - want: wantType, + want: wantTypeTextEdit, wantUnmarshalErr: false, wantErr: true, },