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

     1  // Copyright 2020 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 fake
     6  
     7  import (
     8  	"bytes"
     9  	"context"
    10  	"io/ioutil"
    11  	"os"
    12  	"path/filepath"
    13  	"strings"
    14  	"sync"
    15  	"time"
    16  
    17  	"github.com/jd-ly/tools/internal/lsp/protocol"
    18  	"github.com/jd-ly/tools/internal/span"
    19  	errors "golang.org/x/xerrors"
    20  )
    21  
    22  // FileEvent wraps the protocol.FileEvent so that it can be associated with a
    23  // workdir-relative path.
    24  type FileEvent struct {
    25  	Path, Content string
    26  	ProtocolEvent protocol.FileEvent
    27  }
    28  
    29  // RelativeTo is a helper for operations relative to a given directory.
    30  type RelativeTo string
    31  
    32  // AbsPath returns an absolute filesystem path for the workdir-relative path.
    33  func (r RelativeTo) AbsPath(path string) string {
    34  	fp := filepath.FromSlash(path)
    35  	if filepath.IsAbs(fp) {
    36  		return fp
    37  	}
    38  	return filepath.Join(string(r), filepath.FromSlash(path))
    39  }
    40  
    41  // RelPath returns a '/'-encoded path relative to the working directory (or an
    42  // absolute path if the file is outside of workdir)
    43  func (r RelativeTo) RelPath(fp string) string {
    44  	root := string(r)
    45  	if rel, err := filepath.Rel(root, fp); err == nil && !strings.HasPrefix(rel, "..") {
    46  		return filepath.ToSlash(rel)
    47  	}
    48  	return filepath.ToSlash(fp)
    49  }
    50  
    51  func writeTxtar(txt string, rel RelativeTo) error {
    52  	files := unpackTxt(txt)
    53  	for name, data := range files {
    54  		if err := WriteFileData(name, data, rel); err != nil {
    55  			return errors.Errorf("writing to workdir: %w", err)
    56  		}
    57  	}
    58  	return nil
    59  }
    60  
    61  // WriteFileData writes content to the relative path, replacing the special
    62  // token $SANDBOX_WORKDIR with the relative root given by rel.
    63  func WriteFileData(path string, content []byte, rel RelativeTo) error {
    64  	content = bytes.ReplaceAll(content, []byte("$SANDBOX_WORKDIR"), []byte(rel))
    65  	fp := rel.AbsPath(path)
    66  	if err := os.MkdirAll(filepath.Dir(fp), 0755); err != nil {
    67  		return errors.Errorf("creating nested directory: %w", err)
    68  	}
    69  	if err := ioutil.WriteFile(fp, []byte(content), 0644); err != nil {
    70  		return errors.Errorf("writing %q: %w", path, err)
    71  	}
    72  	return nil
    73  }
    74  
    75  // Workdir is a temporary working directory for tests. It exposes file
    76  // operations in terms of relative paths, and fakes file watching by triggering
    77  // events on file operations.
    78  type Workdir struct {
    79  	RelativeTo
    80  
    81  	watcherMu sync.Mutex
    82  	watchers  []func(context.Context, []FileEvent)
    83  
    84  	fileMu sync.Mutex
    85  	files  map[string]time.Time
    86  }
    87  
    88  // NewWorkdir writes the txtar-encoded file data in txt to dir, and returns a
    89  // Workir for operating on these files using
    90  func NewWorkdir(dir string) *Workdir {
    91  	return &Workdir{RelativeTo: RelativeTo(dir)}
    92  }
    93  
    94  func (w *Workdir) writeInitialFiles(txt string) error {
    95  	writeTxtar(txt, w.RelativeTo)
    96  	// Poll to capture the current file state.
    97  	if _, err := w.pollFiles(); err != nil {
    98  		return errors.Errorf("polling files: %w", err)
    99  	}
   100  	return nil
   101  }
   102  
   103  // RootURI returns the root URI for this working directory of this scratch
   104  // environment.
   105  func (w *Workdir) RootURI() protocol.DocumentURI {
   106  	return toURI(string(w.RelativeTo))
   107  }
   108  
   109  // AddWatcher registers the given func to be called on any file change.
   110  func (w *Workdir) AddWatcher(watcher func(context.Context, []FileEvent)) {
   111  	w.watcherMu.Lock()
   112  	w.watchers = append(w.watchers, watcher)
   113  	w.watcherMu.Unlock()
   114  }
   115  
   116  // URI returns the URI to a the workdir-relative path.
   117  func (w *Workdir) URI(path string) protocol.DocumentURI {
   118  	return toURI(w.AbsPath(path))
   119  }
   120  
   121  // URIToPath converts a uri to a workdir-relative path (or an absolute path,
   122  // if the uri is outside of the workdir).
   123  func (w *Workdir) URIToPath(uri protocol.DocumentURI) string {
   124  	fp := uri.SpanURI().Filename()
   125  	return w.RelPath(fp)
   126  }
   127  
   128  func toURI(fp string) protocol.DocumentURI {
   129  	return protocol.DocumentURI(span.URIFromPath(fp))
   130  }
   131  
   132  // ReadFile reads a text file specified by a workdir-relative path.
   133  func (w *Workdir) ReadFile(path string) (string, error) {
   134  	b, err := ioutil.ReadFile(w.AbsPath(path))
   135  	if err != nil {
   136  		return "", err
   137  	}
   138  	return string(b), nil
   139  }
   140  
   141  func (w *Workdir) RegexpRange(path, re string) (Pos, Pos, error) {
   142  	content, err := w.ReadFile(path)
   143  	if err != nil {
   144  		return Pos{}, Pos{}, err
   145  	}
   146  	return regexpRange(content, re)
   147  }
   148  
   149  // RegexpSearch searches the file corresponding to path for the first position
   150  // matching re.
   151  func (w *Workdir) RegexpSearch(path string, re string) (Pos, error) {
   152  	content, err := w.ReadFile(path)
   153  	if err != nil {
   154  		return Pos{}, err
   155  	}
   156  	start, _, err := regexpRange(content, re)
   157  	return start, err
   158  }
   159  
   160  // ChangeFilesOnDisk executes the given on-disk file changes in a batch,
   161  // simulating the action of changing branches outside of an editor.
   162  func (w *Workdir) ChangeFilesOnDisk(ctx context.Context, events []FileEvent) error {
   163  	for _, e := range events {
   164  		switch e.ProtocolEvent.Type {
   165  		case protocol.Deleted:
   166  			fp := w.AbsPath(e.Path)
   167  			if err := os.Remove(fp); err != nil {
   168  				return errors.Errorf("removing %q: %w", e.Path, err)
   169  			}
   170  		case protocol.Changed, protocol.Created:
   171  			if _, err := w.writeFile(ctx, e.Path, e.Content); err != nil {
   172  				return err
   173  			}
   174  		}
   175  	}
   176  	w.sendEvents(ctx, events)
   177  	return nil
   178  }
   179  
   180  // RemoveFile removes a workdir-relative file path.
   181  func (w *Workdir) RemoveFile(ctx context.Context, path string) error {
   182  	fp := w.AbsPath(path)
   183  	if err := os.RemoveAll(fp); err != nil {
   184  		return errors.Errorf("removing %q: %w", path, err)
   185  	}
   186  	evts := []FileEvent{{
   187  		Path: path,
   188  		ProtocolEvent: protocol.FileEvent{
   189  			URI:  w.URI(path),
   190  			Type: protocol.Deleted,
   191  		},
   192  	}}
   193  	w.sendEvents(ctx, evts)
   194  	return nil
   195  }
   196  
   197  func (w *Workdir) sendEvents(ctx context.Context, evts []FileEvent) {
   198  	if len(evts) == 0 {
   199  		return
   200  	}
   201  	w.watcherMu.Lock()
   202  	watchers := make([]func(context.Context, []FileEvent), len(w.watchers))
   203  	copy(watchers, w.watchers)
   204  	w.watcherMu.Unlock()
   205  	for _, w := range watchers {
   206  		go w(ctx, evts)
   207  	}
   208  }
   209  
   210  // WriteFiles writes the text file content to workdir-relative paths.
   211  // It batches notifications rather than sending them consecutively.
   212  func (w *Workdir) WriteFiles(ctx context.Context, files map[string]string) error {
   213  	var evts []FileEvent
   214  	for filename, content := range files {
   215  		evt, err := w.writeFile(ctx, filename, content)
   216  		if err != nil {
   217  			return err
   218  		}
   219  		evts = append(evts, evt)
   220  	}
   221  	w.sendEvents(ctx, evts)
   222  	return nil
   223  }
   224  
   225  // WriteFile writes text file content to a workdir-relative path.
   226  func (w *Workdir) WriteFile(ctx context.Context, path, content string) error {
   227  	evt, err := w.writeFile(ctx, path, content)
   228  	if err != nil {
   229  		return err
   230  	}
   231  	w.sendEvents(ctx, []FileEvent{evt})
   232  	return nil
   233  }
   234  
   235  func (w *Workdir) writeFile(ctx context.Context, path, content string) (FileEvent, error) {
   236  	fp := w.AbsPath(path)
   237  	_, err := os.Stat(fp)
   238  	if err != nil && !os.IsNotExist(err) {
   239  		return FileEvent{}, errors.Errorf("checking if %q exists: %w", path, err)
   240  	}
   241  	var changeType protocol.FileChangeType
   242  	if os.IsNotExist(err) {
   243  		changeType = protocol.Created
   244  	} else {
   245  		changeType = protocol.Changed
   246  	}
   247  	if err := WriteFileData(path, []byte(content), w.RelativeTo); err != nil {
   248  		return FileEvent{}, err
   249  	}
   250  	return FileEvent{
   251  		Path: path,
   252  		ProtocolEvent: protocol.FileEvent{
   253  			URI:  w.URI(path),
   254  			Type: changeType,
   255  		},
   256  	}, nil
   257  }
   258  
   259  // ListFiles lists files in the given directory, returning a map of relative
   260  // path to modification time.
   261  func (w *Workdir) ListFiles(dir string) (map[string]time.Time, error) {
   262  	files := make(map[string]time.Time)
   263  	absDir := w.AbsPath(dir)
   264  	if err := filepath.Walk(absDir, func(fp string, info os.FileInfo, err error) error {
   265  		if err != nil {
   266  			return err
   267  		}
   268  		if info.IsDir() {
   269  			return nil
   270  		}
   271  		path := w.RelPath(fp)
   272  		files[path] = info.ModTime()
   273  		return nil
   274  	}); err != nil {
   275  		return nil, err
   276  	}
   277  	return files, nil
   278  }
   279  
   280  // CheckForFileChanges walks the working directory and checks for any files
   281  // that have changed since the last poll.
   282  func (w *Workdir) CheckForFileChanges(ctx context.Context) error {
   283  	evts, err := w.pollFiles()
   284  	if err != nil {
   285  		return err
   286  	}
   287  	w.sendEvents(ctx, evts)
   288  	return nil
   289  }
   290  
   291  // pollFiles updates w.files and calculates FileEvents corresponding to file
   292  // state changes since the last poll. It does not call sendEvents.
   293  func (w *Workdir) pollFiles() ([]FileEvent, error) {
   294  	w.fileMu.Lock()
   295  	defer w.fileMu.Unlock()
   296  
   297  	files, err := w.ListFiles(".")
   298  	if err != nil {
   299  		return nil, err
   300  	}
   301  	var evts []FileEvent
   302  	// Check which files have been added or modified.
   303  	for path, mtime := range files {
   304  		oldmtime, ok := w.files[path]
   305  		delete(w.files, path)
   306  		var typ protocol.FileChangeType
   307  		switch {
   308  		case !ok:
   309  			typ = protocol.Created
   310  		case oldmtime != mtime:
   311  			typ = protocol.Changed
   312  		default:
   313  			continue
   314  		}
   315  		evts = append(evts, FileEvent{
   316  			Path: path,
   317  			ProtocolEvent: protocol.FileEvent{
   318  				URI:  w.URI(path),
   319  				Type: typ,
   320  			},
   321  		})
   322  	}
   323  	// Any remaining files must have been deleted.
   324  	for path := range w.files {
   325  		evts = append(evts, FileEvent{
   326  			Path: path,
   327  			ProtocolEvent: protocol.FileEvent{
   328  				URI:  w.URI(path),
   329  				Type: protocol.Deleted,
   330  			},
   331  		})
   332  	}
   333  	w.files = files
   334  	return evts, nil
   335  }