File: HostWorkspace\FileWatching\DefaultFileChangeWatcher.FileChangeContext.cs
Web Access
Project: src\src\LanguageServer\Microsoft.CodeAnalysis.LanguageServer\Microsoft.CodeAnalysis.LanguageServer.csproj (Microsoft.CodeAnalysis.LanguageServer)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
 
using System.Collections.Immutable;
using System.Runtime.InteropServices;
using Microsoft.CodeAnalysis.ProjectSystem;
 
namespace Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.FileWatching;
 
internal sealed partial class DefaultFileChangeWatcher
{
    internal sealed class FileChangeContext : IFileChangeContext
    {
        private readonly DefaultFileChangeWatcher _owner;
 
        /// <summary>
        /// A monitor lock held for code touching <see cref="_explicitlyWatchedFiles"/>, and <see cref="_watchedDirectoriesWatches"/> during disposal. It is not expected to be held
        /// when calling into <see cref="_owner"/>, but that shouldn't really be necessary given the simplicitly of this type.
        /// </summary>
        private readonly object _gate = new();
 
        private readonly ImmutableArray<WatchedDirectory> _watchedDirectories;
 
        /// <summary>
        /// The actual watches created for each watch in <see cref="_watchedDirectories"/>.
        /// </summary>
        private readonly List<IDisposable> _watchedDirectoriesWatches;
 
        /// <summary>
        /// A map from a file path to the number of times <see cref="EnqueueWatchingFile(string)"/> was called for that file path, and the IDisposable for the
        /// return from <see cref="DefaultFileChangeWatcher.AcquireDirectoryWatch(WatchedDirectory, FileChangeContext)"/> when it was called for the first time.
        /// </summary>
        private readonly Dictionary<string, (int count, IDisposable directoryWatch)> _explicitlyWatchedFiles = new(s_pathStringComparer);
 
        private bool _disposed;
 
        public FileChangeContext(DefaultFileChangeWatcher owner, ImmutableArray<WatchedDirectory> watchedDirectories)
        {
            _owner = owner;
            _watchedDirectories = watchedDirectories;
 
            // Acquire the directory watches for each directory; it's important this happens last in the constructor since events
            // could get notified immediatly after the directory is watched.
            _watchedDirectoriesWatches = new List<IDisposable>(_watchedDirectories.Length);
            foreach (var watchedDirectory in _watchedDirectories)
                _watchedDirectoriesWatches.Add(_owner.AcquireDirectoryWatch(watchedDirectory, this));
        }
 
        public event EventHandler<string>? FileChanged;
 
        public IWatchedFile EnqueueWatchingFile(string filePath)
        {
            if (WatchedDirectory.FilePathCoveredByWatchedDirectories(_watchedDirectories, filePath, s_pathStringComparison))
                return NoOpWatchedFile.Instance;
 
            var parentDirectory = Path.GetDirectoryName(filePath);
            if (parentDirectory is null)
                return NoOpWatchedFile.Instance;
 
            lock (_gate)
            {
 
                if (_explicitlyWatchedFiles.TryGetValue(filePath, out var countAndWatcher))
                {
                    _explicitlyWatchedFiles[filePath] = countAndWatcher with { count = countAndWatcher.count + 1 };
                }
                else
                {
                    var extension = Path.GetExtension(filePath);
                    var directoryWatchToken = _owner.AcquireDirectoryWatch(new WatchedDirectory(parentDirectory, extensionFilters: string.IsNullOrEmpty(extension) ? [] : [extension]), this);
                    countAndWatcher = (count: 1, directoryWatchToken);
                    _explicitlyWatchedFiles.Add(filePath, countAndWatcher);
                }
            }
 
            return new ExplicitlyWatchedFile(this, filePath);
 
        }
 
        /// <summary>
        /// Routes a filesystem event to this context when the changed path matches one of the context's watched
        /// directories or explicitly watched files.
        /// </summary>
        internal void OnFileSystemEvent(FileSystemEventArgs e)
        {
            bool shouldRaiseForNewPath;
            bool shouldRaiseForOldPath = false;
 
            lock (_gate)
            {
                if (_disposed)
                    return;
 
                shouldRaiseForNewPath = ShouldRaiseForPath_NoLock(e.FullPath);
 
                if (e is RenamedEventArgs renamedEventArgs && RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
                    shouldRaiseForOldPath = ShouldRaiseForPath_NoLock(renamedEventArgs.OldFullPath);
            }
 
            if (shouldRaiseForNewPath)
                FileChanged?.Invoke(this, e.FullPath);
 
            if (shouldRaiseForOldPath)
                FileChanged?.Invoke(this, ((RenamedEventArgs)e).OldFullPath);
        }
 
        /// <summary>
        /// Returns <see langword="true"/> when this context is interested in notifications for <paramref name="filePath"/>.
        /// </summary>
        private bool ShouldRaiseForPath_NoLock(string filePath)
            => _explicitlyWatchedFiles.ContainsKey(filePath) ||
               WatchedDirectory.FilePathCoveredByWatchedDirectories(_watchedDirectories, filePath, s_pathStringComparison);
 
        /// <summary>
        /// Removes one explicit file watch registration when a returned <see cref="IWatchedFile"/> is disposed.
        /// </summary>
        private void RemoveExplicitlyWatchedFile(string filePath)
        {
            lock (_gate)
            {
                // If it's already not in that dictionary, then the entire context might have already been disposed
                if (!_explicitlyWatchedFiles.TryGetValue(filePath, out var countAndWatcher))
                    return;
 
                if (countAndWatcher.count == 1)
                {
                    countAndWatcher.directoryWatch.Dispose();
                    _explicitlyWatchedFiles.Remove(filePath);
                }
                else
                {
                    _explicitlyWatchedFiles[filePath] = countAndWatcher with { count = countAndWatcher.count - 1 };
                }
            }
        }
 
        public void Dispose()
        {
            if (Interlocked.Exchange(ref _disposed, true))
                return;
 
            List<IDisposable> watches;
 
            lock (_gate)
            {
                watches = [.. _watchedDirectoriesWatches, .. _explicitlyWatchedFiles.Values.Select(v => v.directoryWatch)];
                _watchedDirectoriesWatches.Clear();
                _explicitlyWatchedFiles.Clear();
            }
 
            foreach (var watch in watches)
                watch.Dispose();
        }
 
        private sealed class ExplicitlyWatchedFile(FileChangeContext context, string filePath) : IWatchedFile
        {
            private bool _disposed;
 
            public void Dispose()
            {
                if (Interlocked.Exchange(ref _disposed, true))
                    return;
 
                context.RemoveExplicitlyWatchedFile(filePath);
            }
        }
    }
}