github.com/jd-ly/tools@v0.5.7/internal/lsp/text_synchronization.go (about)

     1  // Copyright 2019 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  package lsp
     6  
     7  import (
     8  	"bytes"
     9  	"context"
    10  	"fmt"
    11  	"path/filepath"
    12  	"sync"
    13  
    14  	"github.com/jd-ly/tools/internal/jsonrpc2"
    15  	"github.com/jd-ly/tools/internal/lsp/protocol"
    16  	"github.com/jd-ly/tools/internal/lsp/source"
    17  	"github.com/jd-ly/tools/internal/span"
    18  	errors "golang.org/x/xerrors"
    19  )
    20  
    21  // ModificationSource identifies the originating cause of a file modification.
    22  type ModificationSource int
    23  
    24  const (
    25  	// FromDidOpen is a file modification caused by opening a file.
    26  	FromDidOpen = ModificationSource(iota)
    27  
    28  	// FromDidChange is a file modification caused by changing a file.
    29  	FromDidChange
    30  
    31  	// FromDidChangeWatchedFiles is a file modification caused by a change to a
    32  	// watched file.
    33  	FromDidChangeWatchedFiles
    34  
    35  	// FromDidSave is a file modification caused by a file save.
    36  	FromDidSave
    37  
    38  	// FromDidClose is a file modification caused by closing a file.
    39  	FromDidClose
    40  
    41  	// FromRegenerateCgo refers to file modifications caused by regenerating
    42  	// the cgo sources for the workspace.
    43  	FromRegenerateCgo
    44  
    45  	// FromInitialWorkspaceLoad refers to the loading of all packages in the
    46  	// workspace when the view is first created.
    47  	FromInitialWorkspaceLoad
    48  )
    49  
    50  func (m ModificationSource) String() string {
    51  	switch m {
    52  	case FromDidOpen:
    53  		return "opened files"
    54  	case FromDidChange:
    55  		return "changed files"
    56  	case FromDidChangeWatchedFiles:
    57  		return "files changed on disk"
    58  	case FromDidSave:
    59  		return "saved files"
    60  	case FromDidClose:
    61  		return "close files"
    62  	case FromRegenerateCgo:
    63  		return "regenerate cgo"
    64  	case FromInitialWorkspaceLoad:
    65  		return "initial workspace load"
    66  	default:
    67  		return "unknown file modification"
    68  	}
    69  }
    70  
    71  func (s *Server) didOpen(ctx context.Context, params *protocol.DidOpenTextDocumentParams) error {
    72  	uri := params.TextDocument.URI.SpanURI()
    73  	if !uri.IsFile() {
    74  		return nil
    75  	}
    76  	// There may not be any matching view in the current session. If that's
    77  	// the case, try creating a new view based on the opened file path.
    78  	//
    79  	// TODO(rstambler): This seems like it would continuously add new
    80  	// views, but it won't because ViewOf only returns an error when there
    81  	// are no views in the session. I don't know if that logic should go
    82  	// here, or if we can continue to rely on that implementation detail.
    83  	if _, err := s.session.ViewOf(uri); err != nil {
    84  		dir := filepath.Dir(uri.Filename())
    85  		if err := s.addFolders(ctx, []protocol.WorkspaceFolder{{
    86  			URI:  string(protocol.URIFromPath(dir)),
    87  			Name: filepath.Base(dir),
    88  		}}); err != nil {
    89  			return err
    90  		}
    91  	}
    92  	return s.didModifyFiles(ctx, []source.FileModification{{
    93  		URI:        uri,
    94  		Action:     source.Open,
    95  		Version:    params.TextDocument.Version,
    96  		Text:       []byte(params.TextDocument.Text),
    97  		LanguageID: params.TextDocument.LanguageID,
    98  	}}, FromDidOpen)
    99  }
   100  
   101  func (s *Server) didChange(ctx context.Context, params *protocol.DidChangeTextDocumentParams) error {
   102  	uri := params.TextDocument.URI.SpanURI()
   103  	if !uri.IsFile() {
   104  		return nil
   105  	}
   106  
   107  	text, err := s.changedText(ctx, uri, params.ContentChanges)
   108  	if err != nil {
   109  		return err
   110  	}
   111  	c := source.FileModification{
   112  		URI:     uri,
   113  		Action:  source.Change,
   114  		Version: params.TextDocument.Version,
   115  		Text:    text,
   116  	}
   117  	if err := s.didModifyFiles(ctx, []source.FileModification{c}, FromDidChange); err != nil {
   118  		return err
   119  	}
   120  	return s.warnAboutModifyingGeneratedFiles(ctx, uri)
   121  }
   122  
   123  // warnAboutModifyingGeneratedFiles shows a warning if a user tries to edit a
   124  // generated file for the first time.
   125  func (s *Server) warnAboutModifyingGeneratedFiles(ctx context.Context, uri span.URI) error {
   126  	s.changedFilesMu.Lock()
   127  	_, ok := s.changedFiles[uri]
   128  	if !ok {
   129  		s.changedFiles[uri] = struct{}{}
   130  	}
   131  	s.changedFilesMu.Unlock()
   132  
   133  	// This file has already been edited before.
   134  	if ok {
   135  		return nil
   136  	}
   137  
   138  	// Ideally, we should be able to specify that a generated file should
   139  	// be opened as read-only. Tell the user that they should not be
   140  	// editing a generated file.
   141  	view, err := s.session.ViewOf(uri)
   142  	if err != nil {
   143  		return err
   144  	}
   145  	snapshot, release := view.Snapshot(ctx)
   146  	isGenerated := source.IsGenerated(ctx, snapshot, uri)
   147  	release()
   148  
   149  	if !isGenerated {
   150  		return nil
   151  	}
   152  	return s.client.ShowMessage(ctx, &protocol.ShowMessageParams{
   153  		Message: fmt.Sprintf("Do not edit this file! %s is a generated file.", uri.Filename()),
   154  		Type:    protocol.Warning,
   155  	})
   156  }
   157  
   158  func (s *Server) didChangeWatchedFiles(ctx context.Context, params *protocol.DidChangeWatchedFilesParams) error {
   159  	var modifications []source.FileModification
   160  	for _, change := range params.Changes {
   161  		uri := change.URI.SpanURI()
   162  		if !uri.IsFile() {
   163  			continue
   164  		}
   165  		action := changeTypeToFileAction(change.Type)
   166  		modifications = append(modifications, source.FileModification{
   167  			URI:    uri,
   168  			Action: action,
   169  			OnDisk: true,
   170  		})
   171  	}
   172  	return s.didModifyFiles(ctx, modifications, FromDidChangeWatchedFiles)
   173  }
   174  
   175  func (s *Server) didSave(ctx context.Context, params *protocol.DidSaveTextDocumentParams) error {
   176  	uri := params.TextDocument.URI.SpanURI()
   177  	if !uri.IsFile() {
   178  		return nil
   179  	}
   180  	c := source.FileModification{
   181  		URI:     uri,
   182  		Action:  source.Save,
   183  		Version: params.TextDocument.Version,
   184  	}
   185  	if params.Text != nil {
   186  		c.Text = []byte(*params.Text)
   187  	}
   188  	return s.didModifyFiles(ctx, []source.FileModification{c}, FromDidSave)
   189  }
   190  
   191  func (s *Server) didClose(ctx context.Context, params *protocol.DidCloseTextDocumentParams) error {
   192  	uri := params.TextDocument.URI.SpanURI()
   193  	if !uri.IsFile() {
   194  		return nil
   195  	}
   196  	return s.didModifyFiles(ctx, []source.FileModification{
   197  		{
   198  			URI:     uri,
   199  			Action:  source.Close,
   200  			Version: -1,
   201  			Text:    nil,
   202  		},
   203  	}, FromDidClose)
   204  }
   205  
   206  func (s *Server) didModifyFiles(ctx context.Context, modifications []source.FileModification, cause ModificationSource) error {
   207  	// diagnosticWG tracks outstanding diagnostic work as a result of this file
   208  	// modification.
   209  	var diagnosticWG sync.WaitGroup
   210  	if s.session.Options().VerboseWorkDoneProgress {
   211  		work := s.progress.start(ctx, DiagnosticWorkTitle(cause), "Calculating file diagnostics...", nil, nil)
   212  		defer func() {
   213  			go func() {
   214  				diagnosticWG.Wait()
   215  				work.end("Done.")
   216  			}()
   217  		}()
   218  	}
   219  
   220  	// If the set of changes included directories, expand those directories
   221  	// to their files.
   222  	modifications = s.session.ExpandModificationsToDirectories(ctx, modifications)
   223  
   224  	views, snapshots, releases, err := s.session.DidModifyFiles(ctx, modifications)
   225  	if err != nil {
   226  		return err
   227  	}
   228  
   229  	// Group files by best view and diagnose them.
   230  	viewURIs := map[source.View][]span.URI{}
   231  	for uri, view := range views {
   232  		viewURIs[view] = append(viewURIs[view], uri)
   233  	}
   234  	for view, uris := range viewURIs {
   235  		snapshot := snapshots[view]
   236  		if snapshot == nil {
   237  			panic(fmt.Sprintf("no snapshot assigned for files %v", uris))
   238  		}
   239  		diagnosticWG.Add(1)
   240  		go func(snapshot source.Snapshot, uris []span.URI) {
   241  			defer diagnosticWG.Done()
   242  			s.diagnoseSnapshot(snapshot, uris, cause == FromDidChangeWatchedFiles)
   243  		}(snapshot, uris)
   244  	}
   245  
   246  	go func() {
   247  		diagnosticWG.Wait()
   248  		for _, release := range releases {
   249  			release()
   250  		}
   251  	}()
   252  
   253  	// After any file modifications, we need to update our watched files,
   254  	// in case something changed. Compute the new set of directories to watch,
   255  	// and if it differs from the current set, send updated registrations.
   256  	return s.updateWatchedDirectories(ctx)
   257  }
   258  
   259  // DiagnosticWorkTitle returns the title of the diagnostic work resulting from a
   260  // file change originating from the given cause.
   261  func DiagnosticWorkTitle(cause ModificationSource) string {
   262  	return fmt.Sprintf("diagnosing %v", cause)
   263  }
   264  
   265  func (s *Server) changedText(ctx context.Context, uri span.URI, changes []protocol.TextDocumentContentChangeEvent) ([]byte, error) {
   266  	if len(changes) == 0 {
   267  		return nil, errors.Errorf("%w: no content changes provided", jsonrpc2.ErrInternal)
   268  	}
   269  
   270  	// Check if the client sent the full content of the file.
   271  	// We accept a full content change even if the server expected incremental changes.
   272  	if len(changes) == 1 && changes[0].Range == nil && changes[0].RangeLength == 0 {
   273  		return []byte(changes[0].Text), nil
   274  	}
   275  	return s.applyIncrementalChanges(ctx, uri, changes)
   276  }
   277  
   278  func (s *Server) applyIncrementalChanges(ctx context.Context, uri span.URI, changes []protocol.TextDocumentContentChangeEvent) ([]byte, error) {
   279  	fh, err := s.session.GetFile(ctx, uri)
   280  	if err != nil {
   281  		return nil, err
   282  	}
   283  	content, err := fh.Read()
   284  	if err != nil {
   285  		return nil, errors.Errorf("%w: file not found (%v)", jsonrpc2.ErrInternal, err)
   286  	}
   287  	for _, change := range changes {
   288  		// Make sure to update column mapper along with the content.
   289  		converter := span.NewContentConverter(uri.Filename(), content)
   290  		m := &protocol.ColumnMapper{
   291  			URI:       uri,
   292  			Converter: converter,
   293  			Content:   content,
   294  		}
   295  		if change.Range == nil {
   296  			return nil, errors.Errorf("%w: unexpected nil range for change", jsonrpc2.ErrInternal)
   297  		}
   298  		spn, err := m.RangeSpan(*change.Range)
   299  		if err != nil {
   300  			return nil, err
   301  		}
   302  		if !spn.HasOffset() {
   303  			return nil, errors.Errorf("%w: invalid range for content change", jsonrpc2.ErrInternal)
   304  		}
   305  		start, end := spn.Start().Offset(), spn.End().Offset()
   306  		if end < start {
   307  			return nil, errors.Errorf("%w: invalid range for content change", jsonrpc2.ErrInternal)
   308  		}
   309  		var buf bytes.Buffer
   310  		buf.Write(content[:start])
   311  		buf.WriteString(change.Text)
   312  		buf.Write(content[end:])
   313  		content = buf.Bytes()
   314  	}
   315  	return content, nil
   316  }
   317  
   318  func changeTypeToFileAction(ct protocol.FileChangeType) source.FileAction {
   319  	switch ct {
   320  	case protocol.Changed:
   321  		return source.Change
   322  	case protocol.Created:
   323  		return source.Create
   324  	case protocol.Deleted:
   325  		return source.Delete
   326  	}
   327  	return source.UnknownFileAction
   328  }