github.com/jhump/golang-x-tools@v0.0.0-20220218190644-4958d6d39439/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  	"time"
    13  
    14  	"github.com/jhump/golang-x-tools/internal/event"
    15  	"github.com/jhump/golang-x-tools/internal/jsonrpc2"
    16  	"github.com/jhump/golang-x-tools/internal/lsp/protocol"
    17  	"github.com/jhump/golang-x-tools/internal/lsp/source"
    18  	"github.com/jhump/golang-x-tools/internal/span"
    19  	"github.com/jhump/golang-x-tools/internal/xcontext"
    20  	errors "golang.org/x/xerrors"
    21  )
    22  
    23  // ModificationSource identifies the originating cause of a file modification.
    24  type ModificationSource int
    25  
    26  const (
    27  	// FromDidOpen is a file modification caused by opening a file.
    28  	FromDidOpen = ModificationSource(iota)
    29  
    30  	// FromDidChange is a file modification caused by changing a file.
    31  	FromDidChange
    32  
    33  	// FromDidChangeWatchedFiles is a file modification caused by a change to a
    34  	// watched file.
    35  	FromDidChangeWatchedFiles
    36  
    37  	// FromDidSave is a file modification caused by a file save.
    38  	FromDidSave
    39  
    40  	// FromDidClose is a file modification caused by closing a file.
    41  	FromDidClose
    42  
    43  	// FromRegenerateCgo refers to file modifications caused by regenerating
    44  	// the cgo sources for the workspace.
    45  	FromRegenerateCgo
    46  
    47  	// FromInitialWorkspaceLoad refers to the loading of all packages in the
    48  	// workspace when the view is first created.
    49  	FromInitialWorkspaceLoad
    50  )
    51  
    52  func (m ModificationSource) String() string {
    53  	switch m {
    54  	case FromDidOpen:
    55  		return "opened files"
    56  	case FromDidChange:
    57  		return "changed files"
    58  	case FromDidChangeWatchedFiles:
    59  		return "files changed on disk"
    60  	case FromDidSave:
    61  		return "saved files"
    62  	case FromDidClose:
    63  		return "close files"
    64  	case FromRegenerateCgo:
    65  		return "regenerate cgo"
    66  	case FromInitialWorkspaceLoad:
    67  		return "initial workspace load"
    68  	default:
    69  		return "unknown file modification"
    70  	}
    71  }
    72  
    73  func (s *Server) didOpen(ctx context.Context, params *protocol.DidOpenTextDocumentParams) error {
    74  	uri := params.TextDocument.URI.SpanURI()
    75  	if !uri.IsFile() {
    76  		return nil
    77  	}
    78  	// There may not be any matching view in the current session. If that's
    79  	// the case, try creating a new view based on the opened file path.
    80  	//
    81  	// TODO(rstambler): This seems like it would continuously add new
    82  	// views, but it won't because ViewOf only returns an error when there
    83  	// are no views in the session. I don't know if that logic should go
    84  	// here, or if we can continue to rely on that implementation detail.
    85  	if _, err := s.session.ViewOf(uri); err != nil {
    86  		dir := filepath.Dir(uri.Filename())
    87  		if err := s.addFolders(ctx, []protocol.WorkspaceFolder{{
    88  			URI:  string(protocol.URIFromPath(dir)),
    89  			Name: filepath.Base(dir),
    90  		}}); err != nil {
    91  			return err
    92  		}
    93  	}
    94  	return s.didModifyFiles(ctx, []source.FileModification{{
    95  		URI:        uri,
    96  		Action:     source.Open,
    97  		Version:    params.TextDocument.Version,
    98  		Text:       []byte(params.TextDocument.Text),
    99  		LanguageID: params.TextDocument.LanguageID,
   100  	}}, FromDidOpen)
   101  }
   102  
   103  func (s *Server) didChange(ctx context.Context, params *protocol.DidChangeTextDocumentParams) error {
   104  	uri := params.TextDocument.URI.SpanURI()
   105  	if !uri.IsFile() {
   106  		return nil
   107  	}
   108  
   109  	text, err := s.changedText(ctx, uri, params.ContentChanges)
   110  	if err != nil {
   111  		return err
   112  	}
   113  	c := source.FileModification{
   114  		URI:     uri,
   115  		Action:  source.Change,
   116  		Version: params.TextDocument.Version,
   117  		Text:    text,
   118  	}
   119  	if err := s.didModifyFiles(ctx, []source.FileModification{c}, FromDidChange); err != nil {
   120  		return err
   121  	}
   122  	return s.warnAboutModifyingGeneratedFiles(ctx, uri)
   123  }
   124  
   125  // warnAboutModifyingGeneratedFiles shows a warning if a user tries to edit a
   126  // generated file for the first time.
   127  func (s *Server) warnAboutModifyingGeneratedFiles(ctx context.Context, uri span.URI) error {
   128  	s.changedFilesMu.Lock()
   129  	_, ok := s.changedFiles[uri]
   130  	if !ok {
   131  		s.changedFiles[uri] = struct{}{}
   132  	}
   133  	s.changedFilesMu.Unlock()
   134  
   135  	// This file has already been edited before.
   136  	if ok {
   137  		return nil
   138  	}
   139  
   140  	// Ideally, we should be able to specify that a generated file should
   141  	// be opened as read-only. Tell the user that they should not be
   142  	// editing a generated file.
   143  	view, err := s.session.ViewOf(uri)
   144  	if err != nil {
   145  		return err
   146  	}
   147  	snapshot, release := view.Snapshot(ctx)
   148  	isGenerated := source.IsGenerated(ctx, snapshot, uri)
   149  	release()
   150  
   151  	if !isGenerated {
   152  		return nil
   153  	}
   154  	return s.client.ShowMessage(ctx, &protocol.ShowMessageParams{
   155  		Message: fmt.Sprintf("Do not edit this file! %s is a generated file.", uri.Filename()),
   156  		Type:    protocol.Warning,
   157  	})
   158  }
   159  
   160  func (s *Server) didChangeWatchedFiles(ctx context.Context, params *protocol.DidChangeWatchedFilesParams) error {
   161  	var modifications []source.FileModification
   162  	for _, change := range params.Changes {
   163  		uri := change.URI.SpanURI()
   164  		if !uri.IsFile() {
   165  			continue
   166  		}
   167  		action := changeTypeToFileAction(change.Type)
   168  		modifications = append(modifications, source.FileModification{
   169  			URI:    uri,
   170  			Action: action,
   171  			OnDisk: true,
   172  		})
   173  	}
   174  	return s.didModifyFiles(ctx, modifications, FromDidChangeWatchedFiles)
   175  }
   176  
   177  func (s *Server) didSave(ctx context.Context, params *protocol.DidSaveTextDocumentParams) error {
   178  	uri := params.TextDocument.URI.SpanURI()
   179  	if !uri.IsFile() {
   180  		return nil
   181  	}
   182  	c := source.FileModification{
   183  		URI:    uri,
   184  		Action: source.Save,
   185  	}
   186  	if params.Text != nil {
   187  		c.Text = []byte(*params.Text)
   188  	}
   189  	return s.didModifyFiles(ctx, []source.FileModification{c}, FromDidSave)
   190  }
   191  
   192  func (s *Server) didClose(ctx context.Context, params *protocol.DidCloseTextDocumentParams) error {
   193  	uri := params.TextDocument.URI.SpanURI()
   194  	if !uri.IsFile() {
   195  		return nil
   196  	}
   197  	return s.didModifyFiles(ctx, []source.FileModification{
   198  		{
   199  			URI:     uri,
   200  			Action:  source.Close,
   201  			Version: -1,
   202  			Text:    nil,
   203  		},
   204  	}, FromDidClose)
   205  }
   206  
   207  func (s *Server) didModifyFiles(ctx context.Context, modifications []source.FileModification, cause ModificationSource) error {
   208  	diagnoseDone := make(chan struct{})
   209  	if s.session.Options().VerboseWorkDoneProgress {
   210  		work := s.progress.Start(ctx, DiagnosticWorkTitle(cause), "Calculating file diagnostics...", nil, nil)
   211  		defer func() {
   212  			go func() {
   213  				<-diagnoseDone
   214  				work.End("Done.")
   215  			}()
   216  		}()
   217  	}
   218  
   219  	onDisk := cause == FromDidChangeWatchedFiles
   220  	delay := s.session.Options().ExperimentalWatchedFileDelay
   221  	s.fileChangeMu.Lock()
   222  	defer s.fileChangeMu.Unlock()
   223  	if !onDisk || delay == 0 {
   224  		// No delay: process the modifications immediately.
   225  		return s.processModifications(ctx, modifications, onDisk, diagnoseDone)
   226  	}
   227  	// Debounce and batch up pending modifications from watched files.
   228  	pending := &pendingModificationSet{
   229  		diagnoseDone: diagnoseDone,
   230  		changes:      modifications,
   231  	}
   232  	// Invariant: changes appended to s.pendingOnDiskChanges are eventually
   233  	// handled in the order they arrive. This guarantee is only partially
   234  	// enforced here. Specifically:
   235  	//  1. s.fileChangesMu ensures that the append below happens in the order
   236  	//     notifications were received, so that the changes within each batch are
   237  	//     ordered properly.
   238  	//  2. The debounced func below holds s.fileChangesMu while processing all
   239  	//     changes in s.pendingOnDiskChanges, ensuring that no batches are
   240  	//     processed out of order.
   241  	//  3. Session.ExpandModificationsToDirectories and Session.DidModifyFiles
   242  	//     process changes in order.
   243  	s.pendingOnDiskChanges = append(s.pendingOnDiskChanges, pending)
   244  	ctx = xcontext.Detach(ctx)
   245  	okc := s.watchedFileDebouncer.debounce("", 0, time.After(delay))
   246  	go func() {
   247  		if ok := <-okc; !ok {
   248  			return
   249  		}
   250  		s.fileChangeMu.Lock()
   251  		var allChanges []source.FileModification
   252  		// For accurate progress notifications, we must notify all goroutines
   253  		// waiting for the diagnose pass following a didChangeWatchedFiles
   254  		// notification. This is necessary for regtest assertions.
   255  		var dones []chan struct{}
   256  		for _, pending := range s.pendingOnDiskChanges {
   257  			allChanges = append(allChanges, pending.changes...)
   258  			dones = append(dones, pending.diagnoseDone)
   259  		}
   260  
   261  		allDone := make(chan struct{})
   262  		if err := s.processModifications(ctx, allChanges, onDisk, allDone); err != nil {
   263  			event.Error(ctx, "processing delayed file changes", err)
   264  		}
   265  		s.pendingOnDiskChanges = nil
   266  		s.fileChangeMu.Unlock()
   267  		<-allDone
   268  		for _, done := range dones {
   269  			close(done)
   270  		}
   271  	}()
   272  	return nil
   273  }
   274  
   275  // processModifications update server state to reflect file changes, and
   276  // triggers diagnostics to run asynchronously. The diagnoseDone channel will be
   277  // closed once diagnostics complete.
   278  func (s *Server) processModifications(ctx context.Context, modifications []source.FileModification, onDisk bool, diagnoseDone chan struct{}) error {
   279  	s.stateMu.Lock()
   280  	if s.state >= serverShutDown {
   281  		// This state check does not prevent races below, and exists only to
   282  		// produce a better error message. The actual race to the cache should be
   283  		// guarded by Session.viewMu.
   284  		s.stateMu.Unlock()
   285  		close(diagnoseDone)
   286  		return errors.New("server is shut down")
   287  	}
   288  	s.stateMu.Unlock()
   289  	// If the set of changes included directories, expand those directories
   290  	// to their files.
   291  	modifications = s.session.ExpandModificationsToDirectories(ctx, modifications)
   292  
   293  	snapshots, releases, err := s.session.DidModifyFiles(ctx, modifications)
   294  	if err != nil {
   295  		close(diagnoseDone)
   296  		return err
   297  	}
   298  
   299  	go func() {
   300  		s.diagnoseSnapshots(snapshots, onDisk)
   301  		for _, release := range releases {
   302  			release()
   303  		}
   304  		close(diagnoseDone)
   305  	}()
   306  
   307  	// After any file modifications, we need to update our watched files,
   308  	// in case something changed. Compute the new set of directories to watch,
   309  	// and if it differs from the current set, send updated registrations.
   310  	return s.updateWatchedDirectories(ctx)
   311  }
   312  
   313  // DiagnosticWorkTitle returns the title of the diagnostic work resulting from a
   314  // file change originating from the given cause.
   315  func DiagnosticWorkTitle(cause ModificationSource) string {
   316  	return fmt.Sprintf("diagnosing %v", cause)
   317  }
   318  
   319  func (s *Server) changedText(ctx context.Context, uri span.URI, changes []protocol.TextDocumentContentChangeEvent) ([]byte, error) {
   320  	if len(changes) == 0 {
   321  		return nil, errors.Errorf("%w: no content changes provided", jsonrpc2.ErrInternal)
   322  	}
   323  
   324  	// Check if the client sent the full content of the file.
   325  	// We accept a full content change even if the server expected incremental changes.
   326  	if len(changes) == 1 && changes[0].Range == nil && changes[0].RangeLength == 0 {
   327  		return []byte(changes[0].Text), nil
   328  	}
   329  	return s.applyIncrementalChanges(ctx, uri, changes)
   330  }
   331  
   332  func (s *Server) applyIncrementalChanges(ctx context.Context, uri span.URI, changes []protocol.TextDocumentContentChangeEvent) ([]byte, error) {
   333  	fh, err := s.session.GetFile(ctx, uri)
   334  	if err != nil {
   335  		return nil, err
   336  	}
   337  	content, err := fh.Read()
   338  	if err != nil {
   339  		return nil, errors.Errorf("%w: file not found (%v)", jsonrpc2.ErrInternal, err)
   340  	}
   341  	for _, change := range changes {
   342  		// Make sure to update column mapper along with the content.
   343  		converter := span.NewContentConverter(uri.Filename(), content)
   344  		m := &protocol.ColumnMapper{
   345  			URI:       uri,
   346  			Converter: converter,
   347  			Content:   content,
   348  		}
   349  		if change.Range == nil {
   350  			return nil, errors.Errorf("%w: unexpected nil range for change", jsonrpc2.ErrInternal)
   351  		}
   352  		spn, err := m.RangeSpan(*change.Range)
   353  		if err != nil {
   354  			return nil, err
   355  		}
   356  		if !spn.HasOffset() {
   357  			return nil, errors.Errorf("%w: invalid range for content change", jsonrpc2.ErrInternal)
   358  		}
   359  		start, end := spn.Start().Offset(), spn.End().Offset()
   360  		if end < start {
   361  			return nil, errors.Errorf("%w: invalid range for content change", jsonrpc2.ErrInternal)
   362  		}
   363  		var buf bytes.Buffer
   364  		buf.Write(content[:start])
   365  		buf.WriteString(change.Text)
   366  		buf.Write(content[end:])
   367  		content = buf.Bytes()
   368  	}
   369  	return content, nil
   370  }
   371  
   372  func changeTypeToFileAction(ct protocol.FileChangeType) source.FileAction {
   373  	switch ct {
   374  	case protocol.Changed:
   375  		return source.Change
   376  	case protocol.Created:
   377  		return source.Create
   378  	case protocol.Deleted:
   379  		return source.Delete
   380  	}
   381  	return source.UnknownFileAction
   382  }