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(), &params)
   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(), &params)
   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(), &params)
   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(), &params)
   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(), &params)
   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(), &params)
   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(), &params)
   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  }