|
// 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.LanguageServer.Handler;
using Microsoft.CodeAnalysis.LanguageServer.LanguageServer;
using Microsoft.CodeAnalysis.ProjectSystem;
using Microsoft.CodeAnalysis.Shared.TestHooks;
using Roslyn.LanguageServer.Protocol;
using Roslyn.Utilities;
using StreamJsonRpc;
using FileSystemWatcher = Roslyn.LanguageServer.Protocol.FileSystemWatcher;
namespace Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.FileWatching;
/// <summary>
/// An implementation of <see cref="IFileChangeWatcher" /> that delegates file watching through the LSP protocol to the client.
/// </summary>
internal sealed class LspFileChangeWatcher : IFileChangeWatcher
{
private readonly LspDidChangeWatchedFilesHandler _didChangeWatchedFilesHandler;
private readonly IClientLanguageServerManager _clientLanguageServerManager;
private readonly IAsynchronousOperationListener _asynchronousOperationListener;
public LspFileChangeWatcher(LanguageServerHost languageServerHost, IAsynchronousOperationListenerProvider asynchronousOperationListenerProvider)
{
_didChangeWatchedFilesHandler = languageServerHost.GetRequiredLspService<LspDidChangeWatchedFilesHandler>();
_clientLanguageServerManager = languageServerHost.GetRequiredLspService<IClientLanguageServerManager>();
_asynchronousOperationListener = asynchronousOperationListenerProvider.GetListener(FeatureAttribute.Workspace);
Contract.ThrowIfFalse(SupportsLanguageServerHost(languageServerHost));
}
public static bool SupportsLanguageServerHost(LanguageServerHost languageServerHost)
{
// We can only use the LSP client for doing file watching if we support dynamic registration for it
var clientCapabilitiesProvider = languageServerHost.GetRequiredLspService<IInitializeManager>();
return clientCapabilitiesProvider.GetClientCapabilities().Workspace?.DidChangeWatchedFiles?.DynamicRegistration ?? false;
}
public IFileChangeContext CreateContext(ImmutableArray<WatchedDirectory> watchedDirectories)
=> new FileChangeContext(watchedDirectories, this);
private sealed class FileChangeContext : IFileChangeContext
{
private readonly ImmutableArray<WatchedDirectory> _watchedDirectories;
private readonly LspFileChangeWatcher _lspFileChangeWatcher;
/// <summary>
/// The registration for the directory being watched in this context, if some were given.
/// </summary>
private readonly LspFileWatchRegistration? _directoryWatchRegistration;
/// <summary>
/// A lock to guard updates to <see cref="_watchedFiles" />. Using a reader/writer lock since file change notifications can be pretty chatty
/// and so we want to be able to process changes as fast as possible.
/// </summary>
private readonly ReaderWriterLockSlim _watchedFilesLock = new ReaderWriterLockSlim();
/// <summary>
/// The list of file paths we're watching manually that were outside the directories being watched. The count in this case counts
/// the number of
/// </summary>
private readonly Dictionary<string, int> _watchedFiles = new Dictionary<string, int>(s_stringComparer);
private static readonly StringComparer s_stringComparer = RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase;
private static readonly StringComparison s_stringComparison = RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
public FileChangeContext(ImmutableArray<WatchedDirectory> watchedDirectories, LspFileChangeWatcher lspFileChangeWatcher)
{
_watchedDirectories = watchedDirectories;
_lspFileChangeWatcher = lspFileChangeWatcher;
// If we have any watched directories, then watch those directories directly
if (watchedDirectories.Any())
{
var directoryWatches = watchedDirectories.Select(d =>
{
var pattern = "**/*" + d.ExtensionFilters.Length switch
{
0 => string.Empty,
1 => d.ExtensionFilters[0],
_ => "{" + string.Join(',', d.ExtensionFilters) + "}"
};
return new FileSystemWatcher
{
GlobPattern = new RelativePattern
{
BaseUri = ProtocolConversions.CreateRelativePatternBaseUri(d.Path),
Pattern = pattern
}
};
}).ToArray();
_directoryWatchRegistration = new LspFileWatchRegistration(lspFileChangeWatcher, directoryWatches);
}
_lspFileChangeWatcher._didChangeWatchedFilesHandler.NotificationRaised += WatchedFilesHandler_OnNotificationRaised;
}
private void WatchedFilesHandler_OnNotificationRaised(object? sender, DidChangeWatchedFilesParams e)
{
foreach (var changedFile in e.Changes)
{
var filePath = changedFile.Uri.LocalPath;
// Unfortunately the LSP protocol doesn't give us any hint of which of the file watches we might have sent to the client
// was the one that registered for this change, so we have to check paths to see if this one we should respond to.
if (WatchedDirectory.FilePathCoveredByWatchedDirectories(_watchedDirectories, filePath, s_stringComparison))
{
FileChanged?.Invoke(this, filePath);
}
else
{
bool isFileWatched;
using (_watchedFilesLock.DisposableRead())
{
isFileWatched = _watchedFiles.ContainsKey(filePath);
}
if (isFileWatched)
FileChanged?.Invoke(this, filePath);
}
}
}
public event EventHandler<string>? FileChanged;
public void Dispose()
{
_lspFileChangeWatcher._didChangeWatchedFilesHandler.NotificationRaised -= WatchedFilesHandler_OnNotificationRaised;
_directoryWatchRegistration?.Dispose();
}
public IWatchedFile EnqueueWatchingFile(string filePath)
{
// If we already have this file under our path, we may not have to do additional watching
if (WatchedDirectory.FilePathCoveredByWatchedDirectories(_watchedDirectories, filePath, s_stringComparison))
return NoOpWatchedFile.Instance;
// Record that we're now watching this file
using (_watchedFilesLock.DisposableWrite())
{
_watchedFiles.TryGetValue(filePath, out var existingWatches);
_watchedFiles[filePath] = existingWatches + 1;
}
var fileSystemWatcher = new FileSystemWatcher()
{
// TODO: figure out how I just can do an absolute path watch
GlobPattern = new RelativePattern
{
BaseUri = ProtocolConversions.CreateAbsoluteUri(Path.GetDirectoryName(filePath)!),
Pattern = Path.GetFileName(filePath)
}
};
return new WatchedFile(filePath, new LspFileWatchRegistration(_lspFileChangeWatcher, fileSystemWatcher), this);
}
private void RemoveFileFromWatchList(string filePath)
{
// Record that we're no longer watching this file
using (_watchedFilesLock.DisposableWrite())
{
var existingWatches = _watchedFiles[filePath];
if (existingWatches == 1)
_watchedFiles.Remove(filePath);
else
_watchedFiles[filePath] = existingWatches - 1;
}
}
private class WatchedFile : IWatchedFile
{
private readonly string _filePath;
private readonly LspFileWatchRegistration _fileWatchRegistration;
private readonly FileChangeContext _fileChangeContext;
public WatchedFile(string filePath, LspFileWatchRegistration fileWatchRegistration, FileChangeContext fileChangeContext)
{
_filePath = filePath;
_fileWatchRegistration = fileWatchRegistration;
_fileChangeContext = fileChangeContext;
}
public void Dispose()
{
_fileWatchRegistration.Dispose();
_fileChangeContext.RemoveFileFromWatchList(_filePath);
}
}
}
/// <summary>
/// A small class to represent a registration that is sent to the client that we can cancel later. Since we send
/// registrations asynchronously, this tracks that so we don't send the unregister too early.
/// </summary>
private sealed class LspFileWatchRegistration : IDisposable
{
private readonly LspFileChangeWatcher _changeWatcher;
private readonly string _id;
private readonly CancellationTokenSource _cancellationTokenSource;
private readonly Task _registrationTask;
public LspFileWatchRegistration(LspFileChangeWatcher changeWatcher, params FileSystemWatcher[] fileSystemWatchers)
{
_changeWatcher = changeWatcher;
_id = Guid.NewGuid().ToString();
_cancellationTokenSource = new CancellationTokenSource();
var registrationParams = new RegistrationParams()
{
Registrations =
[
new Registration
{
Id = _id,
Method = "workspace/didChangeWatchedFiles",
RegisterOptions = new DidChangeWatchedFilesRegistrationOptions
{
Watchers = fileSystemWatchers
}
}
]
};
var asyncToken = _changeWatcher._asynchronousOperationListener.BeginAsyncOperation(nameof(LspFileWatchRegistration));
_registrationTask = changeWatcher._clientLanguageServerManager.SendRequestAsync("client/registerCapability", registrationParams, _cancellationTokenSource.Token).AsTask();
_registrationTask.ReportNonFatalErrorUnlessCancelledAsync(_cancellationTokenSource.Token).CompletesAsyncOperation(asyncToken);
}
public void Dispose()
{
// We need to remove our file watch. We'll run that once the previous work has completed. We'll run only if the registration completed successfully, since cancellation
// means it never actually made it to the client, and fault would mean it never was actually created.
_cancellationTokenSource.Cancel();
var asyncToken = _changeWatcher._asynchronousOperationListener.BeginAsyncOperation(nameof(LspFileWatchRegistration) + "." + nameof(Dispose));
_registrationTask.ContinueWith(async _ =>
{
var unregistrationParams = new UnregistrationParams()
{
Unregistrations =
[
new Unregistration()
{
Id = _id,
Method = "workspace/didChangeWatchedFiles"
}
]
};
try
{
await _changeWatcher._clientLanguageServerManager.SendRequestAsync("client/unregisterCapability", unregistrationParams, CancellationToken.None);
}
catch (ConnectionLostException)
{
// It is very possible we are disposing of this when we're shutting down and the pipe has closed.
// There is no need to spam non fatal faults when this happens.
}
}, CancellationToken.None, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Default).Unwrap().ReportNonFatalErrorAsync().CompletesAsyncOperation(asyncToken);
}
}
}
|