github.com/GoogleContainerTools/skaffold@v1.39.18/pkg/skaffold/lsp/handler.go (about) 1 /* 2 Copyright 2021 The Skaffold Authors 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 package lsp 18 19 import ( 20 "context" 21 "encoding/json" 22 "fmt" 23 "io" 24 "os" 25 "runtime/debug" 26 27 "github.com/spf13/afero" 28 "go.lsp.dev/jsonrpc2" 29 "go.lsp.dev/protocol" 30 "go.lsp.dev/uri" 31 32 "github.com/GoogleContainerTools/skaffold/pkg/skaffold/config" 33 "github.com/GoogleContainerTools/skaffold/pkg/skaffold/docker" 34 "github.com/GoogleContainerTools/skaffold/pkg/skaffold/lint" 35 "github.com/GoogleContainerTools/skaffold/pkg/skaffold/output/log" 36 "github.com/GoogleContainerTools/skaffold/pkg/skaffold/parser" 37 "github.com/GoogleContainerTools/skaffold/pkg/skaffold/runner" 38 "github.com/GoogleContainerTools/skaffold/pkg/skaffold/runner/runcontext" 39 schemautil "github.com/GoogleContainerTools/skaffold/pkg/skaffold/schema/util" 40 "github.com/GoogleContainerTools/skaffold/pkg/skaffold/schema/validation" 41 "github.com/GoogleContainerTools/skaffold/pkg/skaffold/util" 42 ) 43 44 var h Handler 45 46 // Handler is the server handler for the skaffold LSP. It implements the LSP spec and supports connection over TCP 47 type Handler struct { 48 documentManager *DocumentManager 49 lastDiagnostics map[string][]protocol.Diagnostic 50 initialized bool 51 conn jsonrpc2.Conn 52 } 53 54 // NewHandler initializes a new Handler object 55 func NewHandler(conn jsonrpc2.Conn) *Handler { 56 return &Handler{ 57 initialized: false, 58 documentManager: NewDocumentManager(afero.NewMemMapFs()), 59 conn: conn, 60 } 61 } 62 63 func sendValidationAndLintDiagnostics(ctx context.Context, opts config.SkaffoldOptions, out io.Writer, req jsonrpc2.Request, createRunner func(ctx context.Context, out io.Writer, opts config.SkaffoldOptions) (runner.Runner, []schemautil.VersionedConfig, *runcontext.RunContext, error)) error { 64 isValidConfig := true 65 diags, err := validateFiles(ctx, opts, req) 66 if err != nil { 67 return err 68 } 69 sendDiagnostics(ctx, diags) 70 71 // there was a validation error found, config is invalid 72 if len(diags) > 0 { 73 isValidConfig = false 74 } 75 76 if isValidConfig { 77 _, _, runCtx, err := createRunner(ctx, out, opts) 78 if err != nil { 79 return err 80 } 81 // TODO(aaron-prindle) files should be linted even if skaffold.yaml file is invalid (for the lint rules that is possible for) 82 // currently this only lints when the config is valid 83 diags, err = lintFiles(ctx, runCtx, opts, req) 84 if err != nil { 85 return err 86 } 87 sendDiagnostics(ctx, diags) 88 } 89 90 return nil 91 } 92 93 func GetHandler(conn jsonrpc2.Conn, out io.Writer, opts config.SkaffoldOptions, createRunner func(ctx context.Context, out io.Writer, opts config.SkaffoldOptions) (runner.Runner, []schemautil.VersionedConfig, *runcontext.RunContext, error)) jsonrpc2.Handler { 94 h = *NewHandler(conn) 95 util.Fs = afero.NewCacheOnReadFs(util.Fs, h.documentManager.memMapFs, 0) 96 97 return func(ctx context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error { 98 // Recover if a panic occurs in the handlers 99 defer func() { 100 err := recover() 101 if err != nil { 102 log.Entry(ctx).Errorf("recovered from panic at %s: %v\n", req.Method(), err) 103 log.Entry(ctx).Errorf("stacktrace from panic: \n" + string(debug.Stack())) 104 } 105 }() 106 log.Entry(ctx).Debugf("req.Method(): %q\n", req.Method()) 107 switch req.Method() { 108 case protocol.MethodInitialize: 109 var params protocol.InitializeParams 110 json.Unmarshal(req.Params(), ¶ms) 111 log.Entry(ctx).Debugf("InitializeParams: %+v\n", params) 112 log.Entry(ctx).Debugf("InitializeParams.Capabilities.TextDocument: %+v\n", params.Capabilities.TextDocument) 113 // TODO(aaron-prindle) currently this only supports workspaces of length one (or the first of the list of workspaces) 114 // This used to be a single workspace field/value before lsp spec changes and I only know how to open one workspace per 115 // session in VSCode atm so this should be ok initially 116 if len(params.WorkspaceFolders) == 0 { 117 return fmt.Errorf("expected WorkspaceFolders to have at least one value, got 0") 118 } 119 // TODO(aaron-prindle) does workspace changing send a new 'initialize' or is there workspaceChange msg? Need to make sure that is handled... 120 // and we don't keep initialize workspace always 121 err := os.Chdir(uriToFilename(uri.URI(params.WorkspaceFolders[0].URI))) 122 if err != nil { 123 return err 124 } 125 // TODO(aaron-prindle) might need some checks to verify the initialize requests supports these, 126 // right now assuming VS Code w/ supported methods - seems like an ok assumption for now 127 if err := reply(ctx, protocol.InitializeResult{ 128 Capabilities: protocol.ServerCapabilities{ 129 TextDocumentSync: protocol.TextDocumentSyncOptions{ 130 Change: protocol.TextDocumentSyncKindFull, 131 OpenClose: true, 132 Save: &protocol.SaveOptions{ 133 IncludeText: true, 134 }, 135 }, 136 }, 137 }, nil); err != nil { 138 return err 139 } 140 h.initialized = true 141 return nil 142 case protocol.MethodInitialized: 143 var params protocol.InitializedParams 144 json.Unmarshal(req.Params(), ¶ms) 145 log.Entry(ctx).Debugf("InitializedParams: %+v\n", params) 146 return sendValidationAndLintDiagnostics(ctx, opts, out, req, createRunner) 147 } 148 if !h.initialized { 149 reply(ctx, nil, jsonrpc2.Errorf(jsonrpc2.ServerNotInitialized, "not initialized yet")) 150 return nil 151 } 152 153 switch req.Method() { 154 case protocol.MethodTextDocumentDidOpen: 155 var params protocol.DidOpenTextDocumentParams 156 json.Unmarshal(req.Params(), ¶ms) 157 log.Entry(ctx).Debugf("DidOpenTextDocumentParams: %+v\n", params) 158 documentURI := uriToFilename(params.TextDocument.URI) 159 if documentURI != "" { 160 if err := h.updateDocument(ctx, documentURI, params.TextDocument.Text); err != nil { 161 return err 162 } 163 return sendValidationAndLintDiagnostics(ctx, opts, out, req, createRunner) 164 } 165 case protocol.MethodTextDocumentDidChange: 166 var params protocol.DidChangeTextDocumentParams 167 json.Unmarshal(req.Params(), ¶ms) 168 log.Entry(ctx).Debugf("DidChangeTextDocumentParams: %+v\n", params) 169 documentURI := uriToFilename(params.TextDocument.URI) 170 if documentURI != "" && len(params.ContentChanges) > 0 { 171 if err := h.updateDocument(ctx, documentURI, params.ContentChanges[0].Text); err != nil { 172 return err 173 } 174 return sendValidationAndLintDiagnostics(ctx, opts, out, req, createRunner) 175 } 176 case protocol.MethodTextDocumentDidSave: 177 var params protocol.DidSaveTextDocumentParams 178 json.Unmarshal(req.Params(), ¶ms) 179 log.Entry(ctx).Debugf("DidSaveTextDocumentParams: %+v\n", params) 180 documentURI := uriToFilename(params.TextDocument.URI) 181 if documentURI != "" { 182 if err := h.updateDocument(ctx, documentURI, params.Text); err != nil { 183 return err 184 } 185 return sendValidationAndLintDiagnostics(ctx, opts, out, req, createRunner) 186 } 187 // TODO(aaron-prindle) implement additional methods here - eg: lsp.MethodTextDocumentHover, etc. 188 default: 189 return nil 190 } 191 return nil 192 } 193 } 194 195 func (h *Handler) updateDocument(ctx context.Context, documentURI, content string) error { 196 h.documentManager.UpdateDocument(documentURI, content) 197 log.Entry(ctx).Debugf("updated document for %q with %d chars\n", documentURI, len(content)) 198 return nil 199 } 200 201 func convertErrorWithLocationsToResults(errs []validation.ErrorWithLocation) []lint.Result { 202 results := []lint.Result{} 203 for _, e := range errs { 204 results = append(results, 205 lint.Result{ 206 Rule: &lint.Rule{ 207 RuleID: lint.ValidationError, 208 Severity: protocol.DiagnosticSeverityError, 209 }, 210 // TODO(aaron-prindle) currently there is dupe line and file information in the Explanation field, need to remove this for LSP 211 Explanation: e.Error.Error(), 212 AbsFilePath: e.Location.SourceFile, 213 StartLine: e.Location.StartLine, 214 EndLine: e.Location.EndLine, 215 StartColumn: e.Location.StartColumn, 216 EndColumn: e.Location.EndColumn, 217 }) 218 } 219 return results 220 } 221 222 func sendDiagnostics(ctx context.Context, diags map[string][]protocol.Diagnostic) { 223 // copy map to not mutate input 224 tmpDiags := map[string][]protocol.Diagnostic{} 225 for k, v := range diags { 226 tmpDiags[k] = v 227 } 228 229 for k := range h.lastDiagnostics { 230 if _, ok := tmpDiags[k]; !ok { 231 tmpDiags[k] = []protocol.Diagnostic{} 232 } 233 } 234 235 if len(tmpDiags) > 0 { 236 fmt.Fprintf(os.Stderr, "publishing diagnostics (%d).\n", len(tmpDiags)) 237 for k, v := range tmpDiags { 238 fmt.Fprintf(os.Stderr, "> %s\n", k) 239 h.conn.Notify(ctx, protocol.MethodTextDocumentPublishDiagnostics, protocol.PublishDiagnosticsParams{ 240 URI: uri.File(k), 241 Diagnostics: v, 242 }) 243 } 244 } 245 h.lastDiagnostics = tmpDiags 246 } 247 248 func validateFiles(ctx context.Context, 249 opts config.SkaffoldOptions, req jsonrpc2.Request) (map[string][]protocol.Diagnostic, error) { 250 // TODO(aaron-prindle) currently lint checks only filesystem, instead need to check VFS w/ documentManager info (validation uses VFS currently NOT lint) 251 252 // TODO(aaron-prindle) if invalid yaml and parser fails, need to handle that as well as a validation error vs server erroring 253 // OR just show nothing in this case as that would make sense vs all RED text, perhaps should just error 254 cfgs, err := parser.GetConfigSet(ctx, opts) 255 if err != nil { 256 return nil, err 257 } 258 vopts := validation.GetValidationOpts(opts) 259 vopts.CheckDeploySource = true 260 errs := validation.ProcessToErrorWithLocation(cfgs, vopts) 261 results := convertErrorWithLocationsToResults(errs) 262 263 var params protocol.TextDocumentPositionParams 264 json.Unmarshal(req.Params(), ¶ms) 265 tmpDiags := make(map[string][]protocol.Diagnostic) 266 for _, result := range results { 267 diag := protocol.Diagnostic{ 268 Range: protocol.Range{ 269 Start: protocol.Position{Line: uint32(result.StartLine - 1), Character: uint32(result.StartColumn - 1)}, 270 // TODO(aaron-prindle) should implement and pass end range from lint as well (currently a hack and just flags to end of line vs end of flagged text) 271 End: protocol.Position{Line: uint32(result.StartLine), Character: 0}, 272 }, 273 Severity: result.Rule.Severity, 274 Code: result.Rule.RuleID, 275 Source: result.AbsFilePath, 276 Message: result.Explanation, 277 } 278 if _, ok := tmpDiags[result.AbsFilePath]; ok { 279 tmpDiags[result.AbsFilePath] = append(tmpDiags[result.AbsFilePath], diag) 280 continue 281 } 282 tmpDiags[result.AbsFilePath] = []protocol.Diagnostic{diag} 283 } 284 return tmpDiags, nil 285 } 286 287 func lintFiles(ctx context.Context, runCtx docker.Config, 288 opts config.SkaffoldOptions, req jsonrpc2.Request) (map[string][]protocol.Diagnostic, error) { 289 // TODO(aaron-prindle) currently lint checks only filesystem, instead need to check VFS w/ documentManager info 290 // need to make sure something like k8a-manifest.yaml comes from afero VFS and not os FS always 291 results, err := lint.GetAllLintResults(ctx, lint.Options{ 292 Filename: opts.ConfigurationFile, 293 RepoCacheDir: opts.RepoCacheDir, 294 OutFormat: lint.PlainTextOutput, 295 Modules: opts.ConfigurationFilter, 296 Profiles: opts.Profiles, 297 }, runCtx) 298 299 if err != nil { 300 return nil, err 301 } 302 303 var params protocol.TextDocumentPositionParams 304 json.Unmarshal(req.Params(), ¶ms) 305 tmpDiags := make(map[string][]protocol.Diagnostic) 306 for _, result := range results { 307 diag := protocol.Diagnostic{ 308 Range: protocol.Range{ 309 Start: protocol.Position{Line: uint32(result.StartLine - 1), Character: uint32(result.StartColumn - 1)}, 310 // TODO(aaron-prindle) should implement and pass end range from lint as well (currently a hack and just flags to end of line vs end of flagged text) 311 End: protocol.Position{Line: uint32(result.StartLine), Character: 0}, 312 }, 313 Severity: result.Rule.Severity, 314 Code: result.Rule.RuleID, 315 Source: result.AbsFilePath, 316 Message: result.Explanation, 317 } 318 if _, ok := tmpDiags[result.AbsFilePath]; ok { 319 tmpDiags[result.AbsFilePath] = append(tmpDiags[result.AbsFilePath], diag) 320 continue 321 } 322 tmpDiags[result.AbsFilePath] = []protocol.Diagnostic{diag} 323 } 324 return tmpDiags, nil 325 }