this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

internal/lsp: support lazy code action edit resolution

In LSP, the client asks the server for a list of code actions based on
where the client's cursor is. A lot of clients (editors) will make this
request after almost every key-stroke. Consquently we need it to be
fast.

The reply from the server, by default, includes the edits necessary to
apply each available code action. This means that the editor can apply
an code action the user chooses without a furth round-trip to the
server, but the downside is that the server has to calculate all the
edits ahead of time.

Clients can advertise to the server that they support lazy resolution of
the edits. This allows the server to reply to the initial request for
code actions with the edits elided. Once the user has then chosen a code
action, the client will make a subsequent round-trip to the server to
get the edits for the chosen code action only. This means we can delay
calculating edits ahead of time.

Because this is an optional client capability, we have to support both
approaches.

Signed-off-by: Matthew Sackman <matthew@cue.works>
Change-Id: I8d96b5786e0bfc72b5122d714287f3342efdb42a
Reviewed-on: https://cue.gerrithub.io/c/cue-lang/cue/+/1235165
Unity-Result: CUE porcuepine <cue.porcuepine@gmail.com>
TryBot-Result: CUEcueckoo <cueckoo@cuelang.org>
Reviewed-by: Daniel Martí <mvdan@mvdan.cc>

+164 -44
+53 -29
cmd/cue/cmd/integration/workspace/codeaction_test.go
··· 91 91 } 92 92 93 93 for _, tc := range testCases { 94 - t.Run(tc.name, func(t *testing.T) { 95 - WithOptions(RootURIAsDefaultFolder()).Run(t, "-- input.cue --\n"+tc.input, func(t *testing.T, env *Env) { 96 - env.OpenFile("input.cue") 97 - env.Await(env.DoneWithOpen()) 98 - rootURI := env.Sandbox.Workdir.RootURI() 94 + fun := func(t *testing.T, env *Env) { 95 + resolveSupport, err := env.Editor.EditResolveSupport() 96 + if err != nil { 97 + t.Fatal(err) 98 + } 99 99 100 - cursor := protocol.Location{ 101 - URI: rootURI + "/input.cue", 102 - Range: protocol.Range{ 103 - Start: tc.position, 104 - }, 105 - } 100 + env.OpenFile("input.cue") 101 + env.Await(env.DoneWithOpen()) 102 + rootURI := env.Sandbox.Workdir.RootURI() 106 103 107 - actions, err := env.Editor.CodeAction(env.Ctx, cursor, nil) 108 - if err != nil { 109 - qt.Assert(t, qt.IsNil(err)) 110 - } 104 + cursor := protocol.Location{ 105 + URI: rootURI + "/input.cue", 106 + Range: protocol.Range{ 107 + Start: tc.position, 108 + }, 109 + } 111 110 112 - var action protocol.CodeAction 113 - found := slices.ContainsFunc(actions, func(a protocol.CodeAction) bool { 114 - if a.Title == "Add surrounding struct braces" { 115 - action = a 116 - return true 117 - } 118 - return false 119 - }) 120 - if !found { 121 - t.Fatal("Failed to find ConvertToStruct code action") 122 - } 111 + actions, err := env.Editor.CodeAction(env.Ctx, cursor, nil) 112 + if err != nil { 113 + qt.Assert(t, qt.IsNil(err)) 114 + } 123 115 124 - env.ApplyCodeAction(action) 125 - after := env.BufferText("input.cue") 126 - qt.Check(t, qt.Equals(after, tc.expected)) 116 + var action protocol.CodeAction 117 + found := slices.ContainsFunc(actions, func(a protocol.CodeAction) bool { 118 + if a.Title == "Add surrounding struct braces" { 119 + action = a 120 + return true 121 + } 122 + return false 127 123 }) 124 + if !found { 125 + t.Fatal("Failed to find ConvertToStruct code action") 126 + } 127 + // If we advertised to the LSP that we support lazy 128 + // resolution for codeactions, we should have been sent back 129 + // a nil-Edit property. 130 + qt.Assert(t, qt.Equals(action.Edit == nil, resolveSupport)) 131 + // Calling ApplyCodeAction will make the additional call to 132 + // resolve the Edit property if necessary. 133 + env.ApplyCodeAction(action) 134 + after := env.BufferText("input.cue") 135 + qt.Check(t, qt.Equals(after, tc.expected)) 136 + } 137 + 138 + t.Run(tc.name+"/eager", func(t *testing.T) { 139 + WithOptions(RootURIAsDefaultFolder()).Run(t, "-- input.cue --\n"+tc.input, fun) 140 + }) 141 + 142 + t.Run(tc.name+"/lazy", func(t *testing.T) { 143 + WithOptions( 144 + RootURIAsDefaultFolder(), 145 + CapabilitiesJSON([]byte(`{ 146 + "textDocument": {"codeAction": { 147 + "dataSupport": true, 148 + "resolveSupport": {"properties": ["edit"]} 149 + }} 150 + }`)), 151 + ).Run(t, "-- input.cue --\n"+tc.input, fun) 128 152 }) 129 153 } 130 154 }
+40 -2
internal/lsp/cache/codeaction.go
··· 26 26 "cuelang.org/go/internal/golangorgx/tools/diff" 27 27 ) 28 28 29 - func (w *Workspace) CodeActionConvertToStruct(ctx context.Context, params *protocol.CodeActionParams) (*protocol.WorkspaceEdit, error) { 29 + // CodeActionConvertToStruct calculates the edits needed to convert from 30 + // 31 + // a: b: c 32 + // 33 + // to 34 + // 35 + // a: { 36 + // b: c 37 + // } 38 + // 39 + // assuming the cursor is somewhere around `b`. The cursor position 40 + // and file uri are provided by params. A nil [protocol.WorkspaceEdit] 41 + // is returned if the conversion is not possible. If delayEdit is 42 + // true, an empty but non-nil [protocol.WorkspaceEdit] will be 43 + // returned as soon as the params have been successfully validated. 44 + func (w *Workspace) CodeActionConvertToStruct(ctx context.Context, params *protocol.CodeActionParams, delayEdit bool) (*protocol.WorkspaceEdit, error) { 30 45 f := w.GetFile(params.TextDocument.URI) 31 46 if f == nil || f.syntax == nil || f.mapper == nil || f.tokFile == nil { 32 47 return nil, nil ··· 52 67 lineNo := labelStart.Line() - 1 // Line is 1-based, hence the -1 53 68 if lineNo < 0 || lineNo >= len(lineStartOffsets) { 54 69 return nil, nil 70 + } 71 + 72 + if delayEdit { 73 + return &protocol.WorkspaceEdit{}, nil 55 74 } 56 75 57 76 lineEnding := extractLineEnding(content, lineStartOffsets) ··· 104 123 return &protocol.WorkspaceEdit{DocumentChanges: docChanges}, nil 105 124 } 106 125 107 - func (w *Workspace) CodeActionConvertFromStruct(ctx context.Context, params *protocol.CodeActionParams) (*protocol.WorkspaceEdit, error) { 126 + // CodeActionConvertFromStruct calculates the edits needed to convert from 127 + // 128 + // a: { 129 + // b: c 130 + // } 131 + // 132 + // to 133 + // 134 + // a: b: c 135 + // 136 + // assuming the cursor is somewhere around `b`. The cursor position 137 + // and file uri are provided by params. A nil [protocol.WorkspaceEdit] 138 + // is returned if the conversion is not possible. If delayEdit is 139 + // true, an empty but non-nil [protocol.WorkspaceEdit] will be 140 + // returned as soon as the params have been successfully validated. 141 + func (w *Workspace) CodeActionConvertFromStruct(ctx context.Context, params *protocol.CodeActionParams, delayEdit bool) (*protocol.WorkspaceEdit, error) { 108 142 f := w.GetFile(params.TextDocument.URI) 109 143 if f == nil || f.syntax == nil || f.mapper == nil || f.tokFile == nil { 110 144 return nil, nil ··· 129 163 lineNo := structLit.Pos().Line() - 1 // Line is 1-based, hence the -1 130 164 if lineNo < 0 || lineNo >= len(lineStartOffsets) { 131 165 return nil, nil 166 + } 167 + 168 + if delayEdit { 169 + return &protocol.WorkspaceEdit{}, nil 132 170 } 133 171 134 172 lineEnding := extractLineEnding(content, lineStartOffsets)
+70 -8
internal/lsp/server/codeaction.go
··· 16 16 17 17 import ( 18 18 "context" 19 + "encoding/json" 19 20 "slices" 20 21 21 22 "cuelang.org/go/internal/golangorgx/gopls/protocol" ··· 33 34 } 34 35 35 36 func (s *server) CodeAction(ctx context.Context, params *protocol.CodeActionParams) ([]protocol.CodeAction, error) { 37 + // delayEdit means the client supports a subsequent round-trip to 38 + // the server in order to resolve the `edit` property of a chosen 39 + // code action. This allows us to avoid potentially expensive 40 + // calculations of edits (diffs) before the user has chosen any 41 + // code action. 42 + delayEdit := slices.Contains(s.options.ClientOptions.CodeActionResolveOptions, "edit") 43 + var raw json.RawMessage 44 + if delayEdit { 45 + // The client will send this raw data back to us in any 46 + // subsequent call to ResolveCodeAction. For simplicity, we 47 + // reuse the params we've received. 48 + data, err := json.Marshal(params) 49 + if err != nil { 50 + return nil, err 51 + } 52 + raw = json.RawMessage(data) 53 + } 54 + 36 55 var codeActions []protocol.CodeAction 37 56 38 - convertToStructEdit, err := s.workspace.CodeActionConvertToStruct(ctx, params) 57 + convertToStructEdit, err := s.workspace.CodeActionConvertToStruct(ctx, params, delayEdit) 39 58 if err != nil { 40 59 return nil, err 41 60 } 42 61 if convertToStructEdit != nil { 43 - codeActions = append(codeActions, protocol.CodeAction{ 62 + action := protocol.CodeAction{ 44 63 Title: "Add surrounding struct braces", 45 64 Kind: protocol.RefactorRewriteConvertToStruct, 46 - Edit: convertToStructEdit, 47 - }) 65 + } 66 + if delayEdit { 67 + action.Data = &raw 68 + } else { 69 + action.Edit = convertToStructEdit 70 + 71 + } 72 + codeActions = append(codeActions, action) 48 73 } 49 74 50 - convertFromStructEdit, err := s.workspace.CodeActionConvertFromStruct(ctx, params) 75 + convertFromStructEdit, err := s.workspace.CodeActionConvertFromStruct(ctx, params, delayEdit) 51 76 if err != nil { 52 77 return nil, err 53 78 } 54 79 if convertFromStructEdit != nil { 55 - codeActions = append(codeActions, protocol.CodeAction{ 80 + action := protocol.CodeAction{ 56 81 Title: "Remove surrounding struct braces", 57 82 Kind: protocol.RefactorRewriteConvertFromStruct, 58 - Edit: convertFromStructEdit, 59 83 // Mark it preferred so that if both "Add..." and "Remove..." 60 84 // are available, then this "Remove..." action will be 61 85 // prioritised by editors. This most likely matches the 62 86 // user's needs. 63 87 IsPreferred: true, 64 - }) 88 + } 89 + if delayEdit { 90 + action.Data = &raw 91 + } else { 92 + action.Edit = convertFromStructEdit 93 + } 94 + codeActions = append(codeActions, action) 65 95 } 66 96 67 97 return codeActions, nil 68 98 } 99 + 100 + func (s *server) ResolveCodeAction(ctx context.Context, action *protocol.CodeAction) (*protocol.CodeAction, error) { 101 + if action.Data == nil { 102 + return nil, nil 103 + } 104 + var params protocol.CodeActionParams 105 + err := json.Unmarshal(*action.Data, &params) 106 + if err != nil { 107 + return nil, err 108 + } 109 + 110 + switch action.Kind { 111 + case protocol.RefactorRewriteConvertToStruct: 112 + convertToStructEdit, err := s.workspace.CodeActionConvertToStruct(ctx, &params, false) 113 + if err != nil { 114 + return nil, err 115 + } 116 + action.Edit = convertToStructEdit 117 + return action, nil 118 + 119 + case protocol.RefactorRewriteConvertFromStruct: 120 + convertFromStructEdit, err := s.workspace.CodeActionConvertFromStruct(ctx, &params, false) 121 + if err != nil { 122 + return nil, err 123 + } 124 + action.Edit = convertFromStructEdit 125 + return action, nil 126 + 127 + default: 128 + return nil, nil 129 + } 130 + }
+1 -1
internal/lsp/server/initialize.go
··· 95 95 // Using CodeActionOptions is only valid if codeActionLiteralSupport is set. 96 96 codeActionProvider = &protocol.CodeActionOptions{ 97 97 CodeActionKinds: s.getSupportedCodeActions(), 98 - ResolveProvider: false, 98 + ResolveProvider: true, 99 99 } 100 100 } 101 101
-4
internal/lsp/server/unimplemented.go
··· 138 138 return nil, notImplemented("ResolveCodeLens") 139 139 } 140 140 141 - func (s *server) ResolveCodeAction(context.Context, *protocol.CodeAction) (*protocol.CodeAction, error) { 142 - return nil, notImplemented("ResolveCodeAction") 143 - } 144 - 145 141 func (s *server) ResolveCompletionItem(context.Context, *protocol.CompletionItem) (*protocol.CompletionItem, error) { 146 142 return nil, notImplemented("ResolveCompletionItem") 147 143 }