File: FileWatcher\FileWatcher.cs
Web Access
Project: src\src\sdk\src\Dotnet.Watch\Watch\Microsoft.DotNet.HotReload.Watch.csproj (Microsoft.DotNet.HotReload.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 DateTime StartTime { 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.LogTrace(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.GetFileName).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(IEnumerable<string> filePaths, ILogger logger, EnvironmentOptions environmentOptions, Action? startedWatching, CancellationToken cancellationToken)
    {
        using var watcher = new FileWatcher(logger, environmentOptions);

        watcher.WatchContainingDirectories(filePaths, includeSubdirectories: false);

        var pathSet = filePaths.ToHashSet();

        var fileChange = await watcher.WaitForFileChangeAsync(
            acceptChange: change => pathSet.Contains(change.Path),
            startedWatching,
            cancellationToken);

        if (fileChange != null)
        {
            logger.LogInformation("File changed: {FilePath}", fileChange.Value.Path);
        }
    }
}