gotest.tools/gotestsum@v1.11.0/internal/filewatcher/watch.go (about)

     1  //go:build !aix
     2  // +build !aix
     3  
     4  package filewatcher
     5  
     6  import (
     7  	"context"
     8  	"fmt"
     9  	"io"
    10  	"os"
    11  	"path/filepath"
    12  	"strings"
    13  	"time"
    14  
    15  	"github.com/fsnotify/fsnotify"
    16  	"gotest.tools/gotestsum/internal/log"
    17  )
    18  
    19  const maxDepth = 7
    20  
    21  type Event struct {
    22  	// PkgPath of the package that triggered the event.
    23  	PkgPath string
    24  	// Args will be appended to the command line args for 'go test'.
    25  	Args []string
    26  	// Debug runs the tests with delve.
    27  	Debug bool
    28  	// resume the Watch goroutine when this channel is closed. Used to block
    29  	// the Watch goroutine while tests are running.
    30  	resume chan struct{}
    31  	// reloadPaths will cause the watched path list to be reloaded, to watch
    32  	// new directories.
    33  	reloadPaths bool
    34  	// useLastPath when true will use the PkgPath from the previous run.
    35  	useLastPath bool
    36  }
    37  
    38  // Watch dirs for filesystem events, and run tests when .go files are saved.
    39  // nolint: gocyclo
    40  func Watch(ctx context.Context, dirs []string, run func(Event) error) error {
    41  	watcher, err := fsnotify.NewWatcher()
    42  	if err != nil {
    43  		return fmt.Errorf("failed to create file watcher: %w", err)
    44  	}
    45  	defer watcher.Close() // nolint: errcheck // always returns nil error
    46  
    47  	if err := loadPaths(watcher, dirs); err != nil {
    48  		return err
    49  	}
    50  
    51  	timer := time.NewTimer(maxIdleTime)
    52  	defer timer.Stop()
    53  
    54  	term := newTerminal()
    55  	defer term.Reset()
    56  	go term.Monitor(ctx)
    57  
    58  	h := &fsEventHandler{last: time.Now(), fn: run}
    59  	for {
    60  		select {
    61  		case <-ctx.Done():
    62  			return nil
    63  		case <-timer.C:
    64  			return fmt.Errorf("exceeded idle timeout while watching files")
    65  
    66  		case event := <-term.Events():
    67  			resetTimer(timer)
    68  
    69  			if event.reloadPaths {
    70  				if err := loadPaths(watcher, dirs); err != nil {
    71  					return err
    72  				}
    73  				close(event.resume)
    74  				continue
    75  			}
    76  
    77  			term.Reset()
    78  			if err := h.runTests(event); err != nil {
    79  				return fmt.Errorf("failed to rerun tests for %v: %v", event.PkgPath, err)
    80  			}
    81  			term.Start()
    82  			close(event.resume)
    83  
    84  		case event := <-watcher.Events:
    85  			resetTimer(timer)
    86  			log.Debugf("handling event %v", event)
    87  
    88  			if handleDirCreated(watcher, event) {
    89  				continue
    90  			}
    91  
    92  			if err := h.handleEvent(event); err != nil {
    93  				return fmt.Errorf("failed to run tests for %v: %v", event.Name, err)
    94  			}
    95  
    96  		case err := <-watcher.Errors:
    97  			return fmt.Errorf("failed while watching files: %v", err)
    98  		}
    99  	}
   100  }
   101  
   102  const maxIdleTime = time.Hour
   103  
   104  func resetTimer(timer *time.Timer) {
   105  	if !timer.Stop() {
   106  		<-timer.C
   107  	}
   108  	timer.Reset(maxIdleTime)
   109  }
   110  
   111  func loadPaths(watcher *fsnotify.Watcher, dirs []string) error {
   112  	toWatch := findAllDirs(dirs, maxDepth)
   113  	fmt.Printf("Watching %v directories. Use Ctrl-c to to stop a run or exit.\n", len(toWatch))
   114  	for _, dir := range toWatch {
   115  		if err := watcher.Add(dir); err != nil {
   116  			return fmt.Errorf("failed to watch directory %v: %w", dir, err)
   117  		}
   118  	}
   119  	return nil
   120  }
   121  
   122  func findAllDirs(dirs []string, maxDepth int) []string {
   123  	if len(dirs) == 0 {
   124  		dirs = []string{"./..."}
   125  	}
   126  
   127  	var output []string // nolint: prealloc
   128  	for _, dir := range dirs {
   129  		const recur = "/..."
   130  		if strings.HasSuffix(dir, recur) {
   131  			dir = strings.TrimSuffix(dir, recur)
   132  			output = append(output, findSubDirs(dir, maxDepth)...)
   133  			continue
   134  		}
   135  		output = append(output, dir)
   136  	}
   137  	return output
   138  }
   139  
   140  func findSubDirs(rootDir string, maxDepth int) []string {
   141  	var output []string
   142  	// add root dir depth so that maxDepth is relative to the root dir
   143  	maxDepth += pathDepth(rootDir)
   144  	walker := func(path string, info os.FileInfo, err error) error {
   145  		if err != nil {
   146  			log.Warnf("failed to watch %v: %v", path, err)
   147  			return nil
   148  		}
   149  		if !info.IsDir() {
   150  			return nil
   151  		}
   152  		if pathDepth(path) > maxDepth || exclude(path) {
   153  			log.Debugf("Ignoring %v because of max depth or exclude list", path)
   154  			return filepath.SkipDir
   155  		}
   156  		if !hasGoFiles(path) {
   157  			log.Debugf("Ignoring %v because it has no .go files", path)
   158  			return nil
   159  		}
   160  		output = append(output, path)
   161  		return nil
   162  	}
   163  	// nolint: errcheck // error is handled by walker func
   164  	filepath.Walk(rootDir, walker)
   165  	return output
   166  }
   167  
   168  func pathDepth(path string) int {
   169  	return strings.Count(filepath.Clean(path), string(filepath.Separator))
   170  }
   171  
   172  // return true if path is vendor, testdata, or starts with a dot
   173  func exclude(path string) bool {
   174  	base := filepath.Base(path)
   175  	switch {
   176  	case strings.HasPrefix(base, ".") && len(base) > 1:
   177  		return true
   178  	case base == "vendor" || base == "testdata":
   179  		return true
   180  	}
   181  	return false
   182  }
   183  
   184  func hasGoFiles(path string) bool {
   185  	fh, err := os.Open(path)
   186  	if err != nil {
   187  		return false
   188  	}
   189  	defer fh.Close() // nolint: errcheck // fh is opened read-only
   190  
   191  	for {
   192  		names, err := fh.Readdirnames(20)
   193  		switch {
   194  		case err == io.EOF:
   195  			return false
   196  		case err != nil:
   197  			log.Warnf("failed to read directory %v: %v", path, err)
   198  			return false
   199  		}
   200  
   201  		for _, name := range names {
   202  			if strings.HasSuffix(name, ".go") {
   203  				return true
   204  			}
   205  		}
   206  	}
   207  }
   208  
   209  func handleDirCreated(watcher *fsnotify.Watcher, event fsnotify.Event) (handled bool) {
   210  	if event.Op&fsnotify.Create != fsnotify.Create {
   211  		return false
   212  	}
   213  
   214  	fileInfo, err := os.Stat(event.Name)
   215  	if err != nil {
   216  		log.Warnf("failed to stat %s: %s", event.Name, err)
   217  		return false
   218  	}
   219  
   220  	if !fileInfo.IsDir() {
   221  		return false
   222  	}
   223  
   224  	if err := watcher.Add(event.Name); err != nil {
   225  		log.Warnf("failed to watch new directory %v: %v", event.Name, err)
   226  	}
   227  	return true
   228  }
   229  
   230  type fsEventHandler struct {
   231  	last     time.Time
   232  	lastPath string
   233  	fn       func(opts Event) error
   234  }
   235  
   236  var floodThreshold = 250 * time.Millisecond
   237  
   238  func (h *fsEventHandler) handleEvent(event fsnotify.Event) error {
   239  	if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 {
   240  		return nil
   241  	}
   242  
   243  	if !strings.HasSuffix(event.Name, ".go") {
   244  		return nil
   245  	}
   246  
   247  	if time.Since(h.last) < floodThreshold {
   248  		log.Debugf("skipping event received less than %v after the previous", floodThreshold)
   249  		return nil
   250  	}
   251  	return h.runTests(Event{PkgPath: "./" + filepath.Dir(event.Name)})
   252  }
   253  
   254  func (h *fsEventHandler) runTests(opts Event) error {
   255  	if opts.useLastPath {
   256  		opts.PkgPath = h.lastPath
   257  	}
   258  	fmt.Printf("\nRunning tests in %v\n", opts.PkgPath)
   259  
   260  	if err := h.fn(opts); err != nil {
   261  		return err
   262  	}
   263  	h.last = time.Now()
   264  	h.lastPath = opts.PkgPath
   265  	return nil
   266  }