@@ -121,16 +121,48 @@ func (w *Watcher) run(delay time.Duration, handler func([]protocol.FileEvent, er
121121 if ! ok {
122122 continue
123123 }
124+
125+ // fsnotify does not guarantee clean filepaths.
126+ event .Name = filepath .Clean (event .Name )
127+
124128 // fsnotify.Event should not be handled concurrently, to preserve their
125129 // original order. For example, if a file is deleted and recreated,
126130 // concurrent handling could process the events in reverse order.
127131 //
128132 // Only reset the timer if a relevant event happened.
129133 // https://github.com/fsnotify/fsnotify?tab=readme-ov-file#why-do-i-get-many-chmod-events
130- if e := w .handleEvent (event ); e != nil {
131- w .addEvent (* e )
132- timer .Reset (delay )
134+ e , isDir := w .convertEvent (event )
135+ if e == (protocol.FileEvent {}) {
136+ continue
137+ }
138+
139+ if isDir {
140+ switch e .Type {
141+ case protocol .Created :
142+ // Newly created directories are watched asynchronously to prevent
143+ // a potential deadlock on Windows(see fsnotify/fsnotify#502).
144+ // Errors are reported internally.
145+ if done , release := w .addWatchHandle (event .Name ); done != nil {
146+ go func () {
147+ w .errs <- w .watchDir (event .Name , done )
148+
149+ // Only release after the error is sent.
150+ release ()
151+ }()
152+ }
153+ case protocol .Deleted :
154+ // Upon removal, we only need to remove the entries from
155+ // the map. The [fsnotify.Watcher] removes the watch for
156+ // us. fsnotify/fsnotify#268
157+ w .removeWatchHandle (event .Name )
158+ default :
159+ // convertEvent enforces that dirs are only Created or Deleted.
160+ panic ("impossible" )
161+ }
133162 }
163+
164+ w .addEvent (e )
165+ timer .Reset (delay )
134166 }
135167 }
136168}
@@ -171,108 +203,70 @@ func (w *Watcher) WatchDir(path string) error {
171203 })
172204}
173205
174- // handleEvent converts an [fsnotify.Event] to the corresponding [protocol.FileEvent]
175- // and updates the watcher state, returning nil if the event is not relevant.
176- //
177- // To avoid blocking, any required watches for new subdirectories are registered
178- // asynchronously in a separate goroutine.
179- func (w * Watcher ) handleEvent (event fsnotify.Event ) * protocol.FileEvent {
180- // fsnotify does not guarantee clean filepaths.
181- path := filepath .Clean (event .Name )
182-
183- var isDir bool
184- if info , err := os .Stat (path ); err == nil {
206+ // convertEvent translates an [fsnotify.Event] into a [protocol.FileEvent].
207+ // It returns the translated event and a boolean indicating if the path was a
208+ // directory. For directories, the event Type is either Created or Deleted.
209+ // It returns empty event for events that should be ignored.
210+ func (w * Watcher ) convertEvent (event fsnotify.Event ) (_ protocol.FileEvent , isDir bool ) {
211+ // Determine if the event is for a directory.
212+ if info , err := os .Stat (event .Name ); err == nil {
185213 isDir = info .IsDir ()
186214 } else if os .IsNotExist (err ) {
187- // Upon deletion, the file/dir has been removed. fsnotify
188- // does not provide information regarding the deleted item.
215+ // Upon deletion, the file/dir has been removed. fsnotify does not
216+ // provide information regarding the deleted item.
189217 // Use watchHandles to determine if the deleted item was a directory.
190- isDir = w .isWatchedDir (path )
218+ isDir = w .isWatchedDir (event . Name )
191219 } else {
192220 // If statting failed, something is wrong with the file system.
193221 // Log and move on.
194222 if w .logger != nil {
195- w .logger .Error ("failed to stat path, skipping event as its type (file/dir) is unknown" , "path" , path , "err" , err )
223+ w .logger .Error ("failed to stat path, skipping event as its type (file/dir) is unknown" , "path" , event . Name , "err" , err )
196224 }
197- return nil
225+ return protocol. FileEvent {}, false
198226 }
199227
228+ // Filter out events for directories and files that are not of interest.
200229 if isDir {
201- if skipDir (filepath .Base (path )) {
202- return nil
203- }
204-
205- switch {
206- case event .Op .Has (fsnotify .Rename ):
207- // A rename is treated as a deletion of the old path because the
208- // fsnotify RENAME event doesn't include the new path. A separate
209- // CREATE event will be sent for the new path if the destination
210- // directory is watched.
211- fallthrough
212- case event .Op .Has (fsnotify .Remove ):
213- // Upon removal, we only need to remove the entries from the map.
214- // The [fsnotify.Watcher] removes the watch for us.
215- // fsnotify/fsnotify#268
216- w .removeWatchHandle (path )
217-
218- // TODO(hxjiang): Directory removal events from some LSP clients may
219- // not include corresponding removal events for child files and
220- // subdirectories. Should we do some filtering when adding the dir
221- // deletion event to the events slice.
222- return & protocol.FileEvent {
223- URI : protocol .URIFromPath (path ),
224- Type : protocol .Deleted ,
225- }
226- case event .Op .Has (fsnotify .Create ):
227- // This watch is added asynchronously to prevent a potential
228- // deadlock on Windows. See fsnotify/fsnotify#502.
229- // Error encountered will be sent to internal error channel.
230- if done , release := w .addWatchHandle (path ); done != nil {
231- go func () {
232- w .errs <- w .watchDir (path , done )
233-
234- // Only release after the error is sent.
235- release ()
236- }()
237- }
238-
239- return & protocol.FileEvent {
240- URI : protocol .URIFromPath (path ),
241- Type : protocol .Created ,
242- }
243- default :
244- return nil
230+ if skipDir (filepath .Base (event .Name )) {
231+ return protocol.FileEvent {}, true
245232 }
246233 } else {
247- // Only watch files of interest.
248- switch strings .TrimPrefix (filepath .Ext (path ), "." ) {
234+ switch strings .TrimPrefix (filepath .Ext (event .Name ), "." ) {
249235 case "go" , "mod" , "sum" , "work" , "s" :
250236 default :
251- return nil
237+ return protocol. FileEvent {}, false
252238 }
239+ }
253240
254- var t protocol.FileChangeType
255- switch {
256- case event .Op .Has (fsnotify .Rename ):
257- // A rename is treated as a deletion of the old path because the
258- // fsnotify RENAME event doesn't include the new path. A separate
259- // CREATE event will be sent for the new path if the destination
260- // directory is watched.
261- fallthrough
262- case event .Op .Has (fsnotify .Remove ):
263- t = protocol .Deleted
264- case event .Op .Has (fsnotify .Create ):
265- t = protocol .Created
266- case event .Op .Has (fsnotify .Write ):
267- t = protocol .Changed
268- default :
269- return nil // ignore the rest of the events
270- }
271- return & protocol.FileEvent {
272- URI : protocol .URIFromPath (path ),
273- Type : t ,
241+ var t protocol.FileChangeType
242+ switch {
243+ case event .Op .Has (fsnotify .Rename ):
244+ // A rename is treated as a deletion of the old path because the
245+ // fsnotify RENAME event doesn't include the new path. A separate
246+ // CREATE event will be sent for the new path if the destination
247+ // directory is watched.
248+ fallthrough
249+ case event .Op .Has (fsnotify .Remove ):
250+ // TODO(hxjiang): Directory removal events from some LSP clients may
251+ // not include corresponding removal events for child files and
252+ // subdirectories. Should we do some filtering when adding the dir
253+ // deletion event to the events slice.
254+ t = protocol .Deleted
255+ case event .Op .Has (fsnotify .Create ):
256+ t = protocol .Created
257+ case event .Op .Has (fsnotify .Write ):
258+ if isDir {
259+ return protocol.FileEvent {}, isDir // ignore dir write events
274260 }
261+ t = protocol .Changed
262+ default :
263+ return protocol.FileEvent {}, isDir // ignore the rest of the events
275264 }
265+
266+ return protocol.FileEvent {
267+ URI : protocol .URIFromPath (event .Name ),
268+ Type : t ,
269+ }, isDir
276270}
277271
278272// watchDir registers a watch for a directory, retrying with backoff if it fails.
0 commit comments