File: FileWatcher\FileWatcher.cs
Web Access
Project: ..\..\..\src\BuiltInTools\dotnet-watch\dotnet-watch.csproj (dotnet-watch)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.Immutable;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
 
namespace Microsoft.DotNet.Watch
{
    internal class FileWatcher(ILogger logger, EnvironmentOptions environmentOptions) : IDisposable
    {
        // Directory watcher for each watched directory tree.
        // Keyed by full path to the root directory with a trailing directory separator.
        protected readonly Dictionary<string, DirectoryWatcher> _directoryTreeWatchers = new(PathUtilities.OSSpecificPathComparer);
 
        // Directory watcher for each watched directory (non-recursive).
        // Keyed by full path to the root directory with a trailing directory separator.
        protected readonly Dictionary<string, DirectoryWatcher> _directoryWatchers = new(PathUtilities.OSSpecificPathComparer);
 
        private bool _disposed;
        public event Action<ChangedPath>? OnFileChange;
 
        public bool SuppressEvents { get; set; }
 
        public void Dispose()
        {
            if (_disposed)
            {
                return;
            }
 
            _disposed = true;
 
            foreach (var (_, watcher) in _directoryTreeWatchers)
            {
                watcher.OnFileChange -= WatcherChangedHandler;
                watcher.OnError -= WatcherErrorHandler;
                watcher.Dispose();
            }
        }
 
        protected virtual DirectoryWatcher CreateDirectoryWatcher(string directory, ImmutableHashSet<string> fileNames, bool includeSubdirectories)
        {
            var watcher = DirectoryWatcher.Create(directory, fileNames, environmentOptions.IsPollingEnabled, includeSubdirectories);
            if (watcher is EventBasedDirectoryWatcher eventBasedWatcher)
            {
                eventBasedWatcher.Logger = message => logger.LogDebug(message);
            }
 
            return watcher;
        }
 
        public bool WatchingDirectories
            => _directoryTreeWatchers.Count > 0 || _directoryWatchers.Count > 0;
 
        /// <summary>
        /// Watches individual files.
        /// </summary>
        public void WatchFiles(IEnumerable<string> filePaths)
            => Watch(filePaths, containingDirectories: false, includeSubdirectories: false);
 
        /// <summary>
        /// Watches an entire directory or directory tree.
        /// </summary>
        public void WatchContainingDirectories(IEnumerable<string> filePaths, bool includeSubdirectories)
            => Watch(filePaths, containingDirectories: true, includeSubdirectories);
 
