gopls/internal/filewatcher: replace handleEvent with convertEvent

This refactor make the convertEvent a simple function that only
handles event conversion. Leaving concurrency to the caller.

For golang/go#74292

Change-Id: I072e19731ae323da2a7e5012660532143d8afe57
Reviewed-on: https://go-review.googlesource.com/c/tools/+/692155
Reviewed-by: Robert Findley <rfindley@google.com>
Auto-Submit: Hongxiang Jiang <hxjiang@golang.org>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/gopls/internal/filewatcher/filewatcher.go b/gopls/internal/filewatcher/filewatcher.go
index f35e0c6..5d52f1e 100644
--- a/gopls/internal/filewatcher/filewatcher.go
+++ b/gopls/internal/filewatcher/filewatcher.go
@@ -121,16 +121,48 @@
 			if !ok {
 				continue
 			}
+
+			// fsnotify does not guarantee clean filepaths.
+			event.Name = filepath.Clean(event.Name)
+
 			// fsnotify.Event should not be handled concurrently, to preserve their
 			// original order. For example, if a file is deleted and recreated,
 			// concurrent handling could process the events in reverse order.
 			//
 			// Only reset the timer if a relevant event happened.
 			// https://github.com/fsnotify/fsnotify?tab=readme-ov-file#why-do-i-get-many-chmod-events
-			if e := w.handleEvent(event); e != nil {
-				w.addEvent(*e)
-				timer.Reset(delay)
+			e, isDir := w.convertEvent(event)
+			if e == (protocol.FileEvent{}) {
+				continue
 			}
+
+			if isDir {
+				switch e.Type {
+				case protocol.Created:
+					// Newly created directories are watched asynchronously to prevent
+					// a potential deadlock on Windows(see fsnotify/fsnotify#502).
+					// Errors are reported internally.
+					if done, release := w.addWatchHandle(event.Name); done != nil {
+						go func() {
+							w.errs <- w.watchDir(event.Name, done)
+
+							// Only release after the error is sent.
+							release()
+						}()
+					}
+				case protocol.Deleted:
+					// Upon removal, we only need to remove the entries from
+					// the map. The [fsnotify.Watcher] removes the watch for
+					// us. fsnotify/fsnotify#268
+					w.removeWatchHandle(event.Name)
+				default:
+					// convertEvent enforces that dirs are only Created or Deleted.
+					panic("impossible")
+				}
+			}
+
+			w.addEvent(e)
+			timer.Reset(delay)
 		}
 	}
 }
@@ -171,108 +203,70 @@
 	})
 }
 
-// handleEvent converts an [fsnotify.Event] to the corresponding [protocol.FileEvent]
-// and updates the watcher state, returning nil if the event is not relevant.
-//
-// To avoid blocking, any required watches for new subdirectories are registered
-// asynchronously in a separate goroutine.
-func (w *Watcher) handleEvent(event fsnotify.Event) *protocol.FileEvent {
-	// fsnotify does not guarantee clean filepaths.
-	path := filepath.Clean(event.Name)
-
-	var isDir bool
-	if info, err := os.Stat(path); err == nil {
+// convertEvent translates an [fsnotify.Event] into a [protocol.FileEvent].
+// It returns the translated event and a boolean indicating if the path was a
+// directory. For directories, the event Type is either Created or Deleted.
+// It returns empty event for events that should be ignored.
+func (w *Watcher) convertEvent(event fsnotify.Event) (_ protocol.FileEvent, isDir bool) {
+	// Determine if the event is for a directory.
+	if info, err := os.Stat(event.Name); err == nil {
 		isDir = info.IsDir()
 	} else if os.IsNotExist(err) {
-		// Upon deletion, the file/dir has been removed. fsnotify
-		// does not provide information regarding the deleted item.
+		// Upon deletion, the file/dir has been removed. fsnotify does not
+		// provide information regarding the deleted item.
 		// Use watchHandles to determine if the deleted item was a directory.
-		isDir = w.isWatchedDir(path)
+		isDir = w.isWatchedDir(event.Name)
 	} else {
 		// If statting failed, something is wrong with the file system.
 		// Log and move on.
 		if w.logger != nil {
-			w.logger.Error("failed to stat path, skipping event as its type (file/dir) is unknown", "path", path, "err", err)
+			w.logger.Error("failed to stat path, skipping event as its type (file/dir) is unknown", "path", event.Name, "err", err)
 		}
-		return nil
+		return protocol.FileEvent{}, false
 	}
 
+	// Filter out events for directories and files that are not of interest.
 	if isDir {
-		if skipDir(filepath.Base(path)) {
-			return nil
-		}
-
-		switch {
-		case event.Op.Has(fsnotify.Rename):
-			// A rename is treated as a deletion of the old path because the
-			// fsnotify RENAME event doesn't include the new path. A separate
-			// CREATE event will be sent for the new path if the destination
-			// directory is watched.
-			fallthrough
-		case event.Op.Has(fsnotify.Remove):
-			// Upon removal, we only need to remove the entries from the map.
-			// The [fsnotify.Watcher] removes the watch for us.
-			// fsnotify/fsnotify#268
-			w.removeWatchHandle(path)
-
-			// TODO(hxjiang): Directory removal events from some LSP clients may
-			// not include corresponding removal events for child files and
-			// subdirectories. Should we do some filtering when adding the dir
-			// deletion event to the events slice.
-			return &protocol.FileEvent{
-				URI:  protocol.URIFromPath(path),
-				Type: protocol.Deleted,
-			}
-		case event.Op.Has(fsnotify.Create):
-			// This watch is added asynchronously to prevent a potential
-			// deadlock on Windows. See fsnotify/fsnotify#502.
-			// Error encountered will be sent to internal error channel.
-			if done, release := w.addWatchHandle(path); done != nil {
-				go func() {
-					w.errs <- w.watchDir(path, done)
-
-					// Only release after the error is sent.
-					release()
-				}()
-			}
-
-			return &protocol.FileEvent{
-				URI:  protocol.URIFromPath(path),
-				Type: protocol.Created,
-			}
-		default:
-			return nil
+		if skipDir(filepath.Base(event.Name)) {
+			return protocol.FileEvent{}, true
 		}
 	} else {
-		// Only watch files of interest.
-		switch strings.TrimPrefix(filepath.Ext(path), ".") {
+		switch strings.TrimPrefix(filepath.Ext(event.Name), ".") {
 		case "go", "mod", "sum", "work", "s":
 		default:
-			return nil
-		}
-
-		var t protocol.FileChangeType
-		switch {
-		case event.Op.Has(fsnotify.Rename):
-			// A rename is treated as a deletion of the old path because the
-			// fsnotify RENAME event doesn't include the new path. A separate
-			// CREATE event will be sent for the new path if the destination
-			// directory is watched.
-			fallthrough
-		case event.Op.Has(fsnotify.Remove):
-			t = protocol.Deleted
-		case event.Op.Has(fsnotify.Create):
-			t = protocol.Created
-		case event.Op.Has(fsnotify.Write):
-			t = protocol.Changed
-		default:
-			return nil // ignore the rest of the events
-		}
-		return &protocol.FileEvent{
-			URI:  protocol.URIFromPath(path),
-			Type: t,
+			return protocol.FileEvent{}, false
 		}
 	}
+
+	var t protocol.FileChangeType
+	switch {
+	case event.Op.Has(fsnotify.Rename):
+		// A rename is treated as a deletion of the old path because the
+		// fsnotify RENAME event doesn't include the new path. A separate
+		// CREATE event will be sent for the new path if the destination
+		// directory is watched.
+		fallthrough
+	case event.Op.Has(fsnotify.Remove):
+		// TODO(hxjiang): Directory removal events from some LSP clients may
+		// not include corresponding removal events for child files and
+		// subdirectories. Should we do some filtering when adding the dir
+		// deletion event to the events slice.
+		t = protocol.Deleted
+	case event.Op.Has(fsnotify.Create):
+		t = protocol.Created
+	case event.Op.Has(fsnotify.Write):
+		if isDir {
+			return protocol.FileEvent{}, isDir // ignore dir write events
+		}
+		t = protocol.Changed
+	default:
+		return protocol.FileEvent{}, isDir // ignore the rest of the events
+	}
+
+	return protocol.FileEvent{
+		URI:  protocol.URIFromPath(event.Name),
+		Type: t,
+	}, isDir
 }
 
 // watchDir registers a watch for a directory, retrying with backoff if it fails.