        private void Watch(IEnumerable<string> filePaths, bool containingDirectories, bool includeSubdirectories)
        {
            ObjectDisposedException.ThrowIf(_disposed, this);
            Debug.Assert(containingDirectories || !includeSubdirectories);
 
            var filesByDirectory =
                from path in filePaths
                group path by PathUtilities.EnsureTrailingSlash(PathUtilities.NormalizeDirectorySeparators(Path.GetDirectoryName(path)!))
                into g
                select (g.Key, containingDirectories ? [] : g.Select(path => Path.GetFileName(path)).ToImmutableHashSet(PathUtilities.OSSpecificPathComparer));
 
            foreach (var (directory, fileNames) in filesByDirectory)
            {
                // the directory is watched by active directory watcher:
                if (!includeSubdirectories && _directoryWatchers.TryGetValue(directory, out var existingDirectoryWatcher))
                {
                    if (existingDirectoryWatcher.WatchedFileNames.IsEmpty)
                    {
                        // already watching all files in the directory
                        continue;
                    }
 
                    if (fileNames.IsEmpty)
                    {
                        // watch all files:
                        existingDirectoryWatcher.WatchedFileNames = fileNames;
                        continue;
                    }
 
                    // merge sets of watched files:
                    foreach (var fileName in fileNames)
                    {
                        existingDirectoryWatcher.WatchedFileNames = existingDirectoryWatcher.WatchedFileNames.Add(fileName);
                    }
 
                    continue;
                }
 
                // the directory is a root or subdirectory of active directory tree watcher:
                var alreadyWatched = _directoryTreeWatchers.Any(d => directory.StartsWith(d.Key, PathUtilities.OSSpecificPathComparison));
                if (alreadyWatched)
                {
                    continue;
                }
 
                var newWatcher = CreateDirectoryWatcher(directory, fileNames, includeSubdirectories);
                newWatcher.OnFileChange += WatcherChangedHandler;
                newWatcher.OnError += WatcherErrorHandler;
                newWatcher.EnableRaisingEvents = true;
 
                // watchers that are now redundant (covered by the new directory watcher):
                if (includeSubdirectories)
                {
                    Debug.Assert(fileNames.IsEmpty);
 
                    RemoveRedundantWatchers(_directoryTreeWatchers);
                    RemoveRedundantWatchers(_directoryWatchers);
 
                    void RemoveRedundantWatchers(Dictionary<string, DirectoryWatcher> watchers)
                    {
                        var watchersToRemove = watchers
                            .Where(d => d.Key.StartsWith(directory, PathUtilities.OSSpecificPathComparison))
                            .ToList();
 
                        foreach (var (watchedDirectory, watcher) in watchersToRemove)
                        {
                            watchers.Remove(watchedDirectory);
 
                            watcher.EnableRaisingEvents = false;
                            watcher.OnFileChange -= WatcherChangedHandler;
                            watcher.OnError -= WatcherErrorHandler;
 
                            watcher.Dispose();
                        }
                    }
 
                    _directoryTreeWatchers.Add(directory, newWatcher);
                }
                else
                {
                    _directoryWatchers.Add(directory, newWatcher);
                }
            }
        }
 
        private void WatcherErrorHandler(object? sender, Exception error)
        {
            if (sender is DirectoryWatcher watcher)
            {
                logger.LogWarning("The file watcher observing '{WatchedDirectory}' encountered an error: {Message}", watcher.WatchedDirectory, error.Message);
            }
        }
 
        private void WatcherChangedHandler(object? sender, ChangedPath change)
        {
            if (!SuppressEvents)
            {
                OnFileChange?.Invoke(change);
            }
        }
 
        public async Task<ChangedFile?> WaitForFileChangeAsync(IReadOnlyDictionary<string, FileItem> fileSet, Action? startedWatching, CancellationToken cancellationToken)
        {
            var changedPath = await WaitForFileChangeAsync(
                acceptChange: change => fileSet.ContainsKey(change.Path),
                startedWatching,
                cancellationToken);
 
            return changedPath.HasValue ? new ChangedFile(fileSet[changedPath.Value.Path], changedPath.Value.Kind) : null;
        }
 
        public async Task<ChangedPath?> WaitForFileChangeAsync(Predicate<ChangedPath> acceptChange, Action? startedWatching, CancellationToken cancellationToken)
        {
            var fileChangedSource = new TaskCompletionSource<ChangedPath?>(TaskCreationOptions.RunContinuationsAsynchronously);
            cancellationToken.Register(() => fileChangedSource.TrySetResult(null));
 
            void FileChangedCallback(ChangedPath change)
            {
                if (acceptChange(change))
                {
                    fileChangedSource.TrySetResult(change);
                }
            }
 
            ChangedPath? change;
 
            OnFileChange += FileChangedCallback;
            try
            {
                startedWatching?.Invoke();
                change = await fileChangedSource.Task;
            }
            finally
            {
                OnFileChange -= FileChangedCallback;
            }
 
            return change;
        }
 
        public static async ValueTask WaitForFileChangeAsync(string filePath, ILogger logger, EnvironmentOptions environmentOptions, Action? startedWatching, CancellationToken cancellationToken)
        {
            using var watcher = new FileWatcher(logger, environmentOptions);
 
            watcher.WatchContainingDirectories([filePath], includeSubdirectories: false);
 
            var fileChange = await watcher.WaitForFileChangeAsync(
                acceptChange: change => change.Path == filePath,
                startedWatching,
                cancellationToken);
 
            if (fileChange != null)
            {
                logger.LogInformation("File changed: {FilePath}", filePath);
            }
        }
    }
}