diff --git a/src/Razor/benchmarks/Microsoft.AspNetCore.Razor.Microbenchmarks/LanguageServer/RazorLanguageServerBenchmarkBase.cs b/src/Razor/benchmarks/Microsoft.AspNetCore.Razor.Microbenchmarks/LanguageServer/RazorLanguageServerBenchmarkBase.cs index a156be288ea..3759705f138 100644 --- a/src/Razor/benchmarks/Microsoft.AspNetCore.Razor.Microbenchmarks/LanguageServer/RazorLanguageServerBenchmarkBase.cs +++ b/src/Razor/benchmarks/Microsoft.AspNetCore.Razor.Microbenchmarks/LanguageServer/RazorLanguageServerBenchmarkBase.cs @@ -35,7 +35,7 @@ public RazorLanguageServerBenchmarkBase() serverStream, razorLoggerFactory, NoOpTelemetryReporter.Instance, - configure: (collection) => + configureServices: (collection) => { collection.AddSingleton(); collection.AddSingleton(); diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CapabilitiesManager.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CapabilitiesManager.cs index ee72f4cbadd..46635c91ffb 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CapabilitiesManager.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CapabilitiesManager.cs @@ -2,19 +2,26 @@ // Licensed under the MIT license. See License.txt in the project root for license information. using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Threading; +using Microsoft.CodeAnalysis.Razor; using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.CodeAnalysis.Razor.Workspaces; using Microsoft.CommonLanguageServerProtocol.Framework; using Microsoft.VisualStudio.LanguageServer.Protocol; +using Microsoft.VisualStudio.RpcContracts.Settings; +using Microsoft.VisualStudio.Threading; namespace Microsoft.AspNetCore.Razor.LanguageServer; -internal class CapabilitiesManager : IInitializeManager, IClientCapabilitiesService +internal sealed class CapabilitiesManager : IInitializeManager, IClientCapabilitiesService, IWorkspaceRootPathProvider { - private InitializeParams? _initializeParams; private readonly ILspServices _lspServices; + private readonly TaskCompletionSource _initializeParamsTaskSource; + private readonly AsyncLazy _lazyRootPath; - public bool HasInitialized => _initializeParams is not null; + public bool HasInitialized => _initializeParamsTaskSource.Task.IsCompleted; public bool CanGetClientCapabilities => HasInitialized; @@ -23,16 +30,17 @@ internal class CapabilitiesManager : IInitializeManager ComputeRootPathAsync() + { +#pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks + var initializeParams = await _initializeParamsTaskSource.Task.ConfigureAwaitRunInline(); +#pragma warning restore VSTHRD003 // Avoid awaiting foreign Tasks + + if (initializeParams.RootUri is Uri rootUri) + { + return rootUri.GetAbsoluteOrUNCPath(); + } + + // RootUri was added in LSP3, fall back to RootPath + +#pragma warning disable CS0618 // Type or member is obsolete + return initializeParams.RootPath.AssumeNotNull(); +#pragma warning restore CS0618 // Type or member is obsolete } + + public Task GetRootPathAsync(CancellationToken cancellationToken) + => _lazyRootPath.GetValueAsync(cancellationToken); } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/DefaultLanguageServerFeatureOptions.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/DefaultLanguageServerFeatureOptions.cs index 6d678af65c6..e1385602245 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/DefaultLanguageServerFeatureOptions.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/DefaultLanguageServerFeatureOptions.cs @@ -38,13 +38,9 @@ public override bool ReturnCodeActionAndRenamePathsWithPrefixedSlash public override bool UsePreciseSemanticTokenRanges => false; - public override bool MonitorWorkspaceFolderForConfigurationFiles => true; - public override bool UseRazorCohostServer => false; public override bool DisableRazorLanguageServer => false; public override bool ForceRuntimeCodeGeneration => false; - - public override bool UseProjectConfigurationEndpoint => false; } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/DefaultWorkspaceDirectoryPathResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/DefaultWorkspaceDirectoryPathResolver.cs deleted file mode 100644 index 41fea8e688c..00000000000 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/DefaultWorkspaceDirectoryPathResolver.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System; -using Microsoft.CodeAnalysis.Razor; -using Microsoft.CommonLanguageServerProtocol.Framework; -using Microsoft.VisualStudio.LanguageServer.Protocol; - -namespace Microsoft.AspNetCore.Razor.LanguageServer; - -internal class DefaultWorkspaceDirectoryPathResolver : WorkspaceDirectoryPathResolver -{ - private readonly IInitializeManager _settingsManager; - - public DefaultWorkspaceDirectoryPathResolver(IInitializeManager settingsManager) - { - if (settingsManager is null) - { - throw new ArgumentNullException(nameof(settingsManager)); - } - - _settingsManager = settingsManager; - } - - public override string Resolve() - { - var clientSettings = _settingsManager.GetInitializeParams(); - if (clientSettings.RootUri is null) - { -#pragma warning disable CS0618 // Type or member is obsolete - Assumes.NotNull(clientSettings.RootPath); - // RootUri was added in LSP3, fallback to RootPath - return clientSettings.RootPath; -#pragma warning restore CS0618 // Type or member is obsolete - } - - var normalized = clientSettings.RootUri.GetAbsoluteOrUNCPath(); - return normalized; - } -} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Diagnostics/RazorDiagnosticsPublisher.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Diagnostics/RazorDiagnosticsPublisher.cs index bd92d5ed7de..d6bb5beb5c1 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Diagnostics/RazorDiagnosticsPublisher.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Diagnostics/RazorDiagnosticsPublisher.cs @@ -83,6 +83,11 @@ protected RazorDiagnosticsPublisher( public void Dispose() { + if (_disposeTokenSource.IsCancellationRequested) + { + return; + } + _disposeTokenSource.Cancel(); _disposeTokenSource.Dispose(); } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Extensions/IServiceCollectionExtensions.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Extensions/IServiceCollectionExtensions.cs index e81052b54e4..d38a773f624 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Extensions/IServiceCollectionExtensions.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Extensions/IServiceCollectionExtensions.cs @@ -26,7 +26,6 @@ using Microsoft.CodeAnalysis.Razor.ProjectSystem; using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.CodeAnalysis.Razor.SemanticTokens; -using Microsoft.CodeAnalysis.Razor.Serialization; using Microsoft.CodeAnalysis.Razor.Workspaces; using Microsoft.CommonLanguageServerProtocol.Framework; using Microsoft.Extensions.DependencyInjection; @@ -48,6 +47,7 @@ public static void AddLifeCycleServices(this IServiceCollection services, RazorL services.AddSingleton(); services.AddSingleton, CapabilitiesManager>(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton, RazorRequestContextFactory>(); services.AddSingleton(); @@ -211,31 +211,13 @@ public static void AddDocumentManagementServices(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); + services.AddSingleton((services) => (RazorProjectService)services.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); - if (featureOptions.UseProjectConfigurationEndpoint) - { - services.AddSingleton(); - services.AddSingleton(); - } - else - { - services.AddSingleton(); - } - services.AddSingleton(); - - // If we're not monitoring the whole workspace folder for configuration changes, then we don't actually need the the file change - // detector wired up via DI, as the razor/monitorProjectConfigurationFilePath endpoint will directly construct one. This means - // it can be a little simpler, and doesn't need to worry about which folders it's told to listen to. - if (featureOptions.MonitorWorkspaceFolderForConfigurationFiles) - { - services.AddSingleton(); - } - services.AddSingleton(); // Document processed listeners diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Hosting/ConfigurableLanguageServerFeatureOptions.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Hosting/ConfigurableLanguageServerFeatureOptions.cs index 8f1e6f198ca..530d8e1fde3 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Hosting/ConfigurableLanguageServerFeatureOptions.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Hosting/ConfigurableLanguageServerFeatureOptions.cs @@ -22,11 +22,9 @@ internal class ConfigurableLanguageServerFeatureOptions : LanguageServerFeatureO private readonly bool? _usePreciseSemanticTokenRanges; private readonly bool? _updateBuffersForClosedDocuments; private readonly bool? _includeProjectKeyInGeneratedFilePath; - private readonly bool? _monitorWorkspaceFolderForConfigurationFiles; private readonly bool? _useRazorCohostServer; private readonly bool? _disableRazorLanguageServer; private readonly bool? _forceRuntimeCodeGeneration; - private readonly bool? _useProjectConfigurationEndpoint; public override bool SupportsFileManipulation => _supportsFileManipulation ?? _defaults.SupportsFileManipulation; public override string ProjectConfigurationFileName => _projectConfigurationFileName ?? _defaults.ProjectConfigurationFileName; @@ -40,11 +38,9 @@ internal class ConfigurableLanguageServerFeatureOptions : LanguageServerFeatureO public override bool UsePreciseSemanticTokenRanges => _usePreciseSemanticTokenRanges ?? _defaults.UsePreciseSemanticTokenRanges; public override bool UpdateBuffersForClosedDocuments => _updateBuffersForClosedDocuments ?? _defaults.UpdateBuffersForClosedDocuments; public override bool IncludeProjectKeyInGeneratedFilePath => _includeProjectKeyInGeneratedFilePath ?? _defaults.IncludeProjectKeyInGeneratedFilePath; - public override bool MonitorWorkspaceFolderForConfigurationFiles => _monitorWorkspaceFolderForConfigurationFiles ?? _defaults.MonitorWorkspaceFolderForConfigurationFiles; public override bool UseRazorCohostServer => _useRazorCohostServer ?? _defaults.UseRazorCohostServer; public override bool DisableRazorLanguageServer => _disableRazorLanguageServer ?? _defaults.DisableRazorLanguageServer; public override bool ForceRuntimeCodeGeneration => _forceRuntimeCodeGeneration ?? _defaults.ForceRuntimeCodeGeneration; - public override bool UseProjectConfigurationEndpoint => _useProjectConfigurationEndpoint ?? _defaults.UseProjectConfigurationEndpoint; public ConfigurableLanguageServerFeatureOptions(string[] args) { @@ -67,11 +63,9 @@ public ConfigurableLanguageServerFeatureOptions(string[] args) TryProcessBoolOption(nameof(UsePreciseSemanticTokenRanges), ref _usePreciseSemanticTokenRanges, option, args, i); TryProcessBoolOption(nameof(UpdateBuffersForClosedDocuments), ref _updateBuffersForClosedDocuments, option, args, i); TryProcessBoolOption(nameof(IncludeProjectKeyInGeneratedFilePath), ref _includeProjectKeyInGeneratedFilePath, option, args, i); - TryProcessBoolOption(nameof(MonitorWorkspaceFolderForConfigurationFiles), ref _monitorWorkspaceFolderForConfigurationFiles, option, args, i); TryProcessBoolOption(nameof(UseRazorCohostServer), ref _useRazorCohostServer, option, args, i); TryProcessBoolOption(nameof(DisableRazorLanguageServer), ref _disableRazorLanguageServer, option, args, i); TryProcessBoolOption(nameof(ForceRuntimeCodeGeneration), ref _forceRuntimeCodeGeneration, option, args, i); - TryProcessBoolOption(nameof(UseProjectConfigurationEndpoint), ref _useProjectConfigurationEndpoint, option, args, i); } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Hosting/RazorLanguageServerHost.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Hosting/RazorLanguageServerHost.cs index ce2cfb29766..dc24da6e8a9 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Hosting/RazorLanguageServerHost.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Hosting/RazorLanguageServerHost.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Telemetry; using Microsoft.CodeAnalysis.Razor.Logging; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; using Microsoft.CodeAnalysis.Razor.Workspaces; using Microsoft.Extensions.DependencyInjection; using Microsoft.VisualStudio.LanguageServer.Protocol; @@ -42,10 +43,11 @@ public static RazorLanguageServerHost Create( Stream output, ILoggerFactory loggerFactory, ITelemetryReporter telemetryReporter, - Action? configure = null, + Action? configureServices = null, LanguageServerFeatureOptions? featureOptions = null, RazorLSPOptions? razorLSPOptions = null, ILspServerActivationTracker? lspServerActivationTracker = null, + IRazorProjectInfoDriver? projectInfoDriver = null, TraceSource? traceSource = null) { var (jsonRpc, jsonSerializer) = CreateJsonRpc(input, output); @@ -61,9 +63,10 @@ public static RazorLanguageServerHost Create( jsonSerializer, loggerFactory, featureOptions, - configure, + configureServices, razorLSPOptions, lspServerActivationTracker, + projectInfoDriver, telemetryReporter); var host = new RazorLanguageServerHost(server); diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/IProjectFileChangeListener.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/IProjectFileChangeListener.cs deleted file mode 100644 index cbd57d8dac1..00000000000 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/IProjectFileChangeListener.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using Microsoft.AspNetCore.Razor.LanguageServer.Common; - -namespace Microsoft.AspNetCore.Razor.LanguageServer; - -internal interface IProjectFileChangeListener -{ - void ProjectFileChanged(string filePath, RazorFileChangeKind kind); -} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/IProjectConfigurationFileChangeListener.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/IWorkspaceRootPathProvider.cs similarity index 55% rename from src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/IProjectConfigurationFileChangeListener.cs rename to src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/IWorkspaceRootPathProvider.cs index 8dd5892d369..eff10da1cb8 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/IProjectConfigurationFileChangeListener.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/IWorkspaceRootPathProvider.cs @@ -1,9 +1,12 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT license. See License.txt in the project root for license information. +using System.Threading; +using System.Threading.Tasks; + namespace Microsoft.AspNetCore.Razor.LanguageServer; -internal interface IProjectConfigurationFileChangeListener +internal interface IWorkspaceRootPathProvider { - void ProjectConfigurationFileChanged(ProjectConfigurationFileChangeEventArgs args); + Task GetRootPathAsync(CancellationToken cancellationToken); } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/OpenDocumentGenerator.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/OpenDocumentGenerator.cs index 27226f8bafb..133b5078afc 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/OpenDocumentGenerator.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/OpenDocumentGenerator.cs @@ -57,6 +57,11 @@ public OpenDocumentGenerator( public void Dispose() { + if (_disposeTokenSource.IsCancellationRequested) + { + return; + } + _disposeTokenSource.Cancel(); _disposeTokenSource.Dispose(); } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationFileChangeDetector.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationFileChangeDetector.cs deleted file mode 100644 index f91f0f93913..00000000000 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationFileChangeDetector.cs +++ /dev/null @@ -1,145 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor.LanguageServer.Common; -using Microsoft.AspNetCore.Razor.Utilities; -using Microsoft.CodeAnalysis.Razor; -using Microsoft.CodeAnalysis.Razor.Logging; -using Microsoft.CodeAnalysis.Razor.Workspaces; - -namespace Microsoft.AspNetCore.Razor.LanguageServer; - -internal class ProjectConfigurationFileChangeDetector( - IEnumerable listeners, - LanguageServerFeatureOptions options, - ILoggerFactory loggerFactory) : IFileChangeDetector -{ - private static readonly ImmutableArray s_ignoredDirectories = - [ - "node_modules", - "bin", - ".vs", - ]; - - private readonly ImmutableArray _listeners = listeners.ToImmutableArray(); - private readonly LanguageServerFeatureOptions _options = options; - private readonly ILogger _logger = loggerFactory.GetOrCreateLogger(); - - private FileSystemWatcher? _watcher; - - public Task StartAsync(string workspaceDirectory, CancellationToken cancellationToken) - { - // Dive through existing project configuration files and fabricate "added" events so listeners can accurately listen to state changes for them. - - workspaceDirectory = FilePathNormalizer.Normalize(workspaceDirectory); - var existingConfigurationFiles = GetExistingConfigurationFiles(workspaceDirectory); - - _logger.LogDebug($"Triggering events for existing project configuration files"); - - foreach (var configurationFilePath in existingConfigurationFiles) - { - NotifyListeners(new(configurationFilePath, RazorFileChangeKind.Added)); - } - - // This is an entry point for testing - if (!InitializeFileWatchers) - { - return Task.CompletedTask; - } - - try - { - // FileSystemWatcher will throw if the folder we want to watch doesn't exist yet. - if (!Directory.Exists(workspaceDirectory)) - { - _logger.LogInformation($"Workspace directory '{workspaceDirectory}' does not exist yet, so Razor is going to create it."); - Directory.CreateDirectory(workspaceDirectory); - } - } - catch (Exception ex) - { - // Directory.Exists will throw on things like long paths - _logger.LogError(ex, $"Failed validating that file watcher would be successful for '{workspaceDirectory}'"); - - // No point continuing because the FileSystemWatcher constructor would just throw too. - return Task.FromException(ex); - } - - if (cancellationToken.IsCancellationRequested) - { - // Client cancelled connection, no need to setup any file watchers. Server is about to tear down. - return Task.FromCanceled(cancellationToken); - } - - _logger.LogInformation($"Starting configuration file change detector for '{workspaceDirectory}'"); - _watcher = new RazorFileSystemWatcher(workspaceDirectory, _options.ProjectConfigurationFileName) - { - NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime, - IncludeSubdirectories = true, - }; - - _watcher.Created += (sender, args) => NotifyListeners(args.FullPath, RazorFileChangeKind.Added); - _watcher.Deleted += (sender, args) => NotifyListeners(args.FullPath, RazorFileChangeKind.Removed); - _watcher.Changed += (sender, args) => NotifyListeners(args.FullPath, RazorFileChangeKind.Changed); - _watcher.Renamed += (sender, args) => - { - // Translate file renames into remove / add - - if (args.OldFullPath.EndsWith(_options.ProjectConfigurationFileName, FilePathComparison.Instance)) - { - // Renaming from project configuration file to something else. Just remove the configuration file. - NotifyListeners(args.OldFullPath, RazorFileChangeKind.Removed); - } - else if (args.FullPath.EndsWith(_options.ProjectConfigurationFileName, FilePathComparison.Instance)) - { - // Renaming from a non-project configuration file file to a real one. Just add the configuration file. - NotifyListeners(args.FullPath, RazorFileChangeKind.Added); - } - }; - - _watcher.EnableRaisingEvents = true; - - return Task.CompletedTask; - } - - public void Stop() - { - // We're relying on callers to synchronize start/stops so we don't need to ensure one happens before the other. - - _watcher?.Dispose(); - _watcher = null; - } - - // Protected virtual for testing - protected virtual bool InitializeFileWatchers => true; - - // Protected virtual for testing - protected virtual ImmutableArray GetExistingConfigurationFiles(string workspaceDirectory) - { - return DirectoryHelper.GetFilteredFiles( - workspaceDirectory, - _options.ProjectConfigurationFileName, - s_ignoredDirectories, - logger: _logger).ToImmutableArray(); - } - - private void NotifyListeners(string physicalFilePath, RazorFileChangeKind kind) - { - NotifyListeners(new(physicalFilePath, kind)); - } - - private void NotifyListeners(ProjectConfigurationFileChangeEventArgs args) - { - foreach (var listener in _listeners) - { - _logger.LogDebug($"Notifying listener '{listener}' of config file path '{args.ConfigurationFilePath}' change with kind '{args.Kind}'"); - listener.ProjectConfigurationFileChanged(args); - } - } -} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationFileChangeEventArgs.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationFileChangeEventArgs.cs deleted file mode 100644 index 626abb642b2..00000000000 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationFileChangeEventArgs.cs +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System; -using System.Diagnostics.CodeAnalysis; -using Microsoft.AspNetCore.Razor.LanguageServer.Common; -using Microsoft.AspNetCore.Razor.LanguageServer.Serialization; -using Microsoft.AspNetCore.Razor.ProjectSystem; -using Microsoft.AspNetCore.Razor.Utilities; -using Microsoft.CodeAnalysis.Razor; -using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Razor.Workspaces; - -namespace Microsoft.AspNetCore.Razor.LanguageServer; - -internal sealed class ProjectConfigurationFileChangeEventArgs( - string configurationFilePath, - RazorFileChangeKind kind, - IRazorProjectInfoDeserializer? deserializer = null) : EventArgs -{ - public string ConfigurationFilePath { get; } = configurationFilePath; - public RazorFileChangeKind Kind { get; } = kind; - - private readonly IRazorProjectInfoDeserializer _deserializer = deserializer ?? RazorProjectInfoDeserializer.Instance; - private RazorProjectInfo? _projectInfo; - private readonly object _gate = new(); - private bool _deserialized; - - public bool TryDeserialize(LanguageServerFeatureOptions options, [NotNullWhen(true)] out RazorProjectInfo? projectInfo) - { - if (Kind == RazorFileChangeKind.Removed) - { - // There's no file to represent the snapshot handle. - projectInfo = null; - return false; - } - - lock (_gate) - { - if (!_deserialized) - { - // We use a deserialized flag instead of checking if _projectSnapshotHandle is null because if we're reading an old snapshot - // handle that doesn't deserialize properly it could be expected that it would be null. - _deserialized = true; - var deserializedProjectInfo = _deserializer.DeserializeFromFile(ConfigurationFilePath); - if (deserializedProjectInfo is null) - { - projectInfo = null; - return false; - } - - if (FilePathNormalizer.AreDirectoryPathsEquivalent(deserializedProjectInfo.ProjectKey.Id, ConfigurationFilePath)) - { - // Modify the feature flags on the configuration before storing - deserializedProjectInfo = deserializedProjectInfo with - { - Configuration = deserializedProjectInfo.Configuration with - { - LanguageServerFlags = options.ToLanguageServerFlags() - } - }; - - _projectInfo = deserializedProjectInfo; - } - else - { - // Stale project configuration file, most likely a user copy & pasted the project configuration file and it hasn't - // been re-computed yet. Fail deserialization. - projectInfo = null; - return false; - } - } - - projectInfo = _projectInfo; - } - - return projectInfo is not null; - } - - internal ProjectKey GetProjectKey() - { - var intermediateOutputPath = FilePathNormalizer.GetNormalizedDirectoryName(ConfigurationFilePath); - return new ProjectKey(intermediateOutputPath); - } -} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.Comparer.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.Comparer.cs deleted file mode 100644 index 5300496aa69..00000000000 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.Comparer.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System.Collections.Generic; -using Microsoft.CodeAnalysis.Razor; -using Microsoft.Extensions.Internal; - -namespace Microsoft.AspNetCore.Razor.LanguageServer; - -internal partial class ProjectConfigurationStateSynchronizer -{ - private sealed class Comparer : IEqualityComparer - { - public static readonly Comparer Instance = new(); - - private Comparer() - { - } - - public bool Equals(Work? x, Work? y) - => (x, y) switch - { - (Work(var keyX), Work(var keyY)) => keyX == keyY, - (null, null) => true, - - _ => false - }; - - public int GetHashCode(Work obj) - => obj.ProjectKey.GetHashCode(); - } -} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.cs deleted file mode 100644 index eae4ecc0393..00000000000 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.cs +++ /dev/null @@ -1,174 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Diagnostics; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor.LanguageServer.Common; -using Microsoft.AspNetCore.Razor.LanguageServer.ProjectSystem; -using Microsoft.AspNetCore.Razor.PooledObjects; -using Microsoft.AspNetCore.Razor.ProjectSystem; -using Microsoft.AspNetCore.Razor.Utilities; -using Microsoft.CodeAnalysis.Razor.Logging; -using Microsoft.CodeAnalysis.Razor.Utilities; -using Microsoft.CodeAnalysis.Razor.Workspaces; - -namespace Microsoft.AspNetCore.Razor.LanguageServer; - -internal partial class ProjectConfigurationStateSynchronizer : IProjectConfigurationFileChangeListener, IDisposable -{ - private abstract record Work(ProjectKey ProjectKey); - private sealed record ResetProject(ProjectKey ProjectKey) : Work(ProjectKey); - private sealed record UpdateProject(ProjectKey ProjectKey, RazorProjectInfo ProjectInfo) : Work(ProjectKey); - - private static readonly TimeSpan s_delay = TimeSpan.FromMilliseconds(250); - - private readonly IRazorProjectService _projectService; - private readonly LanguageServerFeatureOptions _options; - private readonly ILogger _logger; - - private readonly CancellationTokenSource _disposeTokenSource; - private readonly AsyncBatchingWorkQueue _workQueue; - - private readonly Dictionary _resetProjectMap = new(); - - public ProjectConfigurationStateSynchronizer( - IRazorProjectService projectService, - ILoggerFactory loggerFactory, - LanguageServerFeatureOptions options) - : this(projectService, loggerFactory, options, s_delay) - { - } - - protected ProjectConfigurationStateSynchronizer( - IRazorProjectService projectService, - ILoggerFactory loggerFactory, - LanguageServerFeatureOptions options, - TimeSpan delay) - { - _projectService = projectService; - _options = options; - _logger = loggerFactory.GetOrCreateLogger(); - - _disposeTokenSource = new(); - _workQueue = new(delay, ProcessBatchAsync, _disposeTokenSource.Token); - } - - public void Dispose() - { - _disposeTokenSource.Cancel(); - _disposeTokenSource.Dispose(); - } - - private async ValueTask ProcessBatchAsync(ImmutableArray items, CancellationToken token) - { - foreach (var item in items.GetMostRecentUniqueItems(Comparer.Instance)) - { - if (token.IsCancellationRequested) - { - return; - } - - var itemTask = item switch - { - ResetProject(var projectKey) => ResetProjectAsync(projectKey, token), - UpdateProject(var projectKey, var projectInfo) => UpdateProjectAsync(projectKey, projectInfo, token), - _ => Assumed.Unreachable() - }; - - await itemTask.ConfigureAwait(false); - } - - Task ResetProjectAsync(ProjectKey projectKey, CancellationToken token) - { - _logger.LogInformation($"Resetting {projectKey.Id}."); - - return _projectService - .UpdateProjectAsync( - projectKey, - configuration: null, - rootNamespace: null, - displayName: "", - ProjectWorkspaceState.Default, - documents: [], - token); - } - - Task UpdateProjectAsync(ProjectKey projectKey, RazorProjectInfo projectInfo, CancellationToken token) - { - _logger.LogInformation($"Updating {projectKey.Id}."); - - return _projectService - .AddOrUpdateProjectAsync( - projectKey, - projectInfo.FilePath, - projectInfo.Configuration, - projectInfo.RootNamespace, - projectInfo.DisplayName, - projectInfo.ProjectWorkspaceState, - projectInfo.Documents, - token); - } - } - - public void ProjectConfigurationFileChanged(ProjectConfigurationFileChangeEventArgs args) - { - switch (args.Kind) - { - case RazorFileChangeKind.Changed: - { - if (args.TryDeserialize(_options, out var projectInfo)) - { - var projectKey = projectInfo.ProjectKey; - _logger.LogInformation($"Configuration file changed for project '{projectKey.Id}'."); - - _workQueue.AddWork(new UpdateProject(projectKey, projectInfo)); - } - else - { - var projectKey = args.GetProjectKey(); - _logger.LogWarning($"Failed to deserialize after change to configuration file for project '{projectKey.Id}'."); - - // We found the last associated project file for the configuration file. Reset the project since we can't - // accurately determine its configurations. - _workQueue.AddWork(new ResetProject(projectKey)); - } - } - - break; - - case RazorFileChangeKind.Added: - { - if (args.TryDeserialize(_options, out var projectInfo)) - { - var projectKey = projectInfo.ProjectKey; - _logger.LogInformation($"Configuration file added for project '{projectKey.Id}'."); - - // Update will add the project if it doesn't exist - _workQueue.AddWork(new UpdateProject(projectKey, projectInfo)); - } - else - { - // This is the first time we've seen this configuration file, but we can't deserialize it. - // The only thing we can really do is issue a warning. - _logger.LogWarning($"Failed to deserialize previously unseen configuration file '{args.ConfigurationFilePath}'"); - } - } - - break; - - case RazorFileChangeKind.Removed: - { - var projectKey = args.GetProjectKey(); - _logger.LogInformation($"Configuration file removed for project '{projectKey}'."); - - _workQueue.AddWork(new ResetProject(projectKey)); - } - - break; - } - } -} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/FileWatcherBasedRazorProjectInfoDriver.Comparer.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/FileWatcherBasedRazorProjectInfoDriver.Comparer.cs new file mode 100644 index 00000000000..86ace844701 --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/FileWatcherBasedRazorProjectInfoDriver.Comparer.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.CodeAnalysis.Razor; + +namespace Microsoft.AspNetCore.Razor.LanguageServer.ProjectSystem; + +internal partial class FileWatcherBasedRazorProjectInfoDriver +{ + private sealed class Comparer : IEqualityComparer<(string FilePath, ChangeKind Kind)> + { + public static readonly Comparer Instance = new(); + + private Comparer() + { + } + + public bool Equals((string FilePath, ChangeKind Kind) x, (string FilePath, ChangeKind Kind) y) + { + // We just want the most recent change to each file path. It's ok if there's a remove followed by an add, + // or an add followed by a remove for the same path. + return FilePathComparer.Instance.Equals(x.FilePath, y.FilePath); + } + + public int GetHashCode((string FilePath, ChangeKind Kind) obj) + { + return FilePathComparer.Instance.GetHashCode(obj.FilePath); + } + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/FileWatcherBasedRazorProjectInfoDriver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/FileWatcherBasedRazorProjectInfoDriver.cs new file mode 100644 index 00000000000..3fb31f160aa --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/FileWatcherBasedRazorProjectInfoDriver.cs @@ -0,0 +1,177 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.LanguageServer.Common; +using Microsoft.AspNetCore.Razor.ProjectSystem; +using Microsoft.AspNetCore.Razor.Utilities; +using Microsoft.CodeAnalysis.Razor; +using Microsoft.CodeAnalysis.Razor.Logging; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; +using Microsoft.CodeAnalysis.Razor.Utilities; +using Microsoft.CodeAnalysis.Razor.Workspaces; + +namespace Microsoft.AspNetCore.Razor.LanguageServer.ProjectSystem; + +internal partial class FileWatcherBasedRazorProjectInfoDriver : AbstractRazorProjectInfoDriver +{ + private enum ChangeKind { AddOrUpdate, Remove } + + private static readonly ImmutableArray s_ignoredDirectories = + [ + "node_modules", + "bin", + ".vs", + ]; + + private readonly IWorkspaceRootPathProvider _workspaceRootPathProvider; + private readonly LanguageServerFeatureOptions _options; + private readonly ILogger _logger; + + private readonly AsyncBatchingWorkQueue<(string FilePath, ChangeKind Kind)> _workQueue; + + private FileSystemWatcher? _fileSystemWatcher; + + public FileWatcherBasedRazorProjectInfoDriver( + IWorkspaceRootPathProvider workspaceRootPathProvider, + LanguageServerFeatureOptions options, + ILoggerFactory loggerFactory, + TimeSpan? delay = null) + : base(loggerFactory, delay) + { + _workspaceRootPathProvider = workspaceRootPathProvider; + _options = options; + _logger = loggerFactory.GetOrCreateLogger(); + + _workQueue = new(delay ?? DefaultDelay, ProcessBatchAsync, DisposalToken); + + // Dispose our FileSystemWatcher when the driver is disposed. + DisposalToken.Register(() => + { + _fileSystemWatcher?.Dispose(); + _fileSystemWatcher = null; + }); + + StartInitialization(); + } + + protected override async Task InitializeAsync(CancellationToken cancellationToken) + { + var workspaceDirectoryPath = await _workspaceRootPathProvider.GetRootPathAsync(cancellationToken).ConfigureAwait(false); + workspaceDirectoryPath = FilePathNormalizer.Normalize(workspaceDirectoryPath); + + var existingConfigurationFiles = DirectoryHelper.GetFilteredFiles( + workspaceDirectoryPath, + _options.ProjectConfigurationFileName, + s_ignoredDirectories, + logger: _logger).ToImmutableArray(); + + foreach (var filePath in existingConfigurationFiles) + { + var razorProjectInfo = await TryDeserializeAsync(filePath, cancellationToken).ConfigureAwait(false); + + if (razorProjectInfo is not null) + { + EnqueueUpdate(razorProjectInfo); + } + } + + if (!Directory.Exists(workspaceDirectoryPath)) + { + _logger.LogInformation($"Creating workspace directory: '{workspaceDirectoryPath}'"); + Directory.CreateDirectory(workspaceDirectoryPath); + } + + _logger.LogInformation($"Starting {nameof(FileWatcherBasedRazorProjectInfoDriver)}: '{workspaceDirectoryPath}'"); + + _fileSystemWatcher = new RazorFileSystemWatcher(workspaceDirectoryPath, _options.ProjectConfigurationFileName) + { + NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime, + IncludeSubdirectories = true, + }; + + _fileSystemWatcher.Created += (_, args) => EnqueueAddOrChange(args.FullPath); + _fileSystemWatcher.Changed += (_, args) => EnqueueAddOrChange(args.FullPath); + _fileSystemWatcher.Deleted += (_, args) => EnqueueRemove(args.FullPath); + _fileSystemWatcher.Renamed += (_, args) => EnqueueRename(args.OldFullPath, args.FullPath); + + _fileSystemWatcher.EnableRaisingEvents = true; + + void EnqueueAddOrChange(string filePath) + { + _workQueue.AddWork((filePath, ChangeKind.AddOrUpdate)); + } + + void EnqueueRemove(string filePath) + { + _workQueue.AddWork((filePath, ChangeKind.Remove)); + } + + void EnqueueRename(string oldFilePath, string newFilePath) + { + EnqueueRemove(oldFilePath); + EnqueueAddOrChange(newFilePath); + } + } + + private async ValueTask ProcessBatchAsync(ImmutableArray<(string FilePath, ChangeKind Kind)> items, CancellationToken token) + { + foreach (var (filePath, changeKind) in items.GetMostRecentUniqueItems(Comparer.Instance)) + { + if (token.IsCancellationRequested) + { + return; + } + + switch (changeKind) + { + case ChangeKind.AddOrUpdate: + var razorProjectInfo = await TryDeserializeAsync(filePath, token).ConfigureAwait(false); + + if (razorProjectInfo is not null) + { + EnqueueUpdate(razorProjectInfo); + } + + break; + + case ChangeKind.Remove: + // The file path is located in the directory used as the ProjectKey. + var normalizedDirectory = FilePathNormalizer.GetNormalizedDirectoryName(filePath); + var projectKey = new ProjectKey(normalizedDirectory); + + EnqueueRemove(projectKey); + + break; + + default: + Assumed.Unreachable(); + break; + } + } + } + + private async ValueTask TryDeserializeAsync(string filePath, CancellationToken cancellationToken) + { + try + { + using var stream = File.OpenRead(filePath); + + return await RazorProjectInfo + .DeserializeFromAsync(stream, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogError(ex, $"Error occurred while reading and deserializing '{filePath}'"); + } + + return null; + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/MonitorProjectConfigurationFilePathEndpoint.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/MonitorProjectConfigurationFilePathEndpoint.cs deleted file mode 100644 index f64bf7b13f4..00000000000 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/MonitorProjectConfigurationFilePathEndpoint.cs +++ /dev/null @@ -1,215 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts; -using Microsoft.AspNetCore.Razor.ProjectSystem; -using Microsoft.AspNetCore.Razor.Utilities; -using Microsoft.CodeAnalysis.Razor; -using Microsoft.CodeAnalysis.Razor.Logging; -using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Razor.Workspaces; -using Microsoft.CodeAnalysis.Razor.Workspaces.Protocol.ProjectSystem; - -namespace Microsoft.AspNetCore.Razor.LanguageServer.ProjectSystem; - -[RazorLanguageServerEndpoint(LanguageServerConstants.RazorMonitorProjectConfigurationFilePathEndpoint)] -internal class MonitorProjectConfigurationFilePathEndpoint : IRazorNotificationHandler, IDisposable -{ - private readonly IProjectSnapshotManager _projectManager; - private readonly WorkspaceDirectoryPathResolver _workspaceDirectoryPathResolver; - private readonly IEnumerable _listeners; - private readonly LanguageServerFeatureOptions _options; - private readonly ILoggerFactory _loggerFactory; - private readonly ILogger _logger; - private readonly ConcurrentDictionary _outputPathMonitors; - private readonly object _disposeLock; - private bool _disposed; - - public bool MutatesSolutionState => false; - - public MonitorProjectConfigurationFilePathEndpoint( - IProjectSnapshotManager projectManager, - WorkspaceDirectoryPathResolver workspaceDirectoryPathResolver, - IEnumerable listeners, - LanguageServerFeatureOptions options, - ILoggerFactory loggerFactory) - { - _projectManager = projectManager; - _workspaceDirectoryPathResolver = workspaceDirectoryPathResolver; - _listeners = listeners; - _options = options; - _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); - _logger = loggerFactory.GetOrCreateLogger(); - _outputPathMonitors = new ConcurrentDictionary(FilePathComparer.Instance); - _disposeLock = new object(); - } - - public async Task HandleNotificationAsync(MonitorProjectConfigurationFilePathParams request, RazorRequestContext requestContext, CancellationToken cancellationToken) - { - if (request is null) - { - throw new ArgumentNullException(nameof(request)); - } - - lock (_disposeLock) - { - if (_disposed) - { - return; - } - } - - if (request.ConfigurationFilePath is null) - { - _logger.LogInformation($"'null' configuration path provided. Stopping custom configuration monitoring for project '{request.ProjectKeyId}'."); - // If we're monitoring individual project configuration files, then the config file path should only be null if we're removing the - // project entirely. - await RemoveMonitorAsync(request.ProjectKeyId, removeProject: !_options.MonitorWorkspaceFolderForConfigurationFiles, cancellationToken).ConfigureAwait(false); - - return; - } - - if (!request.ConfigurationFilePath.EndsWith(_options.ProjectConfigurationFileName, StringComparison.Ordinal)) - { - _logger.LogError($"Invalid configuration file path provided for project '{request.ProjectKeyId}': '{request.ConfigurationFilePath}'"); - return; - } - - var configurationDirectory = Path.GetDirectoryName(request.ConfigurationFilePath); - Assumes.NotNull(configurationDirectory); - - var previousMonitorExists = _outputPathMonitors.TryGetValue(request.ProjectKeyId, out var entry); - - if (_options.MonitorWorkspaceFolderForConfigurationFiles) - { - var normalizedConfigurationDirectory = FilePathNormalizer.NormalizeDirectory(configurationDirectory); - var workspaceDirectory = _workspaceDirectoryPathResolver.Resolve(); - var normalizedWorkspaceDirectory = FilePathNormalizer.NormalizeDirectory(workspaceDirectory); - - if (normalizedConfigurationDirectory.StartsWith(normalizedWorkspaceDirectory, FilePathComparison.Instance)) - { - if (previousMonitorExists) - { - _logger.LogInformation($"Configuration directory changed from external directory -> internal directory for project '{request.ProjectKeyId}, terminating existing monitor'."); - await RemoveMonitorAsync(request.ProjectKeyId, removeProject: false, cancellationToken).ConfigureAwait(false); - } - else - { - _logger.LogInformation($"No custom configuration directory required. The workspace directory is sufficient for '{ request.ProjectKeyId}'."); - } - - // Configuration directory is already in the workspace directory. We already monitor everything in the workspace directory. - return; - } - } - - if (previousMonitorExists) - { - if (FilePathComparer.Instance.Equals(configurationDirectory, entry.ConfigurationDirectory)) - { - _logger.LogInformation($"Already tracking configuration directory for project '{request.ProjectKeyId}'."); - - // Already tracking the requested configuration output path for this project - return; - } - - _logger.LogInformation($"Project configuration output path has changed. Stopping existing monitor for project '{request.ProjectKeyId}' so we can restart it with a new directory."); - await RemoveMonitorAsync(request.ProjectKeyId, removeProject: false, cancellationToken).ConfigureAwait(false); - } - - var detector = CreateFileChangeDetector(); - entry = (configurationDirectory, detector); - - if (!_outputPathMonitors.TryAdd(request.ProjectKeyId, entry)) - { - // There's a concurrent request going on for this specific project. To avoid calling "StartAsync" twice we return early. - // Note: This is an extremely edge case race condition that should in practice never happen due to how long it takes to calculate project state changes - return; - } - - _logger.LogInformation($"Starting new configuration monitor for project '{request.ProjectKeyId}' for directory '{configurationDirectory}'."); - await entry.Detector.StartAsync(configurationDirectory, cancellationToken).ConfigureAwait(false); - - if (cancellationToken.IsCancellationRequested) - { - // Request was cancelled while starting the detector. Need to stop it so we don't leak. - entry.Detector.Stop(); - return; - } - - if (!_outputPathMonitors.ContainsKey(request.ProjectKeyId)) - { - // This can happen if there were multiple concurrent requests to "remove" and "update" file change detectors for the same project path. - // In that case we need to stop the detector to ensure we don't leak. - entry.Detector.Stop(); - return; - } - - lock (_disposeLock) - { - if (_disposed) - { - // Server's being stopped. - entry.Detector.Stop(); - } - } - } - - private Task RemoveMonitorAsync(string projectKeyId, bool removeProject, CancellationToken cancellationToken) - { - // Should no longer monitor configuration output paths for the project - if (_outputPathMonitors.TryRemove(projectKeyId, out var removedEntry)) - { - removedEntry.Detector.Stop(); - } - else - { - // Concurrent requests to remove the same configuration output path for the project. We've already - // done the removal so we can just return gracefully. - } - - if (removeProject) - { - return _projectManager.UpdateAsync( - (updater, projectKey) => - { - updater.ProjectRemoved(projectKey); - }, - state: new ProjectKey(projectKeyId), - cancellationToken); - } - - return Task.CompletedTask; - } - - public void Dispose() - { - lock (_disposeLock) - { - if (_disposed) - { - return; - } - - _disposed = true; - } - - foreach (var entry in _outputPathMonitors) - { - entry.Value.Detector.Stop(); - } - } - - // Protected virtual for testing - protected virtual IFileChangeDetector CreateFileChangeDetector() - => new ProjectConfigurationFileChangeDetector( - _listeners, - _options, - _loggerFactory); -} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/ProjectConfigurationStateManager.Comparer.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/ProjectConfigurationStateManager.Comparer.cs deleted file mode 100644 index 6d9f9ee3ced..00000000000 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/ProjectConfigurationStateManager.Comparer.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System.Collections.Generic; -using Microsoft.AspNetCore.Razor.ProjectSystem; - -namespace Microsoft.AspNetCore.Razor.LanguageServer.ProjectSystem; - -internal partial class ProjectConfigurationStateManager -{ - /// - /// Compares two work items from project state manager work queue. - /// - /// - /// Project updates and project removal are treated as equivalent since project removal - /// work item should supersede any project updates in the queue. Project additions are not - /// placed in the queue so project removal would never supersede/overwrite project addition - /// (which would've resulted in us removing a project we never added). - /// - private sealed class Comparer : IEqualityComparer<(ProjectKey ProjectKey, RazorProjectInfo? ProjectInfo)> - { - public static readonly Comparer Instance = new(); - - private Comparer() - { - } - - public bool Equals((ProjectKey ProjectKey, RazorProjectInfo? ProjectInfo) x, (ProjectKey ProjectKey, RazorProjectInfo? ProjectInfo) y) - { - // Project removal should replace project update so treat Removal and non-Removal - // of the same ProjectKey as equivalent work item - return x.ProjectKey.Equals(y.ProjectKey); - } - - public int GetHashCode((ProjectKey ProjectKey, RazorProjectInfo? ProjectInfo) obj) - { - var (projectKey, _) = obj; - - return projectKey.GetHashCode(); - } - } -} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/ProjectConfigurationStateManager.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/ProjectConfigurationStateManager.cs deleted file mode 100644 index 984da988af0..00000000000 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/ProjectConfigurationStateManager.cs +++ /dev/null @@ -1,165 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System; -using System.Collections.Immutable; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor.ProjectSystem; -using Microsoft.AspNetCore.Razor.Serialization; -using Microsoft.AspNetCore.Razor.Utilities; -using Microsoft.CodeAnalysis.Razor.Logging; -using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Razor.Utilities; -using Microsoft.VisualStudio.Threading; - -namespace Microsoft.AspNetCore.Razor.LanguageServer.ProjectSystem; - -/// -/// Updates project system service with new project info. -/// -/// -/// Used to figure out if the project system data is being added, updated or deleted -/// and acts accordingly to update project system service. -/// -internal partial class ProjectConfigurationStateManager : IDisposable -{ - private readonly IRazorProjectService _projectService; - private readonly IProjectSnapshotManager _projectManager; - private readonly ILogger _logger; - - private readonly AsyncBatchingWorkQueue<(ProjectKey ProjectKey, RazorProjectInfo? ProjectInfo)> _workQueue; - private readonly CancellationTokenSource _disposalTokenSource; - private static readonly TimeSpan s_enqueueDelay = TimeSpan.FromMilliseconds(250); - - public ProjectConfigurationStateManager( - IRazorProjectService projectService, - ILoggerFactory loggerFactory, - IProjectSnapshotManager projectManager) - : this(projectService, - loggerFactory, - projectManager, - s_enqueueDelay) - { - } - - // Provided for tests to specify enqueue delay - public ProjectConfigurationStateManager( - IRazorProjectService projectService, - ILoggerFactory loggerFactory, - IProjectSnapshotManager projectManager, - TimeSpan enqueueDelay) - { - _projectService = projectService; - _projectManager = projectManager; - _logger = loggerFactory.GetOrCreateLogger(); - - _disposalTokenSource = new(); - _workQueue = new( - enqueueDelay, - ProcessBatchAsync, - _disposalTokenSource.Token); - } - - public void Dispose() - { - _disposalTokenSource.Cancel(); - _disposalTokenSource.Dispose(); - } - - private async ValueTask ProcessBatchAsync(ImmutableArray<(ProjectKey, RazorProjectInfo?)> items, CancellationToken cancellationToken) - { - foreach (var (projectKey, projectInfo) in items.GetMostRecentUniqueItems(Comparer.Instance)) - { - if (cancellationToken.IsCancellationRequested) - { - return; - } - - await UpdateProjectAsync(projectKey, projectInfo, cancellationToken).ConfigureAwait(false); - } - } - - public async Task ProjectInfoUpdatedAsync(ProjectKey projectKey, RazorProjectInfo? projectInfo, CancellationToken cancellationToken) - { - if (!_projectManager.TryGetLoadedProject(projectKey, out _)) - { - if (projectInfo is not null) - { - var intermediateOutputPath = projectKey.Id; - _logger.LogInformation($"Found no existing project key for project key '{projectKey.Id}'. Assuming new project."); - - projectKey = await AddProjectAsync(intermediateOutputPath, projectInfo, cancellationToken).ConfigureAwait(false); - } - else - { - _logger.LogWarning($"Found no existing project key '{projectKey.Id}' but projectInfo is null. Assuming no-op deletion."); - return; - } - } - else - { - _logger.LogInformation($"Project info changed for project '{projectKey.Id}'"); - } - - // projectInfo may be null, in which case we are enqueuing remove is "remove" - EnqueueUpdateProject(projectKey, projectInfo); - } - - private Task AddProjectAsync( - string intermediateOutputPath, - RazorProjectInfo projectInfo, - CancellationToken cancellationToken) - { - var projectFilePath = FilePathNormalizer.Normalize(projectInfo.FilePath); - var rootNamespace = projectInfo.RootNamespace; - - _logger.LogInformation($"Project configuration added for project '{projectFilePath}': '{intermediateOutputPath}'"); - - return _projectService.AddProjectAsync( - projectFilePath, - intermediateOutputPath, - projectInfo.Configuration, - rootNamespace, - projectInfo.DisplayName, - cancellationToken); - } - - private Task UpdateProjectAsync(ProjectKey projectKey, RazorProjectInfo? projectInfo, CancellationToken cancellationToken) - { - if (projectInfo is null) - { - return ResetProjectAsync(projectKey, cancellationToken); - } - - _logger.LogInformation($"Actually updating {projectKey} with a real projectInfo"); - - var projectWorkspaceState = projectInfo.ProjectWorkspaceState ?? ProjectWorkspaceState.Default; - var documents = projectInfo.Documents; - return _projectService.UpdateProjectAsync( - projectKey, - projectInfo.Configuration, - projectInfo.RootNamespace, - projectInfo.DisplayName, - projectWorkspaceState, - documents, - cancellationToken); - } - - private void EnqueueUpdateProject(ProjectKey projectKey, RazorProjectInfo? projectInfo) - { - _workQueue.AddWork((projectKey, projectInfo)); - } - - private Task ResetProjectAsync(ProjectKey projectKey, CancellationToken cancellationToken) - { - return _projectService.UpdateProjectAsync( - projectKey, - configuration: null, - rootNamespace: null, - displayName: "", - ProjectWorkspaceState.Default, - documents: ImmutableArray.Empty, - cancellationToken); - } -} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/ProjectInfoEndpoint.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/ProjectInfoEndpoint.cs deleted file mode 100644 index 4844b2e644d..00000000000 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/ProjectInfoEndpoint.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts; -using Microsoft.AspNetCore.Razor.LanguageServer.Serialization; -using Microsoft.AspNetCore.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Razor.Serialization; -using Microsoft.CodeAnalysis.Razor.Workspaces.Protocol.ProjectSystem; - -namespace Microsoft.AspNetCore.Razor.LanguageServer.ProjectSystem; - -/// -/// Used to receive project system info updates from the client that were discovered OOB. -/// -[RazorLanguageServerEndpoint(LanguageServerConstants.RazorProjectInfoEndpoint)] -internal class ProjectInfoEndpoint( - ProjectConfigurationStateManager stateManager, - IRazorProjectInfoFileSerializer serializer) - : IRazorNotificationHandler -{ - private readonly ProjectConfigurationStateManager _stateManager = stateManager; - private readonly IRazorProjectInfoFileSerializer _serializer = serializer; - - public bool MutatesSolutionState => false; - - public async Task HandleNotificationAsync(ProjectInfoParams request, RazorRequestContext requestContext, CancellationToken cancellationToken) - { - var count = request.ProjectKeyIds.Length; - - for (var i = 0; i < count; i++) - { - var projectKey = new ProjectKey(request.ProjectKeyIds[i]); - - RazorProjectInfo? projectInfo = null; - - if (request.FilePaths[i] is string filePath) - { - projectInfo = await _serializer.DeserializeFromFileAndDeleteAsync(filePath, cancellationToken).ConfigureAwait(false); - } - - await _stateManager.ProjectInfoUpdatedAsync(projectKey, projectInfo, cancellationToken).ConfigureAwait(false); - } - } -} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/RazorProjectService.TestAccessor.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/RazorProjectService.TestAccessor.cs new file mode 100644 index 00000000000..402e6c2cc78 --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/RazorProjectService.TestAccessor.cs @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Razor.LanguageServer.ProjectSystem; + +internal partial class RazorProjectService +{ + internal TestAccessor GetTestAccessor() => new(this); + + internal readonly struct TestAccessor(RazorProjectService instance) + { + public ValueTask WaitForInitializationAsync() + => instance.WaitForInitializationAsync(); + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/RazorProjectService.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/RazorProjectService.cs index 14bc2e9d75a..916d1a07c19 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/RazorProjectService.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectSystem/RazorProjectService.cs @@ -19,28 +19,141 @@ using Microsoft.CodeAnalysis.Razor.ProjectSystem; using Microsoft.CodeAnalysis.Razor.Workspaces; using Microsoft.CodeAnalysis.Text; +using Microsoft.CommonLanguageServerProtocol.Framework; using Microsoft.VisualStudio.Threading; namespace Microsoft.AspNetCore.Razor.LanguageServer.ProjectSystem; -internal class RazorProjectService( - RemoteTextLoaderFactory remoteTextLoaderFactory, - IDocumentVersionCache documentVersionCache, - IProjectSnapshotManager projectManager, - ILoggerFactory loggerFactory) - : IRazorProjectService +/// +/// Maintains the language server's with the semantics of Razor's project model. +/// +/// +/// This service implements to ensure it is created early so it can begin +/// initializing immediately. +/// +internal partial class RazorProjectService : IRazorProjectService, IRazorProjectInfoListener, IRazorStartupService, IDisposable { - private readonly IProjectSnapshotManager _projectManager = projectManager; - private readonly RemoteTextLoaderFactory _remoteTextLoaderFactory = remoteTextLoaderFactory; - private readonly IDocumentVersionCache _documentVersionCache = documentVersionCache; - private readonly ILogger _logger = loggerFactory.GetOrCreateLogger(); + private readonly IRazorProjectInfoDriver _projectInfoDriver; + private readonly IProjectSnapshotManager _projectManager; + private readonly RemoteTextLoaderFactory _remoteTextLoaderFactory; + private readonly IDocumentVersionCache _documentVersionCache; + private readonly ILogger _logger; + + private readonly CancellationTokenSource _disposeTokenSource; + private readonly Task _initializeTask; + + public RazorProjectService( + IProjectSnapshotManager projectManager, + IRazorProjectInfoDriver projectInfoDriver, + IDocumentVersionCache documentVersionCache, + RemoteTextLoaderFactory remoteTextLoaderFactory, + ILoggerFactory loggerFactory) + { + _projectInfoDriver = projectInfoDriver; + _projectManager = projectManager; + _remoteTextLoaderFactory = remoteTextLoaderFactory; + _documentVersionCache = documentVersionCache; + _logger = loggerFactory.GetOrCreateLogger(); + + // We kick off initialization immediately to ensure that the IRazorProjectService + // is hot and ready to go when requests come in. + _disposeTokenSource = new(); + _initializeTask = InitializeAsync(_disposeTokenSource.Token); + } - public Task AddDocumentToMiscProjectAsync(string filePath, CancellationToken cancellationToken) + public void Dispose() { - return _projectManager.UpdateAsync( - updater: AddDocumentToMiscProjectCore, - state: filePath, - cancellationToken); + if (_disposeTokenSource.IsCancellationRequested) + { + return; + } + + _disposeTokenSource.Cancel(); + _disposeTokenSource.Dispose(); + } + + private async Task InitializeAsync(CancellationToken cancellationToken) + { + _logger.LogTrace($"Initializing {nameof(RazorProjectService)}..."); + + await _projectInfoDriver.WaitForInitializationAsync().ConfigureAwait(false); + + // Register ourselves as a listener to the project driver. + _projectInfoDriver.AddListener(this); + + // Add all existing projects from the driver. + foreach (var projectInfo in _projectInfoDriver.GetLatestProjectInfo()) + { + await AddOrUpdateProjectCoreAsync( + projectInfo.ProjectKey, + projectInfo.FilePath, + projectInfo.Configuration, + projectInfo.RootNamespace, + projectInfo.DisplayName, + projectInfo.ProjectWorkspaceState, + projectInfo.Documents, + cancellationToken) + .ConfigureAwait(false); + } + + _logger.LogTrace($"{nameof(RazorProjectService)} initialized."); + + } + + // Call to ensure that any public IRazorProjectService methods wait for initialization to complete. + private ValueTask WaitForInitializationAsync() + => _initializeTask is { IsCompleted: true } + ? default + : new(_initializeTask); + + async Task IRazorProjectInfoListener.UpdatedAsync(RazorProjectInfo projectInfo, CancellationToken cancellationToken) + { + // Don't update a project during initialization. + await WaitForInitializationAsync().ConfigureAwait(false); + + _logger.LogTrace($"{nameof(IRazorProjectInfoListener)} received update for {projectInfo.ProjectKey}"); + + await AddOrUpdateProjectCoreAsync( + projectInfo.ProjectKey, + projectInfo.FilePath, + projectInfo.Configuration, + projectInfo.RootNamespace, + projectInfo.DisplayName, + projectInfo.ProjectWorkspaceState, + projectInfo.Documents, + cancellationToken) + .ConfigureAwait(false); + } + + async Task IRazorProjectInfoListener.RemovedAsync(ProjectKey projectKey, CancellationToken cancellationToken) + { + // Don't remove a project during initialization. + await WaitForInitializationAsync().ConfigureAwait(false); + + _logger.LogTrace($"{nameof(IRazorProjectInfoListener)} received remove for {projectKey}"); + + await AddOrUpdateProjectCoreAsync( + projectKey, + filePath: null, + configuration: null, + rootNamespace: null, + displayName: "", + ProjectWorkspaceState.Default, + documents: [], + cancellationToken) + .ConfigureAwait(false); + } + + public async Task AddDocumentToMiscProjectAsync(string filePath, CancellationToken cancellationToken) + { + await WaitForInitializationAsync().ConfigureAwait(false); + + await _projectManager + .UpdateAsync( + updater: AddDocumentToMiscProjectCore, + state: filePath, + cancellationToken) + .ConfigureAwait(false); } private void AddDocumentToMiscProjectCore(ProjectSnapshotManager.Updater updater, string filePath) @@ -68,9 +181,11 @@ private void AddDocumentToMiscProjectCore(ProjectSnapshotManager.Updater updater updater.DocumentAdded(miscFilesProject.Key, hostDocument, textLoader); } - public Task OpenDocumentAsync(string filePath, SourceText sourceText, int version, CancellationToken cancellationToken) + public async Task OpenDocumentAsync(string filePath, SourceText sourceText, int version, CancellationToken cancellationToken) { - return _projectManager.UpdateAsync( + await WaitForInitializationAsync().ConfigureAwait(false); + + await _projectManager.UpdateAsync( updater => { var textDocumentPath = FilePathNormalizer.Normalize(filePath); @@ -102,12 +217,15 @@ public Task OpenDocumentAsync(string filePath, SourceText sourceText, int versio TrackDocumentVersion(projectSnapshot, textDocumentPath, version, startGenerating: true); }); }, - cancellationToken); + cancellationToken) + .ConfigureAwait(false); } - public Task CloseDocumentAsync(string filePath, CancellationToken cancellationToken) + public async Task CloseDocumentAsync(string filePath, CancellationToken cancellationToken) { - return _projectManager.UpdateAsync( + await WaitForInitializationAsync().ConfigureAwait(false); + + await _projectManager.UpdateAsync( updater => { ActOnDocumentInMultipleProjects( @@ -120,12 +238,15 @@ public Task CloseDocumentAsync(string filePath, CancellationToken cancellationTo updater.DocumentClosed(projectSnapshot.Key, textDocumentPath, textLoader); }); }, - cancellationToken); + cancellationToken) + .ConfigureAwait(false); } - public Task RemoveDocumentAsync(string filePath, CancellationToken cancellationToken) + public async Task RemoveDocumentAsync(string filePath, CancellationToken cancellationToken) { - return _projectManager.UpdateAsync( + await WaitForInitializationAsync().ConfigureAwait(false); + + await _projectManager.UpdateAsync( updater => { ActOnDocumentInMultipleProjects( @@ -164,12 +285,15 @@ public Task RemoveDocumentAsync(string filePath, CancellationToken cancellationT } }); }, - cancellationToken); + cancellationToken) + .ConfigureAwait(false); } - public Task UpdateDocumentAsync(string filePath, SourceText sourceText, int version, CancellationToken cancellationToken) + public async Task UpdateDocumentAsync(string filePath, SourceText sourceText, int version, CancellationToken cancellationToken) { - return _projectManager.UpdateAsync( + await WaitForInitializationAsync().ConfigureAwait(false); + + await _projectManager.UpdateAsync( updater => { ActOnDocumentInMultipleProjects( @@ -189,7 +313,8 @@ public Task UpdateDocumentAsync(string filePath, SourceText sourceText, int vers TrackDocumentVersion(projectSnapshot, textDocumentPath, version, startGenerating: false); }); }, - cancellationToken); + cancellationToken) + .ConfigureAwait(false); } private void ActOnDocumentInMultipleProjects(string filePath, Action action) @@ -207,7 +332,7 @@ private void ActOnDocumentInMultipleProjects(string filePath, Action AddProjectAsync( + public async Task AddProjectAsync( string filePath, string intermediateOutputPath, RazorConfiguration? configuration, @@ -215,9 +340,13 @@ public Task AddProjectAsync( string? displayName, CancellationToken cancellationToken) { - return _projectManager.UpdateAsync( - updater => AddProjectCore(updater, filePath, intermediateOutputPath, configuration, rootNamespace, displayName), - cancellationToken); + await WaitForInitializationAsync().ConfigureAwait(false); + + return await _projectManager + .UpdateAsync( + updater => AddProjectCore(updater, filePath, intermediateOutputPath, configuration, rootNamespace, displayName), + cancellationToken) + .ConfigureAwait(false); } private ProjectKey AddProjectCore(ProjectSnapshotManager.Updater updater, string filePath, string intermediateOutputPath, RazorConfiguration? configuration, string? rootNamespace, string? displayName) @@ -236,7 +365,7 @@ private ProjectKey AddProjectCore(ProjectSnapshotManager.Updater updater, string return hostProject.Key; } - public Task UpdateProjectAsync( + public async Task UpdateProjectAsync( ProjectKey projectKey, RazorConfiguration? configuration, string? rootNamespace, @@ -245,10 +374,21 @@ public Task UpdateProjectAsync( ImmutableArray documents, CancellationToken cancellationToken) { - return AddOrUpdateProjectCoreAsync(projectKey, filePath: null, configuration, rootNamespace, displayName, projectWorkspaceState, documents, cancellationToken); + await WaitForInitializationAsync().ConfigureAwait(false); + + await AddOrUpdateProjectCoreAsync( + projectKey, + filePath: null, + configuration, + rootNamespace, + displayName, + projectWorkspaceState, + documents, + cancellationToken) + .ConfigureAwait(false); } - public Task AddOrUpdateProjectAsync( + public async Task AddOrUpdateProjectAsync( ProjectKey projectKey, string filePath, RazorConfiguration? configuration, @@ -258,7 +398,18 @@ public Task AddOrUpdateProjectAsync( ImmutableArray documents, CancellationToken cancellationToken) { - return AddOrUpdateProjectCoreAsync(projectKey, filePath, configuration, rootNamespace, displayName, projectWorkspaceState, documents, cancellationToken); + await WaitForInitializationAsync().ConfigureAwait(false); + + await AddOrUpdateProjectCoreAsync( + projectKey, + filePath, + configuration, + rootNamespace, + displayName, + projectWorkspaceState, + documents, + cancellationToken) + .ConfigureAwait(false); } private Task AddOrUpdateProjectCoreAsync( @@ -271,66 +422,68 @@ private Task AddOrUpdateProjectCoreAsync( ImmutableArray documents, CancellationToken cancellationToken) { + // Note: We specifically don't wait for initialization here because this is called *during* initialization. + // All other callers of this method must await WaitForInitializationAsync(). + return _projectManager.UpdateAsync( - updater => + updater => + { + if (!_projectManager.TryGetLoadedProject(projectKey, out var project)) + { + if (filePath is null) { - if (!_projectManager.TryGetLoadedProject(projectKey, out var project)) - { - if (filePath is null) - { - // Never tracked the project to begin with, noop. - _logger.LogInformation($"Failed to update untracked project '{projectKey}'."); - return; - - } + // Never tracked the project to begin with, noop. + _logger.LogInformation($"Failed to update untracked project '{projectKey}'."); + return; + } - // If we've been given a project file path, then we have enough info to add the project ourselves, because we know - // the intermediate output path from the id - var intermediateOutputPath = projectKey.Id; + // If we've been given a project file path, then we have enough info to add the project ourselves, because we know + // the intermediate output path from the id + var intermediateOutputPath = projectKey.Id; - var newKey = AddProjectCore(updater, filePath, intermediateOutputPath, configuration, rootNamespace, displayName); - Debug.Assert(newKey == projectKey); + var newKey = AddProjectCore(updater, filePath, intermediateOutputPath, configuration, rootNamespace, displayName); + Debug.Assert(newKey == projectKey); - project = _projectManager.GetLoadedProject(projectKey); - } + project = _projectManager.GetLoadedProject(projectKey); + } - UpdateProjectDocuments(updater, documents, project.Key); + UpdateProjectDocuments(updater, documents, project.Key); - if (!projectWorkspaceState.Equals(ProjectWorkspaceState.Default)) - { - _logger.LogInformation($"Updating project '{project.Key}' TagHelpers ({projectWorkspaceState.TagHelpers.Length}) and C# Language Version ({projectWorkspaceState.CSharpLanguageVersion})."); - } + if (!projectWorkspaceState.Equals(ProjectWorkspaceState.Default)) + { + _logger.LogInformation($"Updating project '{project.Key}' TagHelpers ({projectWorkspaceState.TagHelpers.Length}) and C# Language Version ({projectWorkspaceState.CSharpLanguageVersion})."); + } - updater.ProjectWorkspaceStateChanged(project.Key, projectWorkspaceState); + updater.ProjectWorkspaceStateChanged(project.Key, projectWorkspaceState); - var currentConfiguration = project.Configuration; - var currentRootNamespace = project.RootNamespace; - if (currentConfiguration.ConfigurationName == configuration?.ConfigurationName && - currentRootNamespace == rootNamespace) - { - _logger.LogTrace($"Updating project '{project.Key}'. The project is already using configuration '{configuration.ConfigurationName}' and root namespace '{rootNamespace}'."); - return; - } + var currentConfiguration = project.Configuration; + var currentRootNamespace = project.RootNamespace; + if (currentConfiguration.ConfigurationName == configuration?.ConfigurationName && + currentRootNamespace == rootNamespace) + { + _logger.LogTrace($"Updating project '{project.Key}'. The project is already using configuration '{configuration.ConfigurationName}' and root namespace '{rootNamespace}'."); + return; + } - if (configuration is null) - { - configuration = FallbackRazorConfiguration.Latest; - _logger.LogInformation($"Updating project '{project.Key}' to use the latest configuration ('{configuration.ConfigurationName}')'."); - } - else if (currentConfiguration.ConfigurationName != configuration.ConfigurationName) - { - _logger.LogInformation($"Updating project '{project.Key}' to Razor configuration '{configuration.ConfigurationName}' with language version '{configuration.LanguageVersion}'."); - } + if (configuration is null) + { + configuration = FallbackRazorConfiguration.Latest; + _logger.LogInformation($"Updating project '{project.Key}' to use the latest configuration ('{configuration.ConfigurationName}')'."); + } + else if (currentConfiguration.ConfigurationName != configuration.ConfigurationName) + { + _logger.LogInformation($"Updating project '{project.Key}' to Razor configuration '{configuration.ConfigurationName}' with language version '{configuration.LanguageVersion}'."); + } - if (currentRootNamespace != rootNamespace) - { - _logger.LogInformation($"Updating project '{project.Key}''s root namespace to '{rootNamespace}'."); - } + if (currentRootNamespace != rootNamespace) + { + _logger.LogInformation($"Updating project '{project.Key}''s root namespace to '{rootNamespace}'."); + } - var hostProject = new HostProject(project.FilePath, project.IntermediateOutputPath, configuration, rootNamespace, displayName); - updater.ProjectConfigurationChanged(hostProject); - }, - cancellationToken); + var hostProject = new HostProject(project.FilePath, project.IntermediateOutputPath, configuration, rootNamespace, displayName); + updater.ProjectConfigurationChanged(hostProject); + }, + cancellationToken); } private void UpdateProjectDocuments( diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorFileChangeDetector.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorFileChangeDetector.cs index a6943ef7c38..e2d19d4f889 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorFileChangeDetector.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorFileChangeDetector.cs @@ -50,6 +50,11 @@ protected RazorFileChangeDetector(IEnumerable listener public void Dispose() { + if (_disposeTokenSource.IsCancellationRequested) + { + return; + } + _disposeTokenSource.Cancel(); _disposeTokenSource.Dispose(); } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorFileChangeDetectorManager.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorFileChangeDetectorManager.cs index 17b727855b6..cbad18beb7c 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorFileChangeDetectorManager.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorFileChangeDetectorManager.cs @@ -12,10 +12,10 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer; internal class RazorFileChangeDetectorManager( - WorkspaceDirectoryPathResolver workspaceDirectoryPathResolver, + IWorkspaceRootPathProvider workspaceRootPathProvider, IEnumerable fileChangeDetectors) : IOnInitialized, IDisposable { - private readonly WorkspaceDirectoryPathResolver _workspaceDirectoryPathResolver = workspaceDirectoryPathResolver; + private readonly IWorkspaceRootPathProvider _workspaceRootPathProvider = workspaceRootPathProvider; private readonly ImmutableArray _fileChangeDetectors = fileChangeDetectors.ToImmutableArray(); private readonly object _disposeLock = new(); private bool _disposed; @@ -24,7 +24,7 @@ public async Task OnInitializedAsync(ILspServices services, CancellationToken ca { // Initialized request, this occurs once the server and client have agreed on what sort of features they both support. It only happens once. - var workspaceDirectoryPath = _workspaceDirectoryPathResolver.Resolve(); + var workspaceDirectoryPath = await _workspaceRootPathProvider.GetRootPathAsync(cancellationToken).ConfigureAwait(false); foreach (var fileChangeDetector in _fileChangeDetectors) { diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorLanguageServer.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorLanguageServer.cs index 30505e0f54b..d8fdaecc815 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorLanguageServer.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorLanguageServer.cs @@ -26,6 +26,7 @@ using Microsoft.AspNetCore.Razor.Telemetry; using Microsoft.CodeAnalysis.Razor; using Microsoft.CodeAnalysis.Razor.Logging; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; using Microsoft.CodeAnalysis.Razor.Workspaces; using Microsoft.CommonLanguageServerProtocol.Framework; using Microsoft.Extensions.DependencyInjection; @@ -40,9 +41,10 @@ internal partial class RazorLanguageServer : NewtonsoftLanguageServer? _configureServer; + private readonly Action? _configureServices; private readonly RazorLSPOptions _lspOptions; private readonly ILspServerActivationTracker? _lspServerActivationTracker; + private readonly IRazorProjectInfoDriver? _projectInfoDriver; private readonly ITelemetryReporter _telemetryReporter; private readonly ClientConnection _clientConnection; @@ -53,18 +55,20 @@ public RazorLanguageServer( JsonSerializer serializer, ILoggerFactory loggerFactory, LanguageServerFeatureOptions? featureOptions, - Action? configureServer, + Action? configureServices, RazorLSPOptions? lspOptions, ILspServerActivationTracker? lspServerActivationTracker, + IRazorProjectInfoDriver? projectInfoDriver, ITelemetryReporter telemetryReporter) : base(jsonRpc, serializer, CreateILspLogger(loggerFactory, telemetryReporter)) { _jsonRpc = jsonRpc; _loggerFactory = loggerFactory; _featureOptions = featureOptions; - _configureServer = configureServer; + _configureServices = configureServices; _lspOptions = lspOptions ?? RazorLSPOptions.Default; _lspServerActivationTracker = lspServerActivationTracker; + _projectInfoDriver = projectInfoDriver; _telemetryReporter = telemetryReporter; _clientConnection = new ClientConnection(_jsonRpc); @@ -104,9 +108,9 @@ protected override ILspServices ConstructLspServices() // Wrap the logger factory so that we can add [LSP] to the start of all the categories services.AddSingleton(loggerFactoryWrapper); - if (_configureServer is not null) + if (_configureServices is not null) { - _configureServer(services); + _configureServices(services); } services.AddSingleton(_clientConnection); @@ -122,6 +126,17 @@ protected override ILspServices ConstructLspServices() services.AddSingleton(); + if (_projectInfoDriver is { } projectInfoDriver) + { + services.AddSingleton(_projectInfoDriver); + } + else + { + // If the language server was not created with an IRazorProjectInfoDriver, + // fall back to a FileWatcher-base driver. + services.AddSingleton(); + } + services.AddLifeCycleServices(this, _clientConnection, _lspServerActivationTracker); services.AddDiagnosticServices(); @@ -145,7 +160,6 @@ protected override ILspServices ConstructLspServices() services.AddSingleton(); // Other - services.AddSingleton(); services.AddSingleton(); // Get the DefaultSession for telemetry. This is set by VS with @@ -175,16 +189,6 @@ static void AddHandlers(IServiceCollection services, LanguageServerFeatureOption services.AddHandlerWithCapabilities(); services.AddHandlerWithCapabilities(); - // Project system info handler - if (featureOptions.UseProjectConfigurationEndpoint) - { - services.AddHandler(); - } - else - { - services.AddHandler(); - } - services.AddHandlerWithCapabilities(); services.AddHandlerWithCapabilities(); diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Serialization/IRazorProjectInfoDeserializer.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Serialization/IRazorProjectInfoDeserializer.cs deleted file mode 100644 index 1f51d6cd2c9..00000000000 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Serialization/IRazorProjectInfoDeserializer.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System.IO; -using Microsoft.AspNetCore.Razor.ProjectSystem; - -namespace Microsoft.AspNetCore.Razor.LanguageServer.Serialization; - -internal interface IRazorProjectInfoDeserializer -{ - RazorProjectInfo? DeserializeFromFile(string filePath); - RazorProjectInfo? DeserializeFromStream(Stream stream); -} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Serialization/RazorProjectInfoDeserializer.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Serialization/RazorProjectInfoDeserializer.cs deleted file mode 100644 index 083969e2bd4..00000000000 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Serialization/RazorProjectInfoDeserializer.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System; -using System.IO; -using Microsoft.AspNetCore.Razor.ProjectSystem; - -namespace Microsoft.AspNetCore.Razor.LanguageServer.Serialization; - -internal sealed class RazorProjectInfoDeserializer : IRazorProjectInfoDeserializer -{ - public static readonly IRazorProjectInfoDeserializer Instance = new RazorProjectInfoDeserializer(); - - private RazorProjectInfoDeserializer() - { - } - - public RazorProjectInfo? DeserializeFromFile(string filePath) - { - using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); - - return DeserializeFromStream(stream); - } - - public RazorProjectInfo? DeserializeFromStream(Stream stream) - { - try - { - return RazorProjectInfo.DeserializeFrom(stream); - } - catch - { - // Swallow deserialization exceptions. There's many reasons they can happen, all out of our control. - return null; - } - } -} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/WorkspaceDirectoryPathResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/WorkspaceDirectoryPathResolver.cs deleted file mode 100644 index 5465b6d42c6..00000000000 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/WorkspaceDirectoryPathResolver.cs +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -namespace Microsoft.AspNetCore.Razor.LanguageServer; - -internal abstract class WorkspaceDirectoryPathResolver -{ - public abstract string Resolve(); -} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/WorkspaceSemanticTokensRefreshNotifier.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/WorkspaceSemanticTokensRefreshNotifier.cs index cf1d6e4c1c3..af8ae8a3d26 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/WorkspaceSemanticTokensRefreshNotifier.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/WorkspaceSemanticTokensRefreshNotifier.cs @@ -43,6 +43,11 @@ public WorkspaceSemanticTokensRefreshNotifier( public void Dispose() { + if (_disposeTokenSource.IsCancellationRequested) + { + return; + } + _optionsChangeListener.Dispose(); _disposeTokenSource.Cancel(); diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/ProjectKey.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/ProjectKey.cs index 9773b9c537b..05756be6005 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/ProjectKey.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/ProjectKey.cs @@ -35,4 +35,7 @@ public bool Equals(ProjectKey other) public override int GetHashCode() => IsUnknown ? 0 : FilePathComparer.Instance.GetHashCode(Id); + + public override string ToString() + => IsUnknown ? "" : Id; } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs index ab832d2f825..07c818eb444 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs @@ -6,6 +6,8 @@ using System.Collections.Immutable; using System.IO; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using MessagePack; using MessagePack.Resolvers; using Microsoft.AspNetCore.Razor.Language; @@ -85,4 +87,7 @@ public void SerializeTo(Stream stream) public static RazorProjectInfo? DeserializeFrom(Stream stream) => MessagePackSerializer.Deserialize(stream, s_options); + + public static ValueTask DeserializeFromAsync(Stream stream, CancellationToken cancellationToken) + => MessagePackSerializer.DeserializeAsync(stream, s_options, cancellationToken); } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/LanguageServerFeatureOptions.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/LanguageServerFeatureOptions.cs index 4247c907299..7f45f166eb2 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/LanguageServerFeatureOptions.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/LanguageServerFeatureOptions.cs @@ -37,15 +37,6 @@ internal abstract class LanguageServerFeatureOptions /// public abstract bool IncludeProjectKeyInGeneratedFilePath { get; } - /// - /// Whether to monitor the entire workspace folder for any project.razor.bin files - /// - /// - /// When this is off, the language server won't have any project knowledge unless the - /// razor/monitorProjectConfigurationFilePath notification is sent. - /// - public abstract bool MonitorWorkspaceFolderForConfigurationFiles { get; } - public abstract bool UseRazorCohostServer { get; } public abstract bool DisableRazorLanguageServer { get; } @@ -57,9 +48,4 @@ internal abstract class LanguageServerFeatureOptions public LanguageServerFlags ToLanguageServerFlags() => new(ForceRuntimeCodeGeneration); - - /// - /// When enabled, project information will be sent to the server using endpoint instead of file. - /// - public abstract bool UseProjectConfigurationEndpoint { get; } } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Logging/ILoggerFactoryExtensions.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Logging/ILoggerFactoryExtensions.cs index db7ef6f6742..d7dc82d67e8 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Logging/ILoggerFactoryExtensions.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Logging/ILoggerFactoryExtensions.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See License.txt in the project root for license information. using System; +using Microsoft.AspNetCore.Razor; namespace Microsoft.CodeAnalysis.Razor.Logging; @@ -9,16 +10,24 @@ internal static class ILoggerFactoryExtensions { public static ILogger GetOrCreateLogger(this ILoggerFactory factory) { - return factory.GetOrCreateLogger(TrimTypeName(typeof(T).FullName)); + return factory.GetOrCreateLogger(typeof(T)); + } + + public static ILogger GetOrCreateLogger(this ILoggerFactory factory, Type type) + { + return factory.GetOrCreateLogger(TrimTypeName(type.FullName.AssumeNotNull())); } private static string TrimTypeName(string name) { - string trimmedName; - _ = TryTrim(name, "Microsoft.VisualStudio.", out trimmedName) || - TryTrim(name, "Microsoft.AspNetCore.Razor.", out trimmedName); + if (TryTrim(name, "Microsoft.VisualStudio.", out var trimmedName) || + TryTrim(name, "Microsoft.AspNetCore.Razor.", out trimmedName) || + TryTrim(name, "Microsoft.CodeAnalysis.Razor.", out trimmedName)) + { + return trimmedName; + } - return trimmedName; + return name; static bool TryTrim(string name, string prefix, out string trimmedName) { diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/AbstractRazorProjectInfoDriver.Comparer.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/AbstractRazorProjectInfoDriver.Comparer.cs new file mode 100644 index 00000000000..54c0f9c8066 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/AbstractRazorProjectInfoDriver.Comparer.cs @@ -0,0 +1,37 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.CodeAnalysis.Razor.ProjectSystem; + +internal abstract partial class AbstractRazorProjectInfoDriver +{ + private sealed class Comparer : IEqualityComparer + { + public static readonly Comparer Instance = new(); + + private Comparer() + { + } + + public bool Equals(Work? x, Work? y) + { + if (x is null) + { + return y is null; + } + else if (y is null) + { + return false; + } + + return x.ProjectKey.Equals(y.ProjectKey); + } + + public int GetHashCode(Work work) + { + return work.ProjectKey.GetHashCode(); + } + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.TestAccessor.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/AbstractRazorProjectInfoDriver.TestAccessor.cs similarity index 66% rename from src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.TestAccessor.cs rename to src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/AbstractRazorProjectInfoDriver.TestAccessor.cs index 5be787a4d8b..e2f37ff1b47 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/ProjectConfigurationStateSynchronizer.TestAccessor.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/AbstractRazorProjectInfoDriver.TestAccessor.cs @@ -3,13 +3,13 @@ using System.Threading.Tasks; -namespace Microsoft.AspNetCore.Razor.LanguageServer; +namespace Microsoft.CodeAnalysis.Razor.ProjectSystem; -internal partial class ProjectConfigurationStateSynchronizer +internal abstract partial class AbstractRazorProjectInfoDriver { internal TestAccessor GetTestAccessor() => new(this); - internal sealed class TestAccessor(ProjectConfigurationStateSynchronizer instance) + internal readonly struct TestAccessor(AbstractRazorProjectInfoDriver instance) { public Task WaitUntilCurrentBatchCompletesAsync() => instance._workQueue.WaitUntilCurrentBatchCompletesAsync(); diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/AbstractRazorProjectInfoDriver.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/AbstractRazorProjectInfoDriver.cs new file mode 100644 index 00000000000..e8cac61438f --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/AbstractRazorProjectInfoDriver.cs @@ -0,0 +1,175 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor; +using Microsoft.AspNetCore.Razor.PooledObjects; +using Microsoft.AspNetCore.Razor.ProjectSystem; +using Microsoft.CodeAnalysis.Razor.Logging; +using Microsoft.CodeAnalysis.Razor.Utilities; +using Microsoft.VisualStudio.Threading; + +namespace Microsoft.CodeAnalysis.Razor.ProjectSystem; + +internal abstract partial class AbstractRazorProjectInfoDriver : IRazorProjectInfoDriver, IDisposable +{ + private abstract record Work(ProjectKey ProjectKey); + private sealed record Update(RazorProjectInfo ProjectInfo) : Work(ProjectInfo.ProjectKey); + private sealed record Remove(ProjectKey ProjectKey) : Work(ProjectKey); + + protected static readonly TimeSpan DefaultDelay = TimeSpan.FromMilliseconds(250); + + protected readonly ILogger Logger; + + private readonly CancellationTokenSource _disposeTokenSource; + private readonly AsyncBatchingWorkQueue _workQueue; + private readonly Dictionary _latestProjectInfoMap; + private ImmutableArray _listeners; + private readonly TaskCompletionSource _initializationTaskSource; + + protected CancellationToken DisposalToken => _disposeTokenSource.Token; + + protected AbstractRazorProjectInfoDriver(ILoggerFactory loggerFactory, TimeSpan? delay = null) + { + Logger = loggerFactory.GetOrCreateLogger(GetType()); + + _disposeTokenSource = new(); + _workQueue = new AsyncBatchingWorkQueue(delay ?? DefaultDelay, ProcessBatchAsync, _disposeTokenSource.Token); + _latestProjectInfoMap = []; + _listeners = []; + _initializationTaskSource = new(); + } + + public void Dispose() + { + if (_disposeTokenSource.IsCancellationRequested) + { + return; + } + + _disposeTokenSource.Cancel(); + _disposeTokenSource.Dispose(); + } + + public Task WaitForInitializationAsync() + { +#pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks + return _initializationTaskSource.Task; +#pragma warning restore VSTHRD003 // Avoid awaiting foreign Tasks + } + + /// + /// MUST be called in the constructor of any descendent + /// to kick off initialization. + /// + protected void StartInitialization() + { + // Kick off initialization asynchronously and call TrySetResult(true) in the continuation. + InitializeAsync(_disposeTokenSource.Token) + .ContinueWith( + _ => + { + _initializationTaskSource.TrySetResult(true); + }, + _disposeTokenSource.Token, + TaskContinuationOptions.OnlyOnRanToCompletion, + TaskScheduler.Default) + .Forget(); + } + + protected abstract Task InitializeAsync(CancellationToken cancellationToken); + + private async ValueTask ProcessBatchAsync(ImmutableArray items, CancellationToken token) + { + foreach (var work in items.GetMostRecentUniqueItems(Comparer.Instance)) + { + if (token.IsCancellationRequested) + { + return; + } + + // Update our map first + lock (_latestProjectInfoMap) + { + switch (work) + { + case Update(var projectInfo): + _latestProjectInfoMap[projectInfo.ProjectKey] = projectInfo; + break; + + case Remove(var projectKey): + _latestProjectInfoMap.Remove(projectKey); + break; + + default: + Assumed.Unreachable(); + break; + } + } + + // Now, notify listeners + foreach (var listener in _listeners) + { + if (token.IsCancellationRequested) + { + return; + } + + switch (work) + { + case Update(var projectInfo): + await listener.UpdatedAsync(projectInfo, token).ConfigureAwait(false); + break; + + case Remove(var projectKey): + await listener.RemovedAsync(projectKey, token).ConfigureAwait(false); + break; + } + } + } + } + + protected void EnqueueUpdate(RazorProjectInfo projectInfo) + { + _workQueue.AddWork(new Update(projectInfo)); + } + + protected void EnqueueRemove(ProjectKey projectKey) + { + _workQueue.AddWork(new Remove(projectKey)); + } + + public ImmutableArray GetLatestProjectInfo() + { + if (!_initializationTaskSource.Task.IsCompleted) + { + throw new InvalidOperationException($"{nameof(GetLatestProjectInfo)} cannot be called before initialization is complete."); + } + + lock (_latestProjectInfoMap) + { + using var builder = new PooledArrayBuilder(capacity: _latestProjectInfoMap.Count); + + foreach (var (_, projectInfo) in _latestProjectInfoMap) + { + builder.Add(projectInfo); + } + + return builder.DrainToImmutable(); + } + } + + public void AddListener(IRazorProjectInfoListener listener) + { + if (!_initializationTaskSource.Task.IsCompleted) + { + throw new InvalidOperationException($"An {nameof(IRazorProjectInfoListener)} cannot be added before initialization is complete."); + } + + ImmutableInterlocked.Update(ref _listeners, array => array.Add(listener)); + } +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/IRazorProjectInfoDriver.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/IRazorProjectInfoDriver.cs new file mode 100644 index 00000000000..c465e309174 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/IRazorProjectInfoDriver.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Collections.Immutable; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.ProjectSystem; + +namespace Microsoft.CodeAnalysis.Razor.ProjectSystem; + +/// +/// Handles project changes and notifies listeners of project updates and removal. +/// +internal interface IRazorProjectInfoDriver +{ + Task WaitForInitializationAsync(); + + ImmutableArray GetLatestProjectInfo(); + + void AddListener(IRazorProjectInfoListener listener); +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/IRazorProjectInfoListener.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/IRazorProjectInfoListener.cs new file mode 100644 index 00000000000..e0ab8f312db --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/IRazorProjectInfoListener.cs @@ -0,0 +1,14 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.ProjectSystem; + +namespace Microsoft.CodeAnalysis.Razor.ProjectSystem; + +internal interface IRazorProjectInfoListener +{ + Task RemovedAsync(ProjectKey projectKey, CancellationToken cancellationToken); + Task UpdatedAsync(RazorProjectInfo projectInfo, CancellationToken cancellationToken); +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectConfigurationFilePathChangedEventArgs.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectConfigurationFilePathChangedEventArgs.cs deleted file mode 100644 index 5535f8c2614..00000000000 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectConfigurationFilePathChangedEventArgs.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System; -using Microsoft.AspNetCore.Razor.ProjectSystem; - -namespace Microsoft.CodeAnalysis.Razor.ProjectSystem; - -internal class ProjectConfigurationFilePathChangedEventArgs : EventArgs -{ - public ProjectConfigurationFilePathChangedEventArgs(ProjectKey projectKey, string? configurationFilePath) - { - ProjectKey = projectKey; - ConfigurationFilePath = configurationFilePath; - } - - public ProjectKey ProjectKey { get; } - - public string? ConfigurationFilePath { get; } -} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectConfigurationFilePathStore.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectConfigurationFilePathStore.cs deleted file mode 100644 index dde2f060f7f..00000000000 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectConfigurationFilePathStore.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Microsoft.AspNetCore.Razor.ProjectSystem; - -namespace Microsoft.CodeAnalysis.Razor.ProjectSystem; - -internal abstract class ProjectConfigurationFilePathStore -{ - public abstract event EventHandler? Changed; - - public abstract IReadOnlyDictionary GetMappings(); - - public abstract void Set(ProjectKey projectKey, string configurationFilePath); - - public abstract bool TryGet(ProjectKey projectKey, [NotNullWhen(returnValue: true)] out string? configurationFilePath); - - public abstract void Remove(ProjectKey projectKey); -} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectSnapshotManager.Dispatcher.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectSnapshotManager.Dispatcher.cs index 7240d722778..8c208630b19 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectSnapshotManager.Dispatcher.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectSnapshotManager.Dispatcher.cs @@ -168,6 +168,11 @@ protected override IEnumerable GetScheduledTasks() public void Dispose() { + if (_disposeTokenSource.IsCancellationRequested) + { + return; + } + _taskQueue.Complete(); _disposeTokenSource.Cancel(); _disposeTokenSource.Dispose(); diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/LanguageServerConstants.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/LanguageServerConstants.cs index 1110191c5df..be2fb1feec3 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/LanguageServerConstants.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/LanguageServerConstants.cs @@ -13,10 +13,6 @@ internal static class LanguageServerConstants public const string RazorLanguageServerName = "Razor Language Server"; - public const string RazorMonitorProjectConfigurationFilePathEndpoint = "razor/monitorProjectConfigurationFilePath"; - - public const string RazorProjectInfoEndpoint = "razor/projectInfo"; - public const string RazorMapToDocumentRangesEndpoint = "razor/mapToDocumentRanges"; public const string RazorCodeActionRunnerCommand = "razor/runCodeAction"; diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/ProjectSystem/MonitorProjectConfigurationFilePathParams.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/ProjectSystem/MonitorProjectConfigurationFilePathParams.cs deleted file mode 100644 index a5933658e58..00000000000 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/ProjectSystem/MonitorProjectConfigurationFilePathParams.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -namespace Microsoft.CodeAnalysis.Razor.Workspaces.Protocol.ProjectSystem; - -internal class MonitorProjectConfigurationFilePathParams -{ - public required string ProjectKeyId { get; set; } - - public required string? ConfigurationFilePath { get; set; } -} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/ProjectSystem/ProjectInfoParams.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/ProjectSystem/ProjectInfoParams.cs deleted file mode 100644 index 70bb99108fb..00000000000 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/ProjectSystem/ProjectInfoParams.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -namespace Microsoft.CodeAnalysis.Razor.Workspaces.Protocol.ProjectSystem; - -internal class ProjectInfoParams -{ - public required string[] ProjectKeyIds { get; init; } - public required string?[] FilePaths { get; init; } -} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Serialization/IRazorProjectInfoFileSerializer.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Serialization/IRazorProjectInfoFileSerializer.cs deleted file mode 100644 index b5c5ed8e082..00000000000 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Serialization/IRazorProjectInfoFileSerializer.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor.ProjectSystem; - -namespace Microsoft.CodeAnalysis.Razor.Serialization; - -internal interface IRazorProjectInfoFileSerializer -{ - Task SerializeToTempFileAsync(RazorProjectInfo projectInfo, CancellationToken cancellationToken); - Task DeserializeFromFileAndDeleteAsync(string filePath, CancellationToken cancellationToken); -} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Serialization/RazorProjectInfoFileSerializer.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Serialization/RazorProjectInfoFileSerializer.cs deleted file mode 100644 index 793763ec7a0..00000000000 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Serialization/RazorProjectInfoFileSerializer.cs +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using MessagePack; -using MessagePack.Resolvers; -using Microsoft.AspNetCore.Razor.ProjectSystem; -using Microsoft.AspNetCore.Razor.Serialization.MessagePack.Resolvers; -using Microsoft.CodeAnalysis.Razor.Logging; - -namespace Microsoft.CodeAnalysis.Razor.Serialization; - -internal class RazorProjectInfoFileSerializer(ILoggerFactory loggerFactory) : IRazorProjectInfoFileSerializer, IDisposable -{ - private static readonly MessagePackSerializerOptions s_options = MessagePackSerializerOptions.Standard - .WithResolver(CompositeResolver.Create( - RazorProjectInfoResolver.Instance, - StandardResolver.Instance)); - - private readonly ILogger _logger = loggerFactory.GetOrCreateLogger(); - private readonly List _filePathsToDelete = []; - - public void Dispose() - { - if (_filePathsToDelete.Count > 0) - { - foreach (var filePath in _filePathsToDelete) - { - DeleteFile(filePath); - } - } - } - - public async Task SerializeToTempFileAsync(RazorProjectInfo projectInfo, CancellationToken cancellationToken) - { - var filePath = Path.GetTempFileName(); - - using var stream = File.OpenWrite(filePath); - await MessagePackSerializer.SerializeAsync(stream, projectInfo, s_options, cancellationToken).ConfigureAwait(false); - - return filePath; - } - - public async Task DeserializeFromFileAndDeleteAsync(string filePath, CancellationToken cancellationToken) - { - RazorProjectInfo projectInfo; - - using (var stream = File.OpenRead(filePath)) - { - projectInfo = await MessagePackSerializer.DeserializeAsync(stream, s_options, cancellationToken).ConfigureAwait(false); - } - - if (!DeleteFile(filePath)) - { - _filePathsToDelete.Add(filePath); - } - - return projectInfo; - } - - private bool DeleteFile(string filePath) - { - try - { - if (File.Exists(filePath)) - { - File.Delete(filePath); - return true; - } - } - catch (Exception ex) - { - _logger.LogError(ex, $"{ex.GetType().FullName} encountered when attempting to delete '{filePath}'"); - } - - return false; - } -} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Initialization/RemoteLanguageServerFeatureOptions.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Initialization/RemoteLanguageServerFeatureOptions.cs index a8249101f5f..29cacab06ac 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Initialization/RemoteLanguageServerFeatureOptions.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Initialization/RemoteLanguageServerFeatureOptions.cs @@ -41,13 +41,9 @@ internal class RemoteLanguageServerFeatureOptions : LanguageServerFeatureOptions public override bool IncludeProjectKeyInGeneratedFilePath => s_options.IncludeProjectKeyInGeneratedFilePath; - public override bool MonitorWorkspaceFolderForConfigurationFiles => throw new InvalidOperationException("This option has not been synced to OOP."); - public override bool UseRazorCohostServer => s_options.UseRazorCohostServer; public override bool DisableRazorLanguageServer => throw new InvalidOperationException("This option has not been synced to OOP."); public override bool ForceRuntimeCodeGeneration => throw new InvalidOperationException("This option has not been synced to OOP."); - - public override bool UseProjectConfigurationEndpoint => throw new InvalidOperationException("This option has not been synced to OOP."); } diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/Documents/EditorDocumentManagerListener.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/Documents/EditorDocumentManagerListener.cs index 983e29ba694..04b3eab8f1c 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/Documents/EditorDocumentManagerListener.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/Documents/EditorDocumentManagerListener.cs @@ -77,6 +77,11 @@ public EditorDocumentManagerListener( public void Dispose() { + if (_disposeTokenSource.IsCancellationRequested) + { + return; + } + _disposeTokenSource.Cancel(); _disposeTokenSource.Dispose(); } diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/DynamicFiles/BackgroundDocumentGenerator.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/DynamicFiles/BackgroundDocumentGenerator.cs index 532091bac0d..25eea35708c 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/DynamicFiles/BackgroundDocumentGenerator.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/DynamicFiles/BackgroundDocumentGenerator.cs @@ -59,6 +59,11 @@ protected BackgroundDocumentGenerator( public void Dispose() { + if (_disposeTokenSource.IsCancellationRequested) + { + return; + } + _disposeTokenSource.Cancel(); _disposeTokenSource.Dispose(); } diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/DefaultProjectConfigurationFilePathStore.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/DefaultProjectConfigurationFilePathStore.cs deleted file mode 100644 index f6d19d307df..00000000000 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/DefaultProjectConfigurationFilePathStore.cs +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System; -using System.Collections.Generic; -using System.ComponentModel.Composition; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using Microsoft.AspNetCore.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Razor; -using Microsoft.CodeAnalysis.Razor.ProjectSystem; - -namespace Microsoft.VisualStudio.Razor.LanguageClient; - -[Export(typeof(ProjectConfigurationFilePathStore))] -internal class DefaultProjectConfigurationFilePathStore : ProjectConfigurationFilePathStore -{ - private readonly Dictionary _mappings; - private readonly object _mappingsLock; - - public override event EventHandler? Changed; - - [ImportingConstructor] - public DefaultProjectConfigurationFilePathStore() - { - _mappings = new Dictionary(); - _mappingsLock = new object(); - } - - public override IReadOnlyDictionary GetMappings() => new Dictionary(_mappings); - - public override void Set(ProjectKey projectKey, string configurationFilePath) - { - if (configurationFilePath is null) - { - throw new ArgumentNullException(nameof(configurationFilePath)); - } - - lock (_mappingsLock) - { - // Resolve any relative pathing in the configuration path so we can talk in absolutes - configurationFilePath = Path.GetFullPath(configurationFilePath); - - if (_mappings.TryGetValue(projectKey, out var existingConfigurationFilePath) && - FilePathComparer.Instance.Equals(configurationFilePath, existingConfigurationFilePath)) - { - // Already have this mapping, don't invoke changed. - return; - } - - _mappings[projectKey] = configurationFilePath; - } - - var args = new ProjectConfigurationFilePathChangedEventArgs(projectKey, configurationFilePath); - Changed?.Invoke(this, args); - } - - public override void Remove(ProjectKey projectKey) - { - lock (_mappingsLock) - { - if (!_mappings.Remove(projectKey)) - { - // We weren't tracking the project file path, no-op. - return; - } - } - - var args = new ProjectConfigurationFilePathChangedEventArgs(projectKey, configurationFilePath: null); - Changed?.Invoke(this, args); - } - - public override bool TryGet(ProjectKey projectKey, [NotNullWhen(returnValue: true)] out string? configurationFilePath) - { - lock (_mappingsLock) - { - return _mappings.TryGetValue(projectKey, out configurationFilePath); - } - } -} diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/ProjectSystem/RazorProjectInfoDriver.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/ProjectSystem/RazorProjectInfoDriver.cs new file mode 100644 index 00000000000..dcce7b2f649 --- /dev/null +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/ProjectSystem/RazorProjectInfoDriver.cs @@ -0,0 +1,74 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor; +using Microsoft.CodeAnalysis.Razor.Logging; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; + +namespace Microsoft.VisualStudio.Razor.LanguageClient.ProjectSystem; + +internal sealed partial class RazorProjectInfoDriver : AbstractRazorProjectInfoDriver +{ + private readonly IProjectSnapshotManager _projectManager; + + public RazorProjectInfoDriver( + IProjectSnapshotManager projectManager, + ILoggerFactory loggerFactory, + TimeSpan? delay = null) : base(loggerFactory, delay) + { + _projectManager = projectManager; + + StartInitialization(); + } + + protected override Task InitializeAsync(CancellationToken cancellationToken) + { + // Even though we aren't mutating the project snapshot manager, we call UpdateAsync(...) here to ensure + // that we run on its dispatcher. That ensures that no changes will code in while we are iterating the + // current set of projects and connected to the Changed event. + return _projectManager.UpdateAsync(updater => + { + foreach (var project in updater.GetProjects()) + { + EnqueueUpdate(project.ToRazorProjectInfo()); + } + + _projectManager.Changed += ProjectManager_Changed; + }, + cancellationToken); + } + + private void ProjectManager_Changed(object sender, ProjectChangeEventArgs e) + { + // Don't do any work if the solution is closing + if (e.SolutionIsClosing) + { + return; + } + + switch (e.Kind) + { + case ProjectChangeKind.ProjectAdded: + case ProjectChangeKind.ProjectChanged: + case ProjectChangeKind.DocumentRemoved: + case ProjectChangeKind.DocumentAdded: + var newer = e.Newer.AssumeNotNull(); + EnqueueUpdate(newer.ToRazorProjectInfo()); + break; + + case ProjectChangeKind.ProjectRemoved: + var older = e.Older.AssumeNotNull(); + EnqueueRemove(older.Key); + break; + + case ProjectChangeKind.DocumentChanged: + break; + + default: + throw new NotSupportedException($"Unsupported {nameof(ProjectChangeKind)}: {e.Kind}"); + } + } +} diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/ProjectSystem/RazorProjectInfoEndpointPublisher.Comparer.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/ProjectSystem/RazorProjectInfoEndpointPublisher.Comparer.cs deleted file mode 100644 index 38f4f258fd3..00000000000 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/ProjectSystem/RazorProjectInfoEndpointPublisher.Comparer.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System.Collections.Generic; -using Microsoft.CodeAnalysis.Razor.ProjectSystem; - -namespace Microsoft.VisualStudio.Razor.LanguageClient.ProjectSystem; - -internal partial class RazorProjectInfoEndpointPublisher -{ - /// - /// Compares two work items from project info publishing work queue. - /// - /// - /// Project updates and project removal are treated as equivalent since project removal - /// work item should supersede any project updates in the queue. Project additions are not - /// placed in the queue so project removal would never supersede/overwrite project addition - /// (which would've resulted in us removing a project we never added). - /// - private sealed class Comparer : IEqualityComparer<(IProjectSnapshot Project, bool Removal)> - { - public static readonly Comparer Instance = new(); - - private Comparer() - { - } - - public bool Equals((IProjectSnapshot Project, bool Removal) x, (IProjectSnapshot Project, bool Removal) y) - { - // Project removal should replace project update so treat Removal and non-Removal - // of the same IProjectSnapshot as equivalent work item - var (snapshotX, _) = x; - var (snapshotY, _) = y; - - return snapshotX.Key.Equals(snapshotY.Key); - } - - public int GetHashCode((IProjectSnapshot Project, bool Removal) obj) - { - var (snapshot, _) = obj; - - return snapshot.Key.GetHashCode(); - } - } -} diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/ProjectSystem/RazorProjectInfoEndpointPublisher.TestAccessor.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/ProjectSystem/RazorProjectInfoEndpointPublisher.TestAccessor.cs deleted file mode 100644 index d97f96672dc..00000000000 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/ProjectSystem/RazorProjectInfoEndpointPublisher.TestAccessor.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System.Threading.Tasks; -using Microsoft.CodeAnalysis.Razor.ProjectSystem; - -namespace Microsoft.VisualStudio.Razor.LanguageClient.ProjectSystem; - -internal partial class RazorProjectInfoEndpointPublisher -{ - internal TestAccessor GetTestAccessor() - => new(this); - - internal sealed class TestAccessor(RazorProjectInfoEndpointPublisher instance) - { - /// - /// Allows unit tests to imitate ProjectManager.Changed event firing - /// - public void ProjectManager_Changed(object sender, ProjectChangeEventArgs args) - => instance.ProjectManager_Changed(sender, args); - - /// - /// Allows unit tests to enqueue project update directly. - /// - public void EnqueuePublish(IProjectSnapshot projectSnapshot) - => instance.EnqueuePublish(projectSnapshot); - - /// - /// Allows unit tests to wait for all work queue items to be processed. - /// - public Task WaitUntilCurrentBatchCompletesAsync() - => instance._workQueue.WaitUntilCurrentBatchCompletesAsync(); - } -} diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/ProjectSystem/RazorProjectInfoEndpointPublisher.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/ProjectSystem/RazorProjectInfoEndpointPublisher.cs deleted file mode 100644 index c22b5ec742f..00000000000 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/ProjectSystem/RazorProjectInfoEndpointPublisher.cs +++ /dev/null @@ -1,182 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System; -using System.Collections.Immutable; -using System.ComponentModel.Composition; -using System.Diagnostics; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor; -using Microsoft.AspNetCore.Razor.PooledObjects; -using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Razor.Protocol; -using Microsoft.CodeAnalysis.Razor.Serialization; -using Microsoft.CodeAnalysis.Razor.Utilities; -using Microsoft.CodeAnalysis.Razor.Workspaces.Protocol.ProjectSystem; -using Microsoft.VisualStudio.LanguageServer.ContainedLanguage; -using Microsoft.VisualStudio.Threading; - -namespace Microsoft.VisualStudio.Razor.LanguageClient.ProjectSystem; - -/// -/// Publishes project data (including TagHelper info) discovered OOB to the server via LSP notification -/// instead of old method of writing a project configuration bin file -/// -[Export(typeof(RazorProjectInfoEndpointPublisher))] -internal partial class RazorProjectInfoEndpointPublisher : IDisposable -{ - private readonly LSPRequestInvoker _requestInvoker; - private readonly IProjectSnapshotManager _projectManager; - private readonly IRazorProjectInfoFileSerializer _serializer; - - private readonly AsyncBatchingWorkQueue<(IProjectSnapshot Project, bool Removal)> _workQueue; - private readonly CancellationTokenSource _disposeTokenSource; - - // Delay between publishes to prevent bursts of changes yet still be responsive to changes. - private static readonly TimeSpan s_enqueueDelay = TimeSpan.FromMilliseconds(250); - - [ImportingConstructor] - public RazorProjectInfoEndpointPublisher( - LSPRequestInvoker requestInvoker, - IProjectSnapshotManager projectManager, - IRazorProjectInfoFileSerializer serializer) - : this(requestInvoker, projectManager, serializer, s_enqueueDelay) - { - } - - // Provided for tests to specify enqueue delay - public RazorProjectInfoEndpointPublisher( - LSPRequestInvoker requestInvoker, - IProjectSnapshotManager projectManager, - IRazorProjectInfoFileSerializer serializer, - TimeSpan enqueueDelay) - { - _requestInvoker = requestInvoker; - _projectManager = projectManager; - _serializer = serializer; - - _disposeTokenSource = new(); - _workQueue = new( - enqueueDelay, - ProcessBatchAsync, - _disposeTokenSource.Token); - } - - public void Dispose() - { - _disposeTokenSource.Cancel(); - _disposeTokenSource.Dispose(); - } - - public void StartSending() - { - _projectManager.Changed += ProjectManager_Changed; - - var projects = _projectManager.GetProjects(); - - PublishProjectsAsync(projects, _disposeTokenSource.Token).Forget(); - } - - private void ProjectManager_Changed(object sender, ProjectChangeEventArgs args) - { - // Don't do any work if the solution is closing - if (args.SolutionIsClosing) - { - return; - } - - switch (args.Kind) - { - case ProjectChangeKind.ProjectChanged: - case ProjectChangeKind.DocumentRemoved: - case ProjectChangeKind.DocumentAdded: - EnqueuePublish(args.Newer.AssumeNotNull()); - - break; - - case ProjectChangeKind.ProjectAdded: - // Don't enqueue project addition as those are unlikely to come through in large batches. - // Also ensures that we won't get project removal go through the queue without project addition. - PublishProjectsAsync([args.Newer.AssumeNotNull()], _disposeTokenSource.Token).Forget(); - - break; - - case ProjectChangeKind.ProjectRemoved: - // Enqueue removal so it will replace any other project changes in the work queue as they unnecessary now. - EnqueueRemoval(args.Older.AssumeNotNull()); - break; - - case ProjectChangeKind.DocumentChanged: - break; - - default: - Debug.Fail("A new ProjectChangeKind has been added that the RazorProjectInfoEndpointPublisher doesn't know how to deal with"); - break; - } - } - - private void EnqueuePublish(IProjectSnapshot projectSnapshot) - { - _workQueue.AddWork((Project: projectSnapshot, Removal: false)); - } - - private void EnqueueRemoval(IProjectSnapshot projectSnapshot) - { - _workQueue.AddWork((Project: projectSnapshot, Removal: true)); - } - - private async ValueTask ProcessBatchAsync(ImmutableArray<(IProjectSnapshot, bool)> items, CancellationToken cancellationToken) - { - using var projectKeyIds = new PooledArrayBuilder(capacity: items.Length); - using var filePaths = new PooledArrayBuilder(capacity: items.Length); - - foreach (var (project, removal) in items.GetMostRecentUniqueItems(Comparer.Instance)) - { - string? filePath = null; - - if (!removal) - { - var projectInfo = project.ToRazorProjectInfo(); - filePath = await _serializer.SerializeToTempFileAsync(projectInfo, cancellationToken).ConfigureAwait(false); - } - - projectKeyIds.Add(project.Key.Id); - filePaths.Add(filePath); - } - - await SendRequestAsync(projectKeyIds.ToArray(), filePaths.ToArray(), cancellationToken); - } - - private async Task PublishProjectsAsync(ImmutableArray projects, CancellationToken cancellationToken) - { - using var projectKeyIds = new PooledArrayBuilder(capacity: projects.Length); - using var filePaths = new PooledArrayBuilder(capacity: projects.Length); - - foreach (var project in projects) - { - var projectInfo = project.ToRazorProjectInfo(); - var filePath = await _serializer.SerializeToTempFileAsync(projectInfo, cancellationToken).ConfigureAwait(false); - - projectKeyIds.Add(project.Key.Id); - filePaths.Add(filePath); - } - - await SendRequestAsync(projectKeyIds.ToArray(), filePaths.ToArray(), cancellationToken); - } - - private Task SendRequestAsync(string[] projectKeyIds, string?[] filePaths, CancellationToken cancellationToken) - { - var parameter = new ProjectInfoParams - { - ProjectKeyIds = projectKeyIds, - FilePaths = filePaths - }; - - return _requestInvoker.ReinvokeRequestOnServerAsync( - LanguageServerConstants.RazorProjectInfoEndpoint, - RazorLSPConstants.RazorLanguageServerName, - parameter, - cancellationToken); - } -} diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/ProjectSystem/VisualStudioRazorProjectInfoFileSerializer.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/ProjectSystem/VisualStudioRazorProjectInfoFileSerializer.cs deleted file mode 100644 index 909851fb644..00000000000 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/ProjectSystem/VisualStudioRazorProjectInfoFileSerializer.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System.ComponentModel.Composition; -using Microsoft.CodeAnalysis.Razor.Logging; -using Microsoft.CodeAnalysis.Razor.Serialization; - -namespace Microsoft.VisualStudio.Razor.LanguageClient.ProjectSystem; - -[Export(typeof(IRazorProjectInfoFileSerializer))] -[method: ImportingConstructor] -internal sealed class VisualStudioRazorProjectInfoFileSerializer(ILoggerFactory loggerFactory) - : RazorProjectInfoFileSerializer(loggerFactory) -{ -} diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/RazorLanguageServerClient.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/RazorLanguageServerClient.cs index bcaad84a57c..07c511bbc7d 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/RazorLanguageServerClient.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/RazorLanguageServerClient.cs @@ -12,16 +12,14 @@ using Microsoft.CodeAnalysis.Host; using Microsoft.CodeAnalysis.Razor.Logging; using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.CodeAnalysis.Razor.Workspaces; -using Microsoft.CodeAnalysis.Razor.Workspaces.Protocol.ProjectSystem; using Microsoft.Extensions.DependencyInjection; using Microsoft.VisualStudio.LanguageServer.Client; -using Microsoft.VisualStudio.LanguageServer.ContainedLanguage; using Microsoft.VisualStudio.Razor.LanguageClient.Endpoints; using Microsoft.VisualStudio.Razor.LanguageClient.ProjectSystem; using Microsoft.VisualStudio.Razor.Logging; using Microsoft.VisualStudio.Razor.Settings; +using Microsoft.VisualStudio.Shell; using Microsoft.VisualStudio.Threading; using Microsoft.VisualStudio.Utilities; using Nerdbank.Streams; @@ -34,9 +32,7 @@ namespace Microsoft.VisualStudio.Razor.LanguageClient; [method: ImportingConstructor] internal class RazorLanguageServerClient( RazorCustomMessageTarget customTarget, - LSPRequestInvoker requestInvoker, - ProjectConfigurationFilePathStore projectConfigurationFilePathStore, - RazorProjectInfoEndpointPublisher projectInfoEndpointPublisher, + IProjectSnapshotManager projectManager, ILoggerFactory loggerFactory, RazorLogHubTraceProvider traceProvider, LanguageServerFeatureOptions languageServerFeatureOptions, @@ -45,22 +41,20 @@ internal class RazorLanguageServerClient( ITelemetryReporter telemetryReporter, IClientSettingsManager clientSettingsManager, ILspServerActivationTracker lspServerActivationTracker, - VisualStudioHostServicesProvider vsHostWorkspaceServicesProvider) + VisualStudioHostServicesProvider vsHostServicesProvider) : ILanguageClient, ILanguageClientCustomMessage2, ILanguageClientPriority { - private readonly ILanguageClientBroker _languageClientBroker = languageClientBroker ?? throw new ArgumentNullException(nameof(languageClientBroker)); - private readonly ILanguageServiceBroker2 _languageServiceBroker = languageServiceBroker ?? throw new ArgumentNullException(nameof(languageServiceBroker)); - private readonly ITelemetryReporter _telemetryReporter = telemetryReporter ?? throw new ArgumentNullException(nameof(telemetryReporter)); - private readonly IClientSettingsManager _clientSettingsManager = clientSettingsManager ?? throw new ArgumentNullException(nameof(clientSettingsManager)); - private readonly ILspServerActivationTracker _lspServerActivationTracker = lspServerActivationTracker ?? throw new ArgumentNullException(nameof(lspServerActivationTracker)); - private readonly RazorCustomMessageTarget _customMessageTarget = customTarget ?? throw new ArgumentNullException(nameof(customTarget)); - private readonly LSPRequestInvoker _requestInvoker = requestInvoker ?? throw new ArgumentNullException(nameof(requestInvoker)); - private readonly ProjectConfigurationFilePathStore _projectConfigurationFilePathStore = projectConfigurationFilePathStore ?? throw new ArgumentNullException(nameof(projectConfigurationFilePathStore)); - private readonly RazorProjectInfoEndpointPublisher _projectInfoEndpointPublisher = projectInfoEndpointPublisher ?? throw new ArgumentNullException(nameof(projectInfoEndpointPublisher)); - private readonly LanguageServerFeatureOptions _languageServerFeatureOptions = languageServerFeatureOptions ?? throw new ArgumentNullException(nameof(languageServerFeatureOptions)); - private readonly VisualStudioHostServicesProvider _vsHostWorkspaceServicesProvider = vsHostWorkspaceServicesProvider ?? throw new ArgumentNullException(nameof(vsHostWorkspaceServicesProvider)); - private readonly ILoggerFactory _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); - private readonly RazorLogHubTraceProvider _traceProvider = traceProvider ?? throw new ArgumentNullException(nameof(traceProvider)); + private readonly ILanguageClientBroker _languageClientBroker = languageClientBroker; + private readonly ILanguageServiceBroker2 _languageServiceBroker = languageServiceBroker; + private readonly ITelemetryReporter _telemetryReporter = telemetryReporter; + private readonly IClientSettingsManager _clientSettingsManager = clientSettingsManager; + private readonly ILspServerActivationTracker _lspServerActivationTracker = lspServerActivationTracker; + private readonly RazorCustomMessageTarget _customMessageTarget = customTarget; + private readonly IProjectSnapshotManager _projectManager = projectManager; + private readonly LanguageServerFeatureOptions _languageServerFeatureOptions = languageServerFeatureOptions; + private readonly VisualStudioHostServicesProvider _vsHostServicesProvider = vsHostServicesProvider; + private readonly ILoggerFactory _loggerFactory = loggerFactory; + private readonly RazorLogHubTraceProvider _traceProvider = traceProvider; private RazorLanguageServerHost? _host; @@ -103,21 +97,47 @@ public event AsyncEventHandler? StopAsync var lspOptions = RazorLSPOptions.From(_clientSettingsManager.GetClientSettings()); + var projectInfoDriver = new RazorProjectInfoDriver(_projectManager, _loggerFactory); + _host = RazorLanguageServerHost.Create( serverStream, serverStream, _loggerFactory, _telemetryReporter, - ConfigureLanguageServer, + ConfigureServices, _languageServerFeatureOptions, lspOptions, _lspServerActivationTracker, + projectInfoDriver, traceSource); // This must not happen on an RPC endpoint due to UIThread concerns, so ActivateAsync was chosen. await EnsureContainedLanguageServersInitializedAsync(); - var connection = new Connection(clientStream, clientStream); - return connection; + + return new Connection(clientStream, clientStream); + + void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(new HostServicesProviderAdapter(_vsHostServicesProvider)); + } + } + + private async Task EnsureCleanedUpServerAsync() + { + if (_host is null) + { + // Server was already cleaned up + return; + } + + if (_host is not null) + { + // Server still hasn't shutdown, wait for it to shutdown + await _host.WaitForExitAsync().ConfigureAwait(false); + + _host.Dispose(); + _host = null; + } } internal static IEnumerable> GetRelevantContainedLanguageClientsAndMetadata(ILanguageServiceBroker2 languageServiceBroker) @@ -178,72 +198,6 @@ private async Task EnsureContainedLanguageServersInitializedAsync() _lspServerActivationTracker.Activated(); } - private void ConfigureLanguageServer(IServiceCollection serviceCollection) - { - if (_vsHostWorkspaceServicesProvider is not null) - { - serviceCollection.AddSingleton(new HostServicesProviderAdapter(_vsHostWorkspaceServicesProvider)); - } - } - - private async Task EnsureCleanedUpServerAsync() - { - if (_host is null) - { - // Server was already cleaned up - return; - } - - if (_host is not null) - { - _projectConfigurationFilePathStore.Changed -= ProjectConfigurationFilePathStore_Changed; - // Server still hasn't shutdown, wait for it to shutdown - await _host.WaitForExitAsync().ConfigureAwait(false); - } - } - - private void ProjectConfigurationFilePathStore_Changed(object sender, ProjectConfigurationFilePathChangedEventArgs args) - { - _ = ProjectConfigurationFilePathStore_ChangedAsync(args, CancellationToken.None); - } - - private async Task ProjectConfigurationFilePathStore_ChangedAsync(ProjectConfigurationFilePathChangedEventArgs args, CancellationToken cancellationToken) - { - if (_languageServerFeatureOptions.DisableRazorLanguageServer || _languageServerFeatureOptions.UseProjectConfigurationEndpoint) - { - return; - } - - try - { - var parameter = new MonitorProjectConfigurationFilePathParams() - { - ProjectKeyId = args.ProjectKey.Id, - ConfigurationFilePath = args.ConfigurationFilePath, - }; - - await _requestInvoker.ReinvokeRequestOnServerAsync( - LanguageServerConstants.RazorMonitorProjectConfigurationFilePathEndpoint, - RazorLSPConstants.RazorLanguageServerName, - parameter, - cancellationToken); - } - catch (Exception) - { - // We're fire and forgetting here, if the request fails we're ok with that. - // - // Note: When moving between solutions this can fail with a null reference exception because the underlying LSP platform's - // JsonRpc object will be `null`. This can happen in two situations: - // 1. There's currently a race in the platform on shutting down/activating so we don't get the opportunity to properly detach - // from the configuration file path store changed event properly. - // Tracked by: https://github.com/dotnet/aspnetcore/issues/23819 - // 2. The LSP platform failed to shutdown our language server properly due to a JsonRpc timeout. There's currently a limitation in - // the LSP platform APIs where we don't know if the LSP platform requested shutdown but our language server never saw it. Therefore, - // we will null-ref until our language server client boot-logic kicks back in and re-activates resulting in the old server being - // being cleaned up. - } - } - public Task AttachForCustomMessageAsync(JsonRpc rpc) => Task.CompletedTask; public Task OnServerInitializeFailedAsync(ILanguageClientInitializationInfo initializationState) @@ -270,30 +224,7 @@ public Task OnLoadedAsync() } public Task OnServerInitializedAsync() - { - ServerStarted(); - - return Task.CompletedTask; - } - - private void ServerStarted() - { - if (_languageServerFeatureOptions.UseProjectConfigurationEndpoint) - { - _projectInfoEndpointPublisher.StartSending(); - } - else - { - _projectConfigurationFilePathStore.Changed += ProjectConfigurationFilePathStore_Changed; - - var mappings = _projectConfigurationFilePathStore.GetMappings(); - foreach (var mapping in mappings) - { - var args = new ProjectConfigurationFilePathChangedEventArgs(mapping.Key, mapping.Value); - ProjectConfigurationFilePathStore_Changed(this, args); - } - } - } + => Task.CompletedTask; private sealed class HostServicesProviderAdapter(VisualStudioHostServicesProvider vsHostServicesProvider) : IHostServicesProvider { diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/RazorProjectInfoPublisher.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/RazorProjectInfoPublisher.cs deleted file mode 100644 index 4d6c38f27ac..00000000000 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/RazorProjectInfoPublisher.cs +++ /dev/null @@ -1,354 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System; -using System.Collections.Generic; -using System.ComponentModel.Composition; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Razor; -using Microsoft.CodeAnalysis.Razor.Logging; -using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Razor.Workspaces; - -namespace Microsoft.VisualStudio.Razor.LanguageClient; - -/// -/// Publishes project.razor.bin files. -/// -[Export(typeof(IRazorStartupService))] -internal class RazorProjectInfoPublisher : IRazorStartupService -{ - internal readonly Dictionary DeferredPublishTasks; - - // Internal for testing - internal bool _active; - - private const string TempFileExt = ".temp"; - private readonly ILogger _logger; - private readonly LSPEditorFeatureDetector _lspEditorFeatureDetector; - private readonly IProjectSnapshotManager _projectManager; - private readonly ProjectConfigurationFilePathStore _projectConfigurationFilePathStore; - private readonly Dictionary _pendingProjectPublishes; - private readonly object _pendingProjectPublishesLock; - private readonly object _publishLock; - - private bool _documentsProcessed = false; - - [ImportingConstructor] - public RazorProjectInfoPublisher( - LSPEditorFeatureDetector lSPEditorFeatureDetector, - IProjectSnapshotManager projectManager, - ProjectConfigurationFilePathStore projectConfigurationFilePathStore, - ILoggerFactory loggerFactory) - { - DeferredPublishTasks = new Dictionary(FilePathComparer.Instance); - _pendingProjectPublishes = new Dictionary(); - _pendingProjectPublishesLock = new(); - _publishLock = new object(); - - _lspEditorFeatureDetector = lSPEditorFeatureDetector; - _projectConfigurationFilePathStore = projectConfigurationFilePathStore; - _logger = loggerFactory.GetOrCreateLogger(); - - _projectManager = projectManager; - _projectManager.Changed += ProjectManager_Changed; - } - - // Internal settable for testing - // 3000ms between publishes to prevent bursts of changes yet still be responsive to changes. - internal int EnqueueDelay { get; set; } = 3000; - - // Internal for testing - internal void EnqueuePublish(IProjectSnapshot projectSnapshot) - { - lock (_pendingProjectPublishesLock) - { - _pendingProjectPublishes[projectSnapshot.Key] = projectSnapshot; - } - - if (!DeferredPublishTasks.TryGetValue(projectSnapshot.FilePath, out var update) || update.IsCompleted) - { - DeferredPublishTasks[projectSnapshot.FilePath] = PublishAfterDelayAsync(projectSnapshot.Key); - } - } - - internal void ProjectManager_Changed(object sender, ProjectChangeEventArgs args) - { - // Don't do any work if the solution is closing - if (args.SolutionIsClosing) - { - return; - } - - if (!_lspEditorFeatureDetector.IsLSPEditorAvailable()) - { - return; - } - - // Prior to doing any sort of project state serialization/publishing we avoid any excess work as long as there aren't any "open" Razor files. - // However, once a Razor file is opened we turn the firehose on and start doing work. - // By taking this lazy approach we ensure that we don't do any excess Razor work (serialization is expensive) in non-Razor scenarios. - - if (!_active) - { - // Not currently active, we need to decide if we should become active or if we should no-op. - - if (!_projectManager.GetOpenDocuments().IsEmpty) - { - // A Razor document was just opened, we should become "active" which means we'll constantly be monitoring project state. - _active = true; - - if (ProjectWorkspacePublishable(args)) - { - // Typically document open events don't result in us re-processing project state; however, given this is the first time a user opened a Razor document we should. - // Don't enqueue, just publish to get the most immediate result. - ImmediatePublish(args.Newer!); - return; - } - } - else - { - // No open documents and not active. No-op. - return; - } - } - - // All the below Publish's (except ProjectRemoved) wait until our project has been initialized (ProjectWorkspaceState != null) - // so that we don't publish half-finished projects, which can cause things like Semantic coloring to "flash" - // when they update repeatedly as they load. - switch (args.Kind) - { - case ProjectChangeKind.ProjectChanged: - if (!ProjectWorkspacePublishable(args)) - { - break; - } - - if (!ReferenceEquals(args.Newer!.ProjectWorkspaceState, args.Older!.ProjectWorkspaceState)) - { - // If our workspace state has changed since our last snapshot then this means pieces influencing - // TagHelper resolution have also changed. Fast path the TagHelper publish. - ImmediatePublish(args.Newer); - } - else - { - // There was a project change that doesn't seem to be related to TagHelpers, we can be - // less aggressive and do a delayed publish. - EnqueuePublish(args.Newer); - } - - break; - case ProjectChangeKind.DocumentChanged: - // DocumentChanged normally isn't a great trigger for publishing, given that it happens while a user types - // but for a brand new project, its possible this DocumentChanged actually represents a DocumentOpen, and - // it could be the first one, so its important to publish if there is no project configuration file present - if (ProjectWorkspacePublishable(args) && - _projectConfigurationFilePathStore.TryGet(args.ProjectKey, out var configurationFilePath) && - !FileExists(configurationFilePath)) - { - ImmediatePublish(args.Newer!); - } - - break; - - case ProjectChangeKind.DocumentRemoved: - case ProjectChangeKind.DocumentAdded: - - if (ProjectWorkspacePublishable(args)) - { - // These changes can come in bursts so we don't want to overload the publishing system. Therefore, - // we enqueue publishes and then publish the latest project after a delay. - EnqueuePublish(args.Newer!); - } - - break; - - case ProjectChangeKind.ProjectAdded: - - if (ProjectWorkspacePublishable(args)) - { - ImmediatePublish(args.Newer!); - } - - break; - - case ProjectChangeKind.ProjectRemoved: - RemovePublishingData(args.Older!); - break; - - default: - Debug.Fail("A new ProjectChangeKind has been added that the RazorProjectInfoPublisher doesn't know how to deal with"); - break; - } - - static bool ProjectWorkspacePublishable(ProjectChangeEventArgs args) - { - return args.Newer?.ProjectWorkspaceState != null; - } - } - - // Internal for testing - internal void Publish(IProjectSnapshot projectSnapshot) - { - if (projectSnapshot is null) - { - throw new ArgumentNullException(nameof(projectSnapshot)); - } - - lock (_publishLock) - { - string? configurationFilePath = null; - try - { - if (!_projectConfigurationFilePathStore.TryGet(projectSnapshot.Key, out configurationFilePath)) - { - return; - } - - // We don't want to serialize the project until it's ready to avoid flashing as the project loads different parts. - // Since the project configuration from last session likely still exists the experience is unlikely to be degraded by this delay. - // An exception is made for when there's no existing project configuration file because some flashing is preferable to having no TagHelper knowledge. - if (ShouldSerialize(projectSnapshot, configurationFilePath)) - { - SerializeToFile(projectSnapshot, configurationFilePath); - } - } - catch (Exception ex) - { - _logger.LogWarning($@"Could not update Razor project configuration file '{configurationFilePath}': -{ex}"); - } - } - } - - // Internal for testing - internal void RemovePublishingData(IProjectSnapshot projectSnapshot) - { - lock (_publishLock) - { - if (!_projectConfigurationFilePathStore.TryGet(projectSnapshot.Key, out var configurationFilePath)) - { - // If we don't track the value in PublishFilePathMappings that means it's already been removed, do nothing. - return; - } - - lock (_pendingProjectPublishesLock) - { - if (_pendingProjectPublishes.TryGetValue(projectSnapshot.Key, out _)) - { - // Project was removed while a delayed publish was in flight. Clear the in-flight publish so it noops. - _pendingProjectPublishes.Remove(projectSnapshot.Key); - } - } - } - } - - protected virtual void SerializeToFile(IProjectSnapshot projectSnapshot, string configurationFilePath) - { - // We need to avoid having an incomplete file at any point, but our - // project configuration file is large enough that it will be written as multiple operations. - var tempFilePath = string.Concat(configurationFilePath, TempFileExt); - var tempFileInfo = new FileInfo(tempFilePath); - - if (tempFileInfo.Exists) - { - // This could be caused by failures during serialization or early process termination. - tempFileInfo.Delete(); - } - - // This needs to be in explicit brackets because the operation needs to be completed - // by the time we move the tempfile into its place - using (var stream = tempFileInfo.Create()) - { - var projectInfo = projectSnapshot.ToRazorProjectInfo(); - projectInfo.SerializeTo(stream); - } - - var fileInfo = new FileInfo(configurationFilePath); - if (fileInfo.Exists) - { - fileInfo.Delete(); - } - - File.Move(tempFilePath, configurationFilePath); - } - - protected virtual bool FileExists(string file) - { - return File.Exists(file); - } - - protected virtual bool ShouldSerialize(IProjectSnapshot projectSnapshot, string configurationFilePath) - { - if (!FileExists(configurationFilePath)) - { - return true; - } - - // Don't serialize our understanding until we're "ready" - if (!_documentsProcessed) - { - if (projectSnapshot.DocumentFilePaths.Any(d => AspNetCore.Razor.Language.FileKinds.GetFileKindFromFilePath(d) - .Equals(AspNetCore.Razor.Language.FileKinds.Component, StringComparison.OrdinalIgnoreCase))) - { - foreach (var documentFilePath in projectSnapshot.DocumentFilePaths) - { - // We want to wait until at least one document has been processed (meaning it became a TagHelper. - // Because we don't have a way to tell which TagHelpers were created from the local project just from their descriptors we have to improvise - // We assume that a document has been processed if at least one Component matches the name of one of our files. - var fileName = Path.GetFileNameWithoutExtension(documentFilePath); - - if (projectSnapshot.GetDocument(documentFilePath) is { } documentSnapshot && - string.Equals(documentSnapshot.FileKind, AspNetCore.Razor.Language.FileKinds.Component, StringComparison.OrdinalIgnoreCase) && - projectSnapshot.GetTagHelpersSynchronously().Any(t => t.Name.EndsWith("." + fileName, StringComparison.OrdinalIgnoreCase))) - { - // Documents have been processed, lets publish - _documentsProcessed = true; - break; - } - } - } - else - { - // This project has no Components and thus cannot suffer from the lagging compilation problem. - _documentsProcessed = true; - } - } - - return _documentsProcessed; - } - - private void ImmediatePublish(IProjectSnapshot projectSnapshot) - { - lock (_pendingProjectPublishesLock) - { - // Clear any pending publish - _pendingProjectPublishes.Remove(projectSnapshot.Key); - } - - Publish(projectSnapshot); - } - - private async Task PublishAfterDelayAsync(ProjectKey projectKey) - { - await Task.Delay(EnqueueDelay).ConfigureAwait(false); - - IProjectSnapshot projectSnapshot; - lock (_pendingProjectPublishesLock) - { - if (!_pendingProjectPublishes.TryGetValue(projectKey, out projectSnapshot)) - { - // Project was removed while waiting for the publish delay. - return; - } - - _pendingProjectPublishes.Remove(projectKey); - } - - Publish(projectSnapshot); - } -} diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/Logging/RazorActivityLog.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/Logging/RazorActivityLog.cs index d730873c029..428f61bdd66 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/Logging/RazorActivityLog.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/Logging/RazorActivityLog.cs @@ -40,6 +40,11 @@ public RazorActivityLog( public void Dispose() { + if (_disposeTokenSource.IsCancellationRequested) + { + return; + } + _disposeTokenSource.Cancel(); _disposeTokenSource.Dispose(); } diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/DefaultWindowsRazorProjectHost.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/DefaultWindowsRazorProjectHost.cs index bae9b601a3a..d3514dc58de 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/DefaultWindowsRazorProjectHost.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/DefaultWindowsRazorProjectHost.cs @@ -47,9 +47,8 @@ public DefaultWindowsRazorProjectHost( IUnconfiguredProjectCommonServices commonServices, [Import(typeof(SVsServiceProvider))] IServiceProvider serviceProvider, IProjectSnapshotManager projectManager, - ProjectConfigurationFilePathStore projectConfigurationFilePathStore, LanguageServerFeatureOptions languageServerFeatureOptions) - : base(commonServices, serviceProvider, projectManager, projectConfigurationFilePathStore) + : base(commonServices, serviceProvider, projectManager) { _languageServerFeatureOptions = languageServerFeatureOptions; } @@ -98,9 +97,6 @@ await UpdateAsync( var hostProject = new HostProject(CommonServices.UnconfiguredProject.FullPath, intermediatePath, configuration, rootNamespace, displayName); - var projectConfigurationFile = Path.Combine(intermediatePath, _languageServerFeatureOptions.ProjectConfigurationFileName); - ProjectConfigurationFilePathStore.Set(hostProject.Key, projectConfigurationFile); - UpdateProject(updater, hostProject); for (var i = 0; i < changedDocuments.Length; i++) diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/FallbackProjectManager.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/FallbackProjectManager.cs index 16d690de406..09130e8cb44 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/FallbackProjectManager.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/FallbackProjectManager.cs @@ -26,14 +26,12 @@ namespace Microsoft.VisualStudio.Razor.ProjectSystem; [method: ImportingConstructor] internal sealed class FallbackProjectManager( [Import(typeof(SVsServiceProvider))] IServiceProvider serviceProvider, - ProjectConfigurationFilePathStore projectConfigurationFilePathStore, LanguageServerFeatureOptions languageServerFeatureOptions, IProjectSnapshotManager projectManager, IWorkspaceProvider workspaceProvider, ITelemetryReporter telemetryReporter) { private readonly IServiceProvider _serviceProvider = serviceProvider; - private readonly ProjectConfigurationFilePathStore _projectConfigurationFilePathStore = projectConfigurationFilePathStore; private readonly LanguageServerFeatureOptions _languageServerFeatureOptions = languageServerFeatureOptions; private readonly IProjectSnapshotManager _projectManager = projectManager; private readonly IWorkspaceProvider _workspaceProvider = workspaceProvider; @@ -123,10 +121,6 @@ private void AddFallbackProject(ProjectId projectId, string filePath, Cancellati cancellationToken); AddFallbackDocument(hostProject.Key, filePath, project.FilePath, cancellationToken); - - var configurationFilePath = Path.Combine(intermediateOutputPath, _languageServerFeatureOptions.ProjectConfigurationFileName); - - _projectConfigurationFilePathStore.Set(hostProject.Key, configurationFilePath); } private void AddFallbackDocument(ProjectKey projectKey, string filePath, string projectFilePath, CancellationToken cancellationToken) diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/FallbackWindowsRazorProjectHost.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/FallbackWindowsRazorProjectHost.cs index 2f23389aeb9..229221b53e4 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/FallbackWindowsRazorProjectHost.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/FallbackWindowsRazorProjectHost.cs @@ -16,7 +16,6 @@ using Microsoft.AspNetCore.Razor.ProjectSystem; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Razor.Workspaces; using Microsoft.VisualStudio.ProjectSystem; using Microsoft.VisualStudio.Shell; using ContentItem = Microsoft.VisualStudio.Razor.ProjectSystem.ManagedProjectSystemSchema.ContentItem; @@ -40,18 +39,14 @@ internal class FallbackWindowsRazorProjectHost : WindowsRazorProjectHostBase NoneItem.SchemaName, ConfigurationGeneralSchemaName, }); - private readonly LanguageServerFeatureOptions? _languageServerFeatureOptions; [ImportingConstructor] public FallbackWindowsRazorProjectHost( IUnconfiguredProjectCommonServices commonServices, [Import(typeof(SVsServiceProvider))] IServiceProvider serviceProvider, - IProjectSnapshotManager projectManager, - ProjectConfigurationFilePathStore projectConfigurationFilePathStore, - LanguageServerFeatureOptions? languageServerFeatureOptions) - : base(commonServices, serviceProvider, projectManager, projectConfigurationFilePathStore) + IProjectSnapshotManager projectManager) + : base(commonServices, serviceProvider, projectManager) { - _languageServerFeatureOptions = languageServerFeatureOptions; } protected override ImmutableHashSet GetRuleNames() => s_ruleNames; @@ -148,12 +143,6 @@ await UpdateAsync(updater => var hostProject = new HostProject(CommonServices.UnconfiguredProject.FullPath, intermediatePath, configuration, rootNamespace: null, displayName); - if (_languageServerFeatureOptions is not null) - { - var projectConfigurationFile = Path.Combine(intermediatePath, _languageServerFeatureOptions.ProjectConfigurationFileName); - ProjectConfigurationFilePathStore.Set(hostProject.Key, projectConfigurationFile); - } - UpdateProject(updater, hostProject); for (var i = 0; i < changedDocuments.Length; i++) diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/WindowsRazorProjectHostBase.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/WindowsRazorProjectHostBase.cs index 60628fba5c5..9c1f525174d 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/WindowsRazorProjectHostBase.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/WindowsRazorProjectHostBase.cs @@ -32,7 +32,6 @@ internal abstract partial class WindowsRazorProjectHostBase : OnceInitializedOnc private readonly Dictionary _projectSubscriptions = new(); private readonly List _disposables = new(); - protected readonly ProjectConfigurationFilePathStore ProjectConfigurationFilePathStore; internal const string BaseIntermediateOutputPathPropertyName = "BaseIntermediateOutputPath"; internal const string IntermediateOutputPathPropertyName = "IntermediateOutputPath"; @@ -47,8 +46,7 @@ internal abstract partial class WindowsRazorProjectHostBase : OnceInitializedOnc protected WindowsRazorProjectHostBase( IUnconfiguredProjectCommonServices commonServices, IServiceProvider serviceProvider, - IProjectSnapshotManager projectManager, - ProjectConfigurationFilePathStore projectConfigurationFilePathStore) + IProjectSnapshotManager projectManager) : base(commonServices.ThreadingService.JoinableTaskContext) { CommonServices = commonServices; @@ -56,7 +54,6 @@ protected WindowsRazorProjectHostBase( _projectManager = projectManager; _lock = new AsyncSemaphore(initialCount: 1); - ProjectConfigurationFilePathStore = projectConfigurationFilePathStore; } protected abstract ImmutableHashSet GetRuleNames(); @@ -286,7 +283,6 @@ protected static void UpdateProject(ProjectSnapshotManager.Updater updater, Host protected void RemoveProject(ProjectSnapshotManager.Updater updater, ProjectKey projectKey) { updater.ProjectRemoved(projectKey); - ProjectConfigurationFilePathStore.Remove(projectKey); } private async Task ExecuteWithLockAsync(Func func) diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectWorkspaceStateGenerator.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectWorkspaceStateGenerator.cs index d35bc554844..e6b29390c64 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectWorkspaceStateGenerator.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectWorkspaceStateGenerator.cs @@ -212,6 +212,11 @@ private async Task TryEnterSemaphoreAsync(ProjectKey projectKey, Cancellat _logger.LogTrace($"Entered semaphore for '{projectKey}'"); return true; } + catch (OperationCanceledException) + { + _logger.LogTrace($"Update cancelled before entering semaphore for '{projectKey}'"); + return false; + } catch (Exception ex) { // Swallow object and task cancelled exceptions diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/VisualStudioLanguageServerFeatureOptions.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/VisualStudioLanguageServerFeatureOptions.cs index b7a832a5786..bb8ecc3b96d 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/VisualStudioLanguageServerFeatureOptions.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/VisualStudioLanguageServerFeatureOptions.cs @@ -18,7 +18,6 @@ internal class VisualStudioLanguageServerFeatureOptions : LanguageServerFeatureO private const string UseRazorCohostServerFeatureFlag = "Razor.LSP.UseRazorCohostServer"; private const string DisableRazorLanguageServerFeatureFlag = "Razor.LSP.DisableRazorLanguageServer"; private const string ForceRuntimeCodeGenerationFeatureFlag = "Razor.LSP.ForceRuntimeCodeGeneration"; - private const string UseProjectConfigurationEndpointFeatureFlag = "Razor.LSP.UseProjectConfigurationEndpoint"; private readonly LSPEditorFeatureDetector _lspEditorFeatureDetector; private readonly Lazy _showAllCSharpCodeActions; @@ -27,7 +26,6 @@ internal class VisualStudioLanguageServerFeatureOptions : LanguageServerFeatureO private readonly Lazy _useRazorCohostServer; private readonly Lazy _disableRazorLanguageServer; private readonly Lazy _forceRuntimeCodeGeneration; - private readonly Lazy _useProjectConfigurationEndpoint; [ImportingConstructor] public VisualStudioLanguageServerFeatureOptions(LSPEditorFeatureDetector lspEditorFeatureDetector) @@ -80,13 +78,6 @@ public VisualStudioLanguageServerFeatureOptions(LSPEditorFeatureDetector lspEdit var forceRuntimeCodeGeneration = featureFlags.IsFeatureEnabled(ForceRuntimeCodeGenerationFeatureFlag, defaultValue: false); return forceRuntimeCodeGeneration; }); - - _useProjectConfigurationEndpoint = new Lazy(() => - { - var featureFlags = (IVsFeatureFlags)Package.GetGlobalService(typeof(SVsFeatureFlags)); - var useProjectConfigurationEndpoint = featureFlags.IsFeatureEnabled(UseProjectConfigurationEndpointFeatureFlag, defaultValue: false); - return useProjectConfigurationEndpoint; - }); } // We don't currently support file creation operations on VS Codespaces or VS Liveshare @@ -117,15 +108,10 @@ public VisualStudioLanguageServerFeatureOptions(LSPEditorFeatureDetector lspEdit public override bool UsePreciseSemanticTokenRanges => _usePreciseSemanticTokenRanges.Value; - public override bool MonitorWorkspaceFolderForConfigurationFiles => false; - public override bool UseRazorCohostServer => _useRazorCohostServer.Value; public override bool DisableRazorLanguageServer => _disableRazorLanguageServer.Value; /// public override bool ForceRuntimeCodeGeneration => _forceRuntimeCodeGeneration.Value; - - /// - public override bool UseProjectConfigurationEndpoint => _useProjectConfigurationEndpoint.Value; } diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/VsSolutionUpdatesProjectSnapshotChangeTrigger.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/VsSolutionUpdatesProjectSnapshotChangeTrigger.cs index b9a468b94c6..c1e9de5988f 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/VsSolutionUpdatesProjectSnapshotChangeTrigger.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/VsSolutionUpdatesProjectSnapshotChangeTrigger.cs @@ -66,6 +66,11 @@ public VsSolutionUpdatesProjectSnapshotChangeTrigger( public void Dispose() { + if (_disposeTokenSource.IsCancellationRequested) + { + return; + } + _disposeTokenSource.Cancel(); _disposeTokenSource.Dispose(); diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/WorkspaceProjectStateChangeDetector.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/WorkspaceProjectStateChangeDetector.cs index 71c34ec93b4..6da28b7d884 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/WorkspaceProjectStateChangeDetector.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/WorkspaceProjectStateChangeDetector.cs @@ -73,6 +73,11 @@ public WorkspaceProjectStateChangeDetector( public void Dispose() { + if (_disposeTokenSource.IsCancellationRequested) + { + return; + } + _projectManager.Changed -= ProjectManager_Changed; _workspace.WorkspaceChanged -= Workspace_WorkspaceChanged; diff --git a/src/Razor/src/Microsoft.VisualStudio.RazorExtension/Microsoft.VisualStudio.RazorExtension.Custom.pkgdef b/src/Razor/src/Microsoft.VisualStudio.RazorExtension/Microsoft.VisualStudio.RazorExtension.Custom.pkgdef index b1bdc0a07ab..9b1c0111007 100644 --- a/src/Razor/src/Microsoft.VisualStudio.RazorExtension/Microsoft.VisualStudio.RazorExtension.Custom.pkgdef +++ b/src/Razor/src/Microsoft.VisualStudio.RazorExtension/Microsoft.VisualStudio.RazorExtension.Custom.pkgdef @@ -80,9 +80,3 @@ "Value"=dword:00000000 "Title"="Force use of runtime code generation for Razor (requires restart)" "PreviewPaneChannels"="IntPreview,int.main" - -[$RootKey$\FeatureFlags\Razor\LSP\UseProjectConfigurationEndpoint] -"Description"="Use endpoint instead of file to send project information" -"Value"=dword:00000000 -"Title"="Use project configuration LSP endpoint instead of a binary file for Razor (requires restart)" -"PreviewPaneChannels"="IntPreview,int.main" \ No newline at end of file diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Completion/LegacyRazorCompletionResolveEndpointTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Completion/LegacyRazorCompletionResolveEndpointTest.cs index e7f37e6b9c8..de733495687 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Completion/LegacyRazorCompletionResolveEndpointTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Completion/LegacyRazorCompletionResolveEndpointTest.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.LanguageServer.Test; using Microsoft.AspNetCore.Razor.LanguageServer.Tooltip; using Microsoft.AspNetCore.Razor.Test.Common; using Microsoft.AspNetCore.Razor.Test.Common.LanguageServer; @@ -247,10 +248,10 @@ private VSInternalCompletionList CreateLSPCompletionList(params RazorCompletionI private VSInternalCompletionItem ConvertToBridgedItem(CompletionItem completionItem) { var textWriter = new StringWriter(); - Serializer.Serialize(textWriter, completionItem); + ProtocolSerializer.Instance.Serialize(textWriter, completionItem); var stringBuilder = textWriter.GetStringBuilder(); var jsonReader = new JsonTextReader(new StringReader(stringBuilder.ToString())); - var bridgedItem = Serializer.Deserialize(jsonReader); + var bridgedItem = ProtocolSerializer.Instance.Deserialize(jsonReader); return bridgedItem; } } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Completion/RazorCompletionResolveEndpointTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Completion/RazorCompletionResolveEndpointTest.cs index 3486482c726..932edc5b4d8 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Completion/RazorCompletionResolveEndpointTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Completion/RazorCompletionResolveEndpointTest.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.LanguageServer.Test; using Microsoft.AspNetCore.Razor.Test.Common.LanguageServer; using Microsoft.VisualStudio.LanguageServer.Protocol; using Newtonsoft.Json; @@ -149,10 +150,10 @@ public async Task Handle_MergedCompletionListFindsProperCompletionList_Resolves( private VSInternalCompletionItem ConvertToBridgedItem(CompletionItem completionItem) { using var textWriter = new StringWriter(); - Serializer.Serialize(textWriter, completionItem); + ProtocolSerializer.Instance.Serialize(textWriter, completionItem); var stringBuilder = textWriter.GetStringBuilder(); using var jsonReader = new JsonTextReader(new StringReader(stringBuilder.ToString())); - var bridgedItem = Serializer.Deserialize(jsonReader); + var bridgedItem = ProtocolSerializer.Instance.Deserialize(jsonReader); return bridgedItem; } @@ -160,7 +161,7 @@ private class TestCompletionItemResolver : CompletionItemResolver { public override Task ResolveAsync( VSInternalCompletionItem item, - VSInternalCompletionList containingCompletionlist, + VSInternalCompletionList containingCompletionList, object originalRequestContext, VSInternalClientCapabilities clientCapabilities, CancellationToken cancellationToken) diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/DefaultWorkspaceDirectoryPathResolverTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/DefaultWorkspaceDirectoryPathResolverTest.cs index c4327f34f33..704de2c824a 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/DefaultWorkspaceDirectoryPathResolverTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/DefaultWorkspaceDirectoryPathResolverTest.cs @@ -1,49 +1,42 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT license. See License.txt in the project root for license information. -#nullable disable - using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Test.Common; using Microsoft.CommonLanguageServerProtocol.Framework; using Microsoft.VisualStudio.LanguageServer.Protocol; -using Moq; using Xunit; -using Microsoft.AspNetCore.Razor.Test.Common; using Xunit.Abstractions; namespace Microsoft.AspNetCore.Razor.LanguageServer; -public class DefaultWorkspaceDirectoryPathResolverTest : ToolingTestBase +public class DefaultWorkspaceDirectoryPathResolverTest(ITestOutputHelper testOutput) : ToolingTestBase(testOutput) { - public DefaultWorkspaceDirectoryPathResolverTest(ITestOutputHelper testOutput) - : base(testOutput) - { - } - [Fact] - public void Resolve_RootUriUnavailable_UsesRootPath() + public async Task Resolve_RootUriUnavailable_UsesRootPath() { // Arrange var expectedWorkspaceDirectory = "/testpath"; #pragma warning disable CS0618 // Type or member is obsolete - var clientSettings = new InitializeParams() + var initializeParams = new InitializeParams() { RootPath = expectedWorkspaceDirectory }; #pragma warning restore CS0618 // Type or member is obsolete - var server = new Mock>(MockBehavior.Strict); - server.Setup(m => m.GetInitializeParams()).Returns(clientSettings); - var workspaceDirectoryPathResolver = new DefaultWorkspaceDirectoryPathResolver(server.Object); + + var capabilitiesManager = new CapabilitiesManager(StrictMock.Of()); + capabilitiesManager.SetInitializeParams(initializeParams); // Act - var workspaceDirectoryPath = workspaceDirectoryPathResolver.Resolve(); + var workspaceDirectoryPath = await capabilitiesManager.GetRootPathAsync(DisposalToken); // Assert Assert.Equal(expectedWorkspaceDirectory, workspaceDirectoryPath); } [Fact] - public void Resolve_RootUriPrefered() + public async Task Resolve_RootUriPrefered() { // Arrange var initialWorkspaceDirectory = "C:\\testpath"; @@ -53,19 +46,20 @@ public void Resolve_RootUriPrefered() Host = null, Path = initialWorkspaceDirectory, }; + #pragma warning disable CS0618 // Type or member is obsolete - var clientSettings = new InitializeParams() + var initializeParams = new InitializeParams() { RootPath = "/somethingelse", RootUri = uriBuilder.Uri, }; #pragma warning restore CS0618 // Type or member is obsolete - var server = new Mock>(MockBehavior.Strict); - server.Setup(s => s.GetInitializeParams()).Returns(clientSettings); - var workspaceDirectoryPathResolver = new DefaultWorkspaceDirectoryPathResolver(server.Object); + + var capabilitiesManager = new CapabilitiesManager(StrictMock.Of()); + capabilitiesManager.SetInitializeParams(initializeParams); // Act - var workspaceDirectoryPath = workspaceDirectoryPathResolver.Resolve(); + var workspaceDirectoryPath = await capabilitiesManager.GetRootPathAsync(DisposalToken); // Assert var expectedWorkspaceDirectory = "C:/testpath"; diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/MonitorProjectConfigurationFilePathEndpointTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/MonitorProjectConfigurationFilePathEndpointTest.cs deleted file mode 100644 index de5785993bf..00000000000 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/MonitorProjectConfigurationFilePathEndpointTest.cs +++ /dev/null @@ -1,509 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor.LanguageServer.ProjectSystem; -using Microsoft.AspNetCore.Razor.Test.Common; -using Microsoft.AspNetCore.Razor.Test.Common.LanguageServer; -using Microsoft.AspNetCore.Razor.Test.Common.ProjectSystem; -using Microsoft.AspNetCore.Razor.Test.Common.Workspaces; -using Microsoft.CodeAnalysis.Razor.Logging; -using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Razor.Workspaces; -using Microsoft.CodeAnalysis.Razor.Workspaces.Protocol.ProjectSystem; -using Moq; -using Xunit; -using Xunit.Abstractions; -using Xunit.Sdk; - -namespace Microsoft.AspNetCore.Razor.LanguageServer; - -public class MonitorProjectConfigurationFilePathEndpointTest : LanguageServerTestBase -{ - private readonly WorkspaceDirectoryPathResolver _directoryPathResolver; - - public MonitorProjectConfigurationFilePathEndpointTest(ITestOutputHelper testOutput) - : base(testOutput) - { - var path = PathUtilities.CreateRootedPath("dir"); - _directoryPathResolver = Mock.Of(resolver => resolver.Resolve() == path, MockBehavior.Strict); - } - - [Fact] - public async Task Handle_Disposed_Noops() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var directoryPathResolver = new Mock(MockBehavior.Strict); - directoryPathResolver.Setup(resolver => resolver.Resolve()) - .Throws(); - var configurationFileEndpoint = new MonitorProjectConfigurationFilePathEndpoint( - projectManager, - directoryPathResolver.Object, - listeners: [], - TestLanguageServerFeatureOptions.Instance, - LoggerFactory); - configurationFileEndpoint.Dispose(); - - var debugDirectory = PathUtilities.CreateRootedPath("dir", "obj", "Debug"); - var projectKeyDirectory = PathUtilities.CreateRootedPath("dir", "obj"); - var projectKey = TestProjectKey.Create(projectKeyDirectory); - - var request = new MonitorProjectConfigurationFilePathParams() - { - ProjectKeyId = projectKey.Id, - ConfigurationFilePath = Path.Combine(debugDirectory, "project.razor.bin") - }; - var requestContext = CreateRazorRequestContext(documentContext: null); - - // Act & Assert - await configurationFileEndpoint.HandleNotificationAsync(request, requestContext, DisposalToken); - } - - [Fact] - public async Task Handle_ConfigurationFilePath_UntrackedMonitorNoops() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var directoryPathResolver = new Mock(MockBehavior.Strict); - directoryPathResolver.Setup(resolver => resolver.Resolve()) - .Throws(); - var configurationFileEndpoint = new MonitorProjectConfigurationFilePathEndpoint( - projectManager, - directoryPathResolver.Object, - listeners: [], - TestLanguageServerFeatureOptions.Instance, - LoggerFactory); - - var projectKeyDirectory = PathUtilities.CreateRootedPath("dir", "obj"); - var projectKey = TestProjectKey.Create(projectKeyDirectory); - - var request = new MonitorProjectConfigurationFilePathParams() - { - ProjectKeyId = projectKey.Id, - ConfigurationFilePath = null!, - }; - var requestContext = CreateRazorRequestContext(documentContext: null); - - // Act & Assert - await configurationFileEndpoint.HandleNotificationAsync(request, requestContext, DisposalToken); - } - - [Fact] - public async Task Handle_ConfigurationFilePath_TrackedMonitor_StopsMonitor() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var detector = new TestFileChangeDetector(); - var configurationFileEndpoint = new TestMonitorProjectConfigurationFilePathEndpoint( - () => detector, - _directoryPathResolver, - listeners: [], - LoggerFactory, - projectManager); - - var debugDirectory = PathUtilities.CreateRootedPath("externaldir", "obj", "Debug"); - var projectKeyDirectory = PathUtilities.CreateRootedPath("dir", "obj"); - var projectKey = TestProjectKey.Create(projectKeyDirectory); - - var startRequest = new MonitorProjectConfigurationFilePathParams() - { - ProjectKeyId = projectKey.Id, - ConfigurationFilePath = Path.Combine(debugDirectory, "project.razor.bin") - }; - var requestContext = CreateRazorRequestContext(documentContext: null); - await configurationFileEndpoint.HandleNotificationAsync(startRequest, requestContext, DisposalToken); - var stopRequest = new MonitorProjectConfigurationFilePathParams() - { - ProjectKeyId = projectKey.Id, - ConfigurationFilePath = null!, - }; - - // Act - await configurationFileEndpoint.HandleNotificationAsync(stopRequest, requestContext, DisposalToken); - - // Assert - Assert.Equal(1, detector.StartCount); - Assert.Equal(1, detector.StopCount); - } - - [Fact] - public async Task Handle_InWorkspaceDirectory_Noops() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var detector = new TestFileChangeDetector(); - var configurationFileEndpoint = new TestMonitorProjectConfigurationFilePathEndpoint( - () => detector, - _directoryPathResolver, - listeners: [], - LoggerFactory, - projectManager); - - var debugDirectory = PathUtilities.CreateRootedPath("dir", "obj", "Debug"); - var projectKeyDirectory = PathUtilities.CreateRootedPath("dir", "obj"); - var projectKey = TestProjectKey.Create(projectKeyDirectory); - - var startRequest = new MonitorProjectConfigurationFilePathParams() - { - ProjectKeyId = projectKey.Id, - ConfigurationFilePath = Path.Combine(debugDirectory, "project.razor.bin") - }; - var requestContext = CreateRazorRequestContext(documentContext: null); - - // Act - await configurationFileEndpoint.HandleNotificationAsync(startRequest, requestContext, DisposalToken); - - // Assert - Assert.Equal(0, detector.StartCount); - } - - [Fact] - public async Task Handle_InWorkspaceDirectory_MonitorsIfLanguageFeatureOptionSet() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var detector = new TestFileChangeDetector(); - var configurationFileEndpoint = new TestMonitorProjectConfigurationFilePathEndpoint( - () => detector, - _directoryPathResolver, - listeners: [], - LoggerFactory, - projectManager, - options: new TestLanguageServerFeatureOptions(monitorWorkspaceFolderForConfigurationFiles: false)); - - var debugDirectory = PathUtilities.CreateRootedPath("dir", "obj", "Debug"); - var projectKeyDirectory = PathUtilities.CreateRootedPath("dir", "obj"); - var projectKey = TestProjectKey.Create(projectKeyDirectory); - - var startRequest = new MonitorProjectConfigurationFilePathParams() - { - ProjectKeyId = projectKey.Id, - ConfigurationFilePath = Path.Combine(debugDirectory, "project.razor.bin") - }; - var requestContext = CreateRazorRequestContext(documentContext: null); - - // Act - await configurationFileEndpoint.HandleNotificationAsync(startRequest, requestContext, DisposalToken); - - // Assert - Assert.Equal(1, detector.StartCount); - } - - [Fact] - public async Task Handle_DuplicateMonitors_Noops() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var detector = new TestFileChangeDetector(); - var configurationFileEndpoint = new TestMonitorProjectConfigurationFilePathEndpoint( - () => detector, - _directoryPathResolver, - listeners: [], - LoggerFactory, - projectManager); - - var debugDirectory = PathUtilities.CreateRootedPath("externaldir", "obj", "Debug"); - var projectKeyDirectory = PathUtilities.CreateRootedPath("dir", "obj"); - var projectKey = TestProjectKey.Create(projectKeyDirectory); - - var startRequest = new MonitorProjectConfigurationFilePathParams() - { - ProjectKeyId = projectKey.Id, - ConfigurationFilePath = Path.Combine(debugDirectory, "project.razor.bin") - }; - var requestContext = CreateRazorRequestContext(documentContext: null); - - // Act - await configurationFileEndpoint.HandleNotificationAsync(startRequest, requestContext, DisposalToken); - await configurationFileEndpoint.HandleNotificationAsync(startRequest, requestContext, DisposalToken); - - // Assert - Assert.Equal(1, detector.StartCount); - Assert.Equal(0, detector.StopCount); - } - - [Fact] - public async Task Handle_ChangedConfigurationOutputPath_StartsWithNewPath() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var detector = new TestFileChangeDetector(); - var configurationFileEndpoint = new TestMonitorProjectConfigurationFilePathEndpoint( - () => detector, - _directoryPathResolver, - listeners: [], - LoggerFactory, - projectManager); - - var debugDirectory = PathUtilities.CreateRootedPath("externaldir", "obj", "Debug"); - var releaseDirectory = PathUtilities.CreateRootedPath("externaldir", "obj", "Release"); - var projectKeyDirectory = PathUtilities.CreateRootedPath("dir", "obj"); - var projectKey = TestProjectKey.Create(projectKeyDirectory); - - var debugOutputPath = new MonitorProjectConfigurationFilePathParams() - { - ProjectKeyId = projectKey.Id, - ConfigurationFilePath = Path.Combine(debugDirectory, "project.razor.bin") - }; - - var releaseOutputPath = new MonitorProjectConfigurationFilePathParams() - { - ProjectKeyId = projectKey.Id, - ConfigurationFilePath = Path.Combine(releaseDirectory, "project.razor.bin") - }; - - var requestContext = CreateRazorRequestContext(documentContext: null); - - // Act - await configurationFileEndpoint.HandleNotificationAsync(debugOutputPath, requestContext, DisposalToken); - await configurationFileEndpoint.HandleNotificationAsync(releaseOutputPath, requestContext, DisposalToken); - - // Assert - Assert.Equal([debugDirectory, releaseDirectory], detector.StartedWithDirectory); - Assert.Equal(1, detector.StopCount); - } - - [Fact] - public async Task Handle_ChangedConfigurationExternalToInternal_StopsWithoutRestarting() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var detector = new TestFileChangeDetector(); - var configurationFileEndpoint = new TestMonitorProjectConfigurationFilePathEndpoint( - () => detector, - _directoryPathResolver, - listeners: [], - LoggerFactory, - projectManager); - - var debugDirectory = PathUtilities.CreateRootedPath("externaldir", "obj", "Debug"); - var releaseDirectory = PathUtilities.CreateRootedPath("dir", "obj", "Release"); - var projectKeyDirectory = PathUtilities.CreateRootedPath("dir", "obj"); - var projectKey = TestProjectKey.Create(projectKeyDirectory); - - var externalRequest = new MonitorProjectConfigurationFilePathParams() - { - ProjectKeyId = projectKey.Id, - ConfigurationFilePath = Path.Combine(debugDirectory, "project.razor.bin") - }; - - var internalRequest = new MonitorProjectConfigurationFilePathParams() - { - ProjectKeyId = projectKey.Id, - ConfigurationFilePath = Path.Combine(releaseDirectory, "project.razor.bin") - }; - - var requestContext = CreateRazorRequestContext(documentContext: null); - - // Act - await configurationFileEndpoint.HandleNotificationAsync(externalRequest, requestContext, DisposalToken); - await configurationFileEndpoint.HandleNotificationAsync(internalRequest, requestContext, DisposalToken); - - // Assert - Assert.Equal([debugDirectory], detector.StartedWithDirectory); - Assert.Equal(1, detector.StopCount); - } - - [Fact] - public async Task Handle_ProjectPublished() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var callCount = 0; - var projectOpenDebugDetector = new TestFileChangeDetector(); - var releaseDetector = new TestFileChangeDetector(); - var postPublishDebugDetector = new TestFileChangeDetector(); - var detectors = new[] { projectOpenDebugDetector, releaseDetector, postPublishDebugDetector }; - var configurationFileEndpoint = new TestMonitorProjectConfigurationFilePathEndpoint( - () => detectors[callCount++], - _directoryPathResolver, - listeners: [], - LoggerFactory, - projectManager); - - var debugDirectory = PathUtilities.CreateRootedPath("externaldir1", "obj", "Debug"); - var releaseDirectory = PathUtilities.CreateRootedPath("externaldir1", "obj", "Release"); - var projectKeyDirectory = PathUtilities.CreateRootedPath("dir", "obj"); - var projectKey = TestProjectKey.Create(projectKeyDirectory); - - var debugOutputPath = new MonitorProjectConfigurationFilePathParams() - { - ProjectKeyId = projectKey.Id, - ConfigurationFilePath = Path.Combine(debugDirectory, "project.razor.bin") - }; - - var releaseOutputPath = new MonitorProjectConfigurationFilePathParams() - { - ProjectKeyId = projectKey.Id, - ConfigurationFilePath = Path.Combine(releaseDirectory, "project.razor.bin") - }; - - var requestContext = CreateRazorRequestContext(documentContext: null); - - // Act - - // Project opened, defaults to Debug output path - await configurationFileEndpoint.HandleNotificationAsync(debugOutputPath, requestContext, DisposalToken); - - // Project published (temporarily moves to release output path) - await configurationFileEndpoint.HandleNotificationAsync(releaseOutputPath, requestContext, DisposalToken); - - // Project publish finished (moves back to debug output path) - await configurationFileEndpoint.HandleNotificationAsync(debugOutputPath, requestContext, DisposalToken); - - // Assert - Assert.Equal(1, projectOpenDebugDetector.StartCount); - Assert.Equal(1, projectOpenDebugDetector.StopCount); - Assert.Equal(1, releaseDetector.StartCount); - Assert.Equal(1, releaseDetector.StopCount); - Assert.Equal(1, postPublishDebugDetector.StartCount); - Assert.Equal(0, postPublishDebugDetector.StopCount); - } - - [Fact] - public async Task Handle_MultipleProjects_StartedAndStopped() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var callCount = 0; - var debug1Detector = new TestFileChangeDetector(); - var debug2Detector = new TestFileChangeDetector(); - var release1Detector = new TestFileChangeDetector(); - var detectors = new[] { debug1Detector, debug2Detector, release1Detector }; - var configurationFileEndpoint = new TestMonitorProjectConfigurationFilePathEndpoint( - () => detectors[callCount++], - _directoryPathResolver, - listeners: [], - LoggerFactory, - projectManager); - - var debugDirectory1 = PathUtilities.CreateRootedPath("externaldir1", "obj", "Debug"); - var releaseDirectory1 = PathUtilities.CreateRootedPath("externaldir1", "obj", "Release"); - var debugDirectory2 = PathUtilities.CreateRootedPath("externaldir2", "obj", "Debug"); - var projectKeyDirectory1 = PathUtilities.CreateRootedPath("dir", "obj"); - var projectKey1 = TestProjectKey.Create(projectKeyDirectory1); - var projectKeyDirectory2 = PathUtilities.CreateRootedPath("dir", "obj2"); - var projectKey2 = TestProjectKey.Create(projectKeyDirectory2); - - var debugOutputPath1 = new MonitorProjectConfigurationFilePathParams() - { - ProjectKeyId = projectKey1.Id, - ConfigurationFilePath = Path.Combine(debugDirectory1, "project.razor.bin") - }; - - var releaseOutputPath1 = new MonitorProjectConfigurationFilePathParams() - { - ProjectKeyId = projectKey1.Id, - ConfigurationFilePath = Path.Combine(releaseDirectory1, "project.razor.bin") - }; - - var debugOutputPath2 = new MonitorProjectConfigurationFilePathParams() - { - ProjectKeyId = projectKey2.Id, - ConfigurationFilePath = Path.Combine(debugDirectory2, "project.razor.bin") - }; - - var requestContext = CreateRazorRequestContext(documentContext: null); - - // Act - await configurationFileEndpoint.HandleNotificationAsync(debugOutputPath1, requestContext, DisposalToken); - await configurationFileEndpoint.HandleNotificationAsync(debugOutputPath2, requestContext, DisposalToken); - await configurationFileEndpoint.HandleNotificationAsync(releaseOutputPath1, requestContext, DisposalToken); - - // Assert - Assert.Equal(1, debug1Detector.StartCount); - Assert.Equal(1, debug1Detector.StopCount); - Assert.Equal(1, debug2Detector.StartCount); - Assert.Equal(0, debug2Detector.StopCount); - Assert.Equal(1, release1Detector.StartCount); - Assert.Equal(0, release1Detector.StopCount); - } - - [Fact] - public async Task Handle_ConfigurationFilePath_TrackedMonitor_RemovesProject() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - - var detector = new TestFileChangeDetector(); - var configurationFileEndpoint = new TestMonitorProjectConfigurationFilePathEndpoint( - () => detector, - _directoryPathResolver, - listeners: [], - LoggerFactory, - projectManager, - options: new TestLanguageServerFeatureOptions(monitorWorkspaceFolderForConfigurationFiles: false)); - - var debugDirectory = PathUtilities.CreateRootedPath("externaldir", "obj", "Debug"); - var projectKeyDirectory = PathUtilities.CreateRootedPath("dir", "obj"); - var projectKey = TestProjectKey.Create(projectKeyDirectory); - - //projectSnapshotManagerAccessor - // .Setup(a => a.Instance.ProjectRemoved(It.IsAny())) - // .Callback((ProjectKey key) => Assert.Equal(projectKey, key)); - - var startRequest = new MonitorProjectConfigurationFilePathParams() - { - ProjectKeyId = projectKey.Id, - ConfigurationFilePath = Path.Combine(debugDirectory, "project.razor.bin") - }; - var requestContext = CreateRazorRequestContext(documentContext: null); - await configurationFileEndpoint.HandleNotificationAsync(startRequest, requestContext, DisposalToken); - var stopRequest = new MonitorProjectConfigurationFilePathParams() - { - ProjectKeyId = projectKey.Id, - ConfigurationFilePath = null!, - }; - - // Act - await configurationFileEndpoint.HandleNotificationAsync(stopRequest, requestContext, DisposalToken); - - // Assert - Assert.Equal(1, detector.StartCount); - Assert.Equal(1, detector.StopCount); - } - - private class TestMonitorProjectConfigurationFilePathEndpoint( - Func fileChangeDetectorFactory, - WorkspaceDirectoryPathResolver workspaceDirectoryPathResolver, - IEnumerable listeners, - ILoggerFactory loggerFactory, - IProjectSnapshotManager projectManager, - LanguageServerFeatureOptions? options = null) - : MonitorProjectConfigurationFilePathEndpoint( - projectManager, - workspaceDirectoryPathResolver, - listeners, - options ?? TestLanguageServerFeatureOptions.Instance, - loggerFactory) - { - private readonly Func _fileChangeDetectorFactory = fileChangeDetectorFactory ?? (() => Mock.Of(MockBehavior.Strict)); - - protected override IFileChangeDetector CreateFileChangeDetector() => _fileChangeDetectorFactory(); - } - - private class TestFileChangeDetector : IFileChangeDetector - { - public int StartCount => StartedWithDirectory.Count; - - public List StartedWithDirectory { get; } = new List(); - - public int StopCount { get; private set; } - - public Task StartAsync(string workspaceDirectory, CancellationToken cancellationToken) - { - StartedWithDirectory.Add(workspaceDirectory); - return Task.CompletedTask; - } - - public void Stop() - { - StopCount++; - } - } -} diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/ProjectConfigurationFileChangeDetectorTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/ProjectConfigurationFileChangeDetectorTest.cs deleted file mode 100644 index 8987b488c47..00000000000 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/ProjectConfigurationFileChangeDetectorTest.cs +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor.LanguageServer.Common; -using Microsoft.AspNetCore.Razor.Test.Common; -using Microsoft.AspNetCore.Razor.Test.Common.LanguageServer; -using Microsoft.AspNetCore.Razor.Test.Common.Workspaces; -using Microsoft.CodeAnalysis.Razor.Logging; -using Moq; -using Xunit; -using Xunit.Abstractions; - -namespace Microsoft.AspNetCore.Razor.LanguageServer; - -public class ProjectConfigurationFileChangeDetectorTest(ITestOutputHelper testOutput) : LanguageServerTestBase(testOutput) -{ - [Fact] - public async Task StartAsync_NotifiesListenersOfExistingConfigurationFiles() - { - // Arrange - var eventArgs1 = new List(); - var listenerMock1 = new StrictMock(); - listenerMock1 - .Setup(l => l.ProjectConfigurationFileChanged(It.IsAny())) - .Callback(eventArgs1.Add); - - var eventArgs2 = new List(); - var listenerMock2 = new StrictMock(); - listenerMock2 - .Setup(l => l.ProjectConfigurationFileChanged(It.IsAny())) - .Callback(eventArgs2.Add); - - ImmutableArray existingConfigurationFiles = ["c:/path/to/project.razor.json", "c:/other/path/project.razor.bin"]; - - var detector = new TestProjectConfigurationFileChangeDetector( - [listenerMock1.Object, listenerMock2.Object], - existingConfigurationFiles, - LoggerFactory); - - // Act - await detector.StartAsync("/some/workspace+directory", DisposalToken); - - // Assert - Assert.Collection(eventArgs1, - args => - { - Assert.Equal(RazorFileChangeKind.Added, args.Kind); - Assert.Equal(existingConfigurationFiles[0], args.ConfigurationFilePath); - }, - args => - { - Assert.Equal(RazorFileChangeKind.Added, args.Kind); - Assert.Equal(existingConfigurationFiles[1], args.ConfigurationFilePath); - }); - Assert.Collection(eventArgs2, - args => - { - Assert.Equal(RazorFileChangeKind.Added, args.Kind); - Assert.Equal(existingConfigurationFiles[0], args.ConfigurationFilePath); - }, - args => - { - Assert.Equal(RazorFileChangeKind.Added, args.Kind); - Assert.Equal(existingConfigurationFiles[1], args.ConfigurationFilePath); - }); - } - - private class TestProjectConfigurationFileChangeDetector( - IEnumerable listeners, - ImmutableArray existingConfigurationFiles, - ILoggerFactory loggerFactory) - : ProjectConfigurationFileChangeDetector(listeners, TestLanguageServerFeatureOptions.Instance, loggerFactory) - { - private readonly ImmutableArray _existingConfigurationFiles = existingConfigurationFiles; - - protected override bool InitializeFileWatchers => false; - - protected override ImmutableArray GetExistingConfigurationFiles(string workspaceDirectory) - => _existingConfigurationFiles; - } -} diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/ProjectConfigurationFileChangeEventArgsTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/ProjectConfigurationFileChangeEventArgsTest.cs deleted file mode 100644 index 78fa93d8d2d..00000000000 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/ProjectConfigurationFileChangeEventArgsTest.cs +++ /dev/null @@ -1,143 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using Microsoft.AspNetCore.Razor.Language; -using Microsoft.AspNetCore.Razor.LanguageServer.Common; -using Microsoft.AspNetCore.Razor.LanguageServer.Serialization; -using Microsoft.AspNetCore.Razor.ProjectSystem; -using Microsoft.AspNetCore.Razor.Test.Common; -using Microsoft.AspNetCore.Razor.Test.Common.Workspaces; -using Moq; -using Xunit; -using Xunit.Abstractions; - -namespace Microsoft.AspNetCore.Razor.LanguageServer; - -public class ProjectConfigurationFileChangeEventArgsTest(ITestOutputHelper testOutput) : ToolingTestBase(testOutput) -{ - [Fact] - public void TryDeserialize_RemovedKind_ReturnsFalse() - { - // Arrange - var deserializerMock = new StrictMock(); - deserializerMock - .Setup(x => x.DeserializeFromFile(It.IsAny())) - .Returns(new RazorProjectInfo( - new ProjectKey("/path/to/obj/"), - "c:/path/to/project.csproj", - configuration: RazorConfiguration.Default, - rootNamespace: null, - displayName: "project", - projectWorkspaceState: ProjectWorkspaceState.Default, - documents: [])); - - var args = new ProjectConfigurationFileChangeEventArgs( - configurationFilePath: "/path/to/obj/project.razor.bin", - kind: RazorFileChangeKind.Removed, - deserializer: deserializerMock.Object); - - // Act - var result = args.TryDeserialize(TestLanguageServerFeatureOptions.Instance, out var handle); - - // Assert - Assert.False(result); - Assert.Null(handle); - } - - [Fact] - [WorkItem("https://github.com/dotnet/razor-tooling/issues/5907")] - public void TryDeserialize_DifferingSerializationPaths_ReturnsFalse() - { - // Arrange - var deserializerMock = new StrictMock(); - var projectInfo = new RazorProjectInfo( - new ProjectKey("/path/to/ORIGINAL/obj/"), - "c:/path/to/project.csproj", - configuration: RazorConfiguration.Default, - rootNamespace: null, - displayName: "project", - projectWorkspaceState: ProjectWorkspaceState.Default, - documents: []); - - deserializerMock - .Setup(x => x.DeserializeFromFile(It.IsAny())) - .Returns(projectInfo); - - var args = new ProjectConfigurationFileChangeEventArgs( - configurationFilePath: "/path/to/DIFFERENT/obj/project.razor.bin", - kind: RazorFileChangeKind.Added, - deserializer: deserializerMock.Object); - - // Act - var result = args.TryDeserialize(TestLanguageServerFeatureOptions.Instance, out var deserializedProjectInfo); - - // Assert - Assert.False(result); - Assert.Null(deserializedProjectInfo); - } - - [Fact] - public void TryDeserialize_MemoizesResults() - { - // Arrange - var deserializerMock = new StrictMock(); - var projectInfo = new RazorProjectInfo( - new ProjectKey("/path/to/obj/"), - "c:/path/to/project.csproj", - configuration: RazorConfiguration.Default with { LanguageServerFlags = TestLanguageServerFeatureOptions.Instance.ToLanguageServerFlags() }, - rootNamespace: null, - displayName: "project", - projectWorkspaceState: ProjectWorkspaceState.Default, - documents: []); - - deserializerMock - .Setup(x => x.DeserializeFromFile(It.IsAny())) - .Returns(projectInfo); - - var args = new ProjectConfigurationFileChangeEventArgs( - configurationFilePath: "/path/to/obj/project.razor.bin", - kind: RazorFileChangeKind.Added, - deserializer: deserializerMock.Object); - - // Act - var result1 = args.TryDeserialize(TestLanguageServerFeatureOptions.Instance, out var projectInfo1); - var result2 = args.TryDeserialize(TestLanguageServerFeatureOptions.Instance, out var projectInfo2); - - // Assert - Assert.True(result1); - Assert.True(result2); - - // Deserialization will cause the LanguageServerFlags to be updated on the configuration, so reference equality will not hold. - // Test equality, and that retrieving same instance on repeat calls works by reference equality of projectInfo1 and projectInfo2. - Assert.Equal(projectInfo, projectInfo1); - Assert.Same(projectInfo1, projectInfo2); - } - - [Fact] - public void TryDeserialize_NullFileDeserialization_MemoizesResults_ReturnsFalse() - { - // Arrange - var deserializerMock = new StrictMock(); - var callCount = 0; - deserializerMock - .Setup(x => x.DeserializeFromFile(It.IsAny())) - .Callback(() => callCount++) - .Returns(null); - - var args = new ProjectConfigurationFileChangeEventArgs( - configurationFilePath: "/path/to/obj/project.razor.bin", - kind: RazorFileChangeKind.Changed, - deserializer: deserializerMock.Object); - - // Act - var result1 = args.TryDeserialize(TestLanguageServerFeatureOptions.Instance, out var handle1); - var result2 = args.TryDeserialize(TestLanguageServerFeatureOptions.Instance, out var handle2); - - // Assert - Assert.False(result1); - Assert.False(result2); - Assert.Null(handle1); - Assert.Null(handle2); - Assert.Equal(1, callCount); - } -} diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/ProjectConfigurationStateSynchronizerTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/ProjectConfigurationStateSynchronizerTest.cs deleted file mode 100644 index 77128251faa..00000000000 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/ProjectConfigurationStateSynchronizerTest.cs +++ /dev/null @@ -1,831 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System; -using System.Collections.Immutable; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor.Language; -using Microsoft.AspNetCore.Razor.LanguageServer.Common; -using Microsoft.AspNetCore.Razor.LanguageServer.ProjectSystem; -using Microsoft.AspNetCore.Razor.LanguageServer.Serialization; -using Microsoft.AspNetCore.Razor.ProjectSystem; -using Microsoft.AspNetCore.Razor.Serialization; -using Microsoft.AspNetCore.Razor.Test.Common; -using Microsoft.AspNetCore.Razor.Test.Common.LanguageServer; -using Microsoft.AspNetCore.Razor.Test.Common.ProjectSystem; -using Microsoft.AspNetCore.Razor.Test.Common.Workspaces; -using Microsoft.AspNetCore.Razor.Utilities; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Razor.Logging; -using Microsoft.CodeAnalysis.Razor.Workspaces; -using Moq; -using Xunit; -using Xunit.Abstractions; - -namespace Microsoft.AspNetCore.Razor.LanguageServer; - -public class ProjectConfigurationStateSynchronizerTest(ITestOutputHelper testOutput) : LanguageServerTestBase(testOutput) -{ - [Fact] - public async Task ProjectConfigurationFileChanged_Removed_UntrackedProject_CallsUpdate() - { - // Arrange - var args = new ProjectConfigurationFileChangeEventArgs( - configurationFilePath: "/path/to/project.razor.bin", - kind: RazorFileChangeKind.Removed, - deserializer: StrictMock.Of()); - - var intermediateOutputPath = FilePathNormalizer.GetNormalizedDirectoryName(args.ConfigurationFilePath); - var projectKey = new ProjectKey(intermediateOutputPath); - - var projectServiceMock = new StrictMock(); - projectServiceMock - .Setup(p => p.UpdateProjectAsync( - projectKey, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>(), - It.IsAny())) - .Returns(Task.CompletedTask); - - using var synchronizer = GetSynchronizer(projectServiceMock.Object); - var synchronizerAccessor = synchronizer.GetTestAccessor(); - - // Act - synchronizer.ProjectConfigurationFileChanged(args); - - await synchronizerAccessor.WaitUntilCurrentBatchCompletesAsync(); - - // Assert - projectServiceMock.VerifyAll(); - } - - [Fact] - public async Task ProjectConfigurationFileChanged_Removed_NonNormalizedPaths() - { - // Arrange - var projectInfo = new RazorProjectInfo( - new ProjectKey("/path/to/obj/"), - "path/to/project.csproj", - RazorConfiguration.Default, - rootNamespace: "TestRootNamespace", - displayName: "project", - ProjectWorkspaceState.Create(LanguageVersion.CSharp5), - documents: []); - var intermediateOutputPath = projectInfo.ProjectKey.Id; - var projectKey = TestProjectKey.Create(intermediateOutputPath); - - var projectServiceMock = new StrictMock(); - projectServiceMock - .Setup(x => x.AddOrUpdateProjectAsync( - projectKey, - projectInfo.FilePath, - It.IsAny(), - projectInfo.RootNamespace, - projectInfo.DisplayName, - projectInfo.ProjectWorkspaceState, - projectInfo.Documents, - It.IsAny())) - .Returns(Task.CompletedTask) - .Verifiable(); - projectServiceMock - .Setup(x => x.UpdateProjectAsync( - projectKey, - null, - null, - "", - ProjectWorkspaceState.Default, - ImmutableArray.Empty, - It.IsAny())) - .Returns(Task.CompletedTask) - .Verifiable(); - - using var synchronizer = GetSynchronizer(projectServiceMock.Object); - var synchronizerAccessor = synchronizer.GetTestAccessor(); - - var addArgs = new ProjectConfigurationFileChangeEventArgs( - "/path/to\\obj/project.razor.bin", - RazorFileChangeKind.Added, - CreateDeserializer(projectInfo)); - - synchronizer.ProjectConfigurationFileChanged(addArgs); - - await synchronizerAccessor.WaitUntilCurrentBatchCompletesAsync(); - - var removeArgs = new ProjectConfigurationFileChangeEventArgs( - configurationFilePath: "/path/to/obj/project.razor.bin", - kind: RazorFileChangeKind.Removed, - deserializer: StrictMock.Of()); - - // Act - synchronizer.ProjectConfigurationFileChanged(removeArgs); - - await synchronizerAccessor.WaitUntilCurrentBatchCompletesAsync(); - - // Assert - projectServiceMock.VerifyAll(); - } - - [Fact] - public async Task ProjectConfigurationFileChanged_Added_CantDeserialize_Noops() - { - // Arrange - var projectServiceMock = new StrictMock(); - - using var synchronizer = GetSynchronizer(projectServiceMock.Object); - var synchronizerAccessor = synchronizer.GetTestAccessor(); - - var args = new ProjectConfigurationFileChangeEventArgs( - configurationFilePath: "/path/to/project.razor.bin", - kind: RazorFileChangeKind.Added, - deserializer: CreateDeserializer(projectInfo: null)); - - // Act - synchronizer.ProjectConfigurationFileChanged(args); - - await synchronizerAccessor.WaitUntilCurrentBatchCompletesAsync(); - - // Assert - projectServiceMock.VerifyAll(); - } - - [Fact] - public async Task ProjectConfigurationFileChanged_Added_AddAndUpdatesProject() - { - // Arrange - var projectInfo = new RazorProjectInfo( - new ProjectKey("/path/to/obj/"), - "path/to/project.csproj", - RazorConfiguration.Default, - rootNamespace: "TestRootNamespace", - displayName: "project", - ProjectWorkspaceState.Create(LanguageVersion.CSharp5), - documents: []); - var intermediateOutputPath = projectInfo.ProjectKey.Id; - var projectKey = TestProjectKey.Create(intermediateOutputPath); - - var projectServiceMock = new StrictMock(); - projectServiceMock - .Setup(service => service.AddOrUpdateProjectAsync( - projectKey, - projectInfo.FilePath, - It.IsAny(), - projectInfo.RootNamespace, - projectInfo.DisplayName, - projectInfo.ProjectWorkspaceState, - projectInfo.Documents, - It.IsAny())) - .Returns(Task.CompletedTask) - .Verifiable(); - - using var synchronizer = GetSynchronizer(projectServiceMock.Object); - var synchronizerAccessor = synchronizer.GetTestAccessor(); - - var args = new ProjectConfigurationFileChangeEventArgs( - configurationFilePath: "/path/to/obj/project.razor.bin", - kind: RazorFileChangeKind.Added, - deserializer: CreateDeserializer(projectInfo)); - - // Act - synchronizer.ProjectConfigurationFileChanged(args); - - await synchronizerAccessor.WaitUntilCurrentBatchCompletesAsync(); - - // Assert - projectServiceMock.VerifyAll(); - } - - [Fact] - public async Task ProjectConfigurationFileChanged_Removed_ResetsProject() - { - // Arrange - var projectInfo = new RazorProjectInfo( - new ProjectKey("/path/to/obj/"), - "path/to/project.csproj", - RazorConfiguration.Default, - rootNamespace: "TestRootNamespace", - displayName: "project", - ProjectWorkspaceState.Create(LanguageVersion.CSharp5), - documents: []); - var intermediateOutputPath = projectInfo.ProjectKey.Id; - var projectKey = TestProjectKey.Create(intermediateOutputPath); - - var projectServiceMock = new StrictMock(); - projectServiceMock - .Setup(service => service.AddOrUpdateProjectAsync( - projectKey, - projectInfo.FilePath, - It.IsAny(), - projectInfo.RootNamespace, - projectInfo.DisplayName, - projectInfo.ProjectWorkspaceState, - projectInfo.Documents, - It.IsAny())) - .Returns(Task.CompletedTask) - .Verifiable(); - projectServiceMock - .Setup(service => service.UpdateProjectAsync( - projectKey, - null, - null, - "", - ProjectWorkspaceState.Default, - ImmutableArray.Empty, - It.IsAny())) - .Returns(Task.CompletedTask) - .Verifiable(); - - using var synchronizer = GetSynchronizer(projectServiceMock.Object); - var synchronizerAccessor = synchronizer.GetTestAccessor(); - - var addArgs = new ProjectConfigurationFileChangeEventArgs( - configurationFilePath: "/path/to/obj/project.razor.bin", - kind: RazorFileChangeKind.Added, - deserializer: CreateDeserializer(projectInfo)); - - synchronizer.ProjectConfigurationFileChanged(addArgs); - - await synchronizerAccessor.WaitUntilCurrentBatchCompletesAsync(); - - var removeArgs = new ProjectConfigurationFileChangeEventArgs( - configurationFilePath: "/path/to/obj/project.razor.bin", - kind: RazorFileChangeKind.Removed, - deserializer: StrictMock.Of()); - - // Act - synchronizer.ProjectConfigurationFileChanged(removeArgs); - - await synchronizerAccessor.WaitUntilCurrentBatchCompletesAsync(); - - // Assert - projectServiceMock.VerifyAll(); - } - - [Fact] - public async Task ProjectConfigurationFileChanged_Changed_UpdatesProject() - { - // Arrange - var initialProjectInfo = new RazorProjectInfo( - new ProjectKey("/path/to/obj/"), - "path/to/project.csproj", - RazorConfiguration.Default, - rootNamespace: "TestRootNamespace", - displayName: "project", - ProjectWorkspaceState.Create(LanguageVersion.CSharp5), - documents: []); - var intermediateOutputPath = initialProjectInfo.ProjectKey.Id; - var projectKey = TestProjectKey.Create(intermediateOutputPath); - - var projectServiceMock = new StrictMock(); - projectServiceMock - .Setup(service => service.AddOrUpdateProjectAsync( - projectKey, - initialProjectInfo.FilePath, - It.IsAny(), - initialProjectInfo.RootNamespace, - initialProjectInfo.DisplayName, - initialProjectInfo.ProjectWorkspaceState, - initialProjectInfo.Documents, - It.IsAny())) - .Returns(Task.CompletedTask) - .Verifiable(); - var changedProjectInfo = new RazorProjectInfo( - new ProjectKey("/path/to/obj/"), - "path/to/project.csproj", - new(RazorLanguageVersion.Experimental, - "TestConfiguration", - Extensions: []), - rootNamespace: "TestRootNamespace2", - displayName: "project", - ProjectWorkspaceState.Create(LanguageVersion.CSharp6), - documents: []); - projectServiceMock - .Setup(service => service.AddOrUpdateProjectAsync( - projectKey, - changedProjectInfo.FilePath, - It.IsAny(), - changedProjectInfo.RootNamespace, - changedProjectInfo.DisplayName, - changedProjectInfo.ProjectWorkspaceState, - changedProjectInfo.Documents, - It.IsAny())) - .Returns(Task.CompletedTask) - .Verifiable(); - - using var synchronizer = GetSynchronizer(projectServiceMock.Object); - var synchronizerAccessor = synchronizer.GetTestAccessor(); - - var addArgs = new ProjectConfigurationFileChangeEventArgs( - configurationFilePath: "/path/to/obj/project.razor.bin", - kind: RazorFileChangeKind.Added, - deserializer: CreateDeserializer(initialProjectInfo)); - - synchronizer.ProjectConfigurationFileChanged(addArgs); - - await synchronizerAccessor.WaitUntilCurrentBatchCompletesAsync(); - - var changedArgs = new ProjectConfigurationFileChangeEventArgs( - configurationFilePath: "/path/to/obj/project.razor.bin", - kind: RazorFileChangeKind.Changed, - deserializer: CreateDeserializer(changedProjectInfo)); - - // Act - synchronizer.ProjectConfigurationFileChanged(changedArgs); - - await synchronizerAccessor.WaitUntilCurrentBatchCompletesAsync(); - - // Assert - projectServiceMock.VerifyAll(); - } - - [Fact] - public async Task ProjectConfigurationFileChanged_Changed_CantDeserialize_ResetsProject() - { - // Arrange - var initialProjectInfo = new RazorProjectInfo( - new ProjectKey("/path/to/obj/"), - "path/to/project.csproj", - RazorConfiguration.Default, - rootNamespace: "TestRootNamespace", - displayName: "project", - ProjectWorkspaceState.Create(LanguageVersion.CSharp5), - documents: []); - var intermediateOutputPath = initialProjectInfo.ProjectKey.Id; - var projectKey = TestProjectKey.Create(intermediateOutputPath); - - var projectServiceMock = new StrictMock(); - projectServiceMock - .Setup(service => service.AddOrUpdateProjectAsync( - projectKey, - initialProjectInfo.FilePath, - It.IsAny(), - initialProjectInfo.RootNamespace, - initialProjectInfo.DisplayName, - initialProjectInfo.ProjectWorkspaceState, - initialProjectInfo.Documents, - It.IsAny())) - .Returns(Task.CompletedTask) - .Verifiable(); - var changedProjectInfo = new RazorProjectInfo( - new ProjectKey("/path/to/obj/"), - "path/to/project.csproj", - new(RazorLanguageVersion.Experimental, - "TestConfiguration", - Extensions: []), - rootNamespace: "TestRootNamespace2", - displayName: "project", - ProjectWorkspaceState.Create(LanguageVersion.CSharp6), - documents: []); - - // This is the request that happens when the server is reset - projectServiceMock - .Setup(service => service.UpdateProjectAsync( - projectKey, - null, - null, - "", - ProjectWorkspaceState.Default, - ImmutableArray.Empty, - It.IsAny())) - .Returns(Task.CompletedTask) - .Verifiable(); - - using var synchronizer = GetSynchronizer(projectServiceMock.Object); - var synchronizerAccessor = synchronizer.GetTestAccessor(); - - var addArgs = new ProjectConfigurationFileChangeEventArgs( - configurationFilePath: "/path/to/obj/project.razor.bin", - kind: RazorFileChangeKind.Added, - deserializer: CreateDeserializer(initialProjectInfo)); - - synchronizer.ProjectConfigurationFileChanged(addArgs); - - await synchronizerAccessor.WaitUntilCurrentBatchCompletesAsync(); - - var changedArgs = new ProjectConfigurationFileChangeEventArgs( - configurationFilePath: "/path/to/obj/project.razor.bin", - kind: RazorFileChangeKind.Changed, - deserializer: CreateDeserializer(projectInfo: null)); - - // Act - synchronizer.ProjectConfigurationFileChanged(changedArgs); - - await synchronizerAccessor.WaitUntilCurrentBatchCompletesAsync(); - - // Assert - projectServiceMock.VerifyAll(); - } - - [Fact] - public async Task ProjectConfigurationFileChanged_Changed_UntrackedProject_CallsUpdate() - { - // Arrange - var changedArgs = new ProjectConfigurationFileChangeEventArgs( - configurationFilePath: "/path/to/obj/project.razor.bin", - kind: RazorFileChangeKind.Changed, - deserializer: CreateDeserializer(projectInfo: null)); - - var intermediateOutputPath = FilePathNormalizer.GetNormalizedDirectoryName(changedArgs.ConfigurationFilePath); - var projectKey = new ProjectKey(intermediateOutputPath); - - var projectServiceMock = new StrictMock(); - projectServiceMock - .Setup(p => p.UpdateProjectAsync( - projectKey, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>(), - It.IsAny())) - .Returns(Task.CompletedTask); - - using var synchronizer = GetSynchronizer(projectServiceMock.Object); - var synchronizerAccessor = synchronizer.GetTestAccessor(); - - // Act - synchronizer.ProjectConfigurationFileChanged(changedArgs); - - await synchronizerAccessor.WaitUntilCurrentBatchCompletesAsync(); - - // Assert - projectServiceMock.VerifyAll(); - } - - [Fact] - public async Task ProjectConfigurationFileChanged_RemoveThenAdd_Updates() - { - // Arrange - var projectInfo = new RazorProjectInfo( - new ProjectKey("/path/to/obj/"), - "path/to/project.csproj", - RazorConfiguration.Default, - rootNamespace: "TestRootNamespace", - displayName: "project", - ProjectWorkspaceState.Create(LanguageVersion.CSharp5), - documents: []); - - var configurationFilePath = projectInfo.ProjectKey.Id + "project.razor.bin"; - - var projectServiceMock = new StrictMock(); - projectServiceMock - .Setup(p => p.AddOrUpdateProjectAsync( - projectInfo.ProjectKey, - projectInfo.FilePath, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>(), - It.IsAny())) - .Returns(Task.CompletedTask); - - using var synchronizer = GetSynchronizer(projectServiceMock.Object); - var synchronizerAccessor = synchronizer.GetTestAccessor(); - - var deserializer = CreateDeserializer(projectInfo); - var removedArgs = new ProjectConfigurationFileChangeEventArgs(configurationFilePath, RazorFileChangeKind.Removed, deserializer); - var addedArgs = new ProjectConfigurationFileChangeEventArgs(configurationFilePath, RazorFileChangeKind.Added, deserializer); - - // Act - synchronizer.ProjectConfigurationFileChanged(removedArgs); - synchronizer.ProjectConfigurationFileChanged(addedArgs); - - await synchronizerAccessor.WaitUntilCurrentBatchCompletesAsync(); - - // Assert - projectServiceMock.Verify(p => p.AddOrUpdateProjectAsync( - projectInfo.ProjectKey, - projectInfo.FilePath, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>(), - It.IsAny()), - Times.Once); - - projectServiceMock.VerifyAll(); - } - - [Fact] - public async Task ProjectConfigurationFileChanged_AddThenRemove_AddsAndRemoves() - { - // Arrange - var projectInfo = new RazorProjectInfo( - new ProjectKey("/path/to/obj/"), - "path/to/project.csproj", - RazorConfiguration.Default, - rootNamespace: "TestRootNamespace", - displayName: "project", - ProjectWorkspaceState.Create(LanguageVersion.CSharp5), - documents: []); - - var configurationFilePath = projectInfo.ProjectKey.Id + "project.razor.bin"; - - var projectServiceMock = new StrictMock(); - projectServiceMock - .Setup(p => p.UpdateProjectAsync( - projectInfo.ProjectKey, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>(), - It.IsAny())) - .Returns(Task.CompletedTask); - - using var synchronizer = GetSynchronizer(projectServiceMock.Object); - var synchronizerAccessor = synchronizer.GetTestAccessor(); - - var deserializer = CreateDeserializer(projectInfo); - var addedArgs = new ProjectConfigurationFileChangeEventArgs(configurationFilePath, RazorFileChangeKind.Added, deserializer); - var removedArgs = new ProjectConfigurationFileChangeEventArgs(configurationFilePath, RazorFileChangeKind.Removed, deserializer); - - // Act - synchronizer.ProjectConfigurationFileChanged(addedArgs); - synchronizer.ProjectConfigurationFileChanged(removedArgs); - - await synchronizerAccessor.WaitUntilCurrentBatchCompletesAsync(); - - // Assert - projectServiceMock.Verify(p => p.UpdateProjectAsync( - projectInfo.ProjectKey, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>(), - It.IsAny()), - Times.Once); - - projectServiceMock.VerifyAll(); - } - - [Fact] - public async Task ProjectConfigurationFileChanged_AddThenRemoveThenAdd_AddsAndUpdates() - { - // Arrange - var projectInfo = new RazorProjectInfo( - new ProjectKey("/path/to/obj/"), - "path/to/project.csproj", - RazorConfiguration.Default, - rootNamespace: "TestRootNamespace", - displayName: "project", - ProjectWorkspaceState.Create(LanguageVersion.CSharp5), - documents: []); - - var configurationFilePath = projectInfo.ProjectKey.Id + "project.razor.bin"; - - var projectServiceMock = new StrictMock(); - projectServiceMock - .Setup(p => p.AddOrUpdateProjectAsync( - projectInfo.ProjectKey, - projectInfo.FilePath, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>(), - It.IsAny())) - .Returns(Task.CompletedTask); - - using var synchronizer = GetSynchronizer(projectServiceMock.Object); - var synchronizerAccessor = synchronizer.GetTestAccessor(); - - var deserializer = CreateDeserializer(projectInfo); - var addedArgs = new ProjectConfigurationFileChangeEventArgs(configurationFilePath, RazorFileChangeKind.Added, deserializer); - var removedArgs = new ProjectConfigurationFileChangeEventArgs(configurationFilePath, RazorFileChangeKind.Removed, deserializer); - - // Act - synchronizer.ProjectConfigurationFileChanged(addedArgs); - synchronizer.ProjectConfigurationFileChanged(removedArgs); - synchronizer.ProjectConfigurationFileChanged(addedArgs); - - await synchronizerAccessor.WaitUntilCurrentBatchCompletesAsync(); - - // Assert - projectServiceMock - .Verify(p => p.AddOrUpdateProjectAsync( - projectInfo.ProjectKey, - projectInfo.FilePath, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>(), - It.IsAny()), - Times.Once); - - projectServiceMock.VerifyAll(); - } - - [Fact] - public async Task ProjectConfigurationFileChanged_RemoveThenRemoveThenAdd_UpdatesTwice() - { - // Arrange - var projectInfo = new RazorProjectInfo( - new ProjectKey("/path/to/obj/"), - "path/to/project.csproj", - RazorConfiguration.Default, - rootNamespace: "TestRootNamespace", - displayName: "project", - ProjectWorkspaceState.Create(LanguageVersion.CSharp5), - documents: []); - - var configurationFilePath = projectInfo.ProjectKey.Id + "project.razor.bin"; - - var projectServiceMock = new StrictMock(); - projectServiceMock - .Setup(p => p.AddOrUpdateProjectAsync( - projectInfo.ProjectKey, - projectInfo.FilePath, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>(), - It.IsAny())) - .Returns(Task.CompletedTask); - - using var synchronizer = GetSynchronizer(projectServiceMock.Object); - var synchronizerAccessor = synchronizer.GetTestAccessor(); - - var deserializer = CreateDeserializer(projectInfo); - var addedArgs = new ProjectConfigurationFileChangeEventArgs(configurationFilePath, RazorFileChangeKind.Added, deserializer); - var removedArgs = new ProjectConfigurationFileChangeEventArgs(configurationFilePath, RazorFileChangeKind.Removed, deserializer); - - // Act - synchronizer.ProjectConfigurationFileChanged(removedArgs); - synchronizer.ProjectConfigurationFileChanged(removedArgs); - synchronizer.ProjectConfigurationFileChanged(addedArgs); - - await synchronizerAccessor.WaitUntilCurrentBatchCompletesAsync(); - - // Assert - projectServiceMock.Verify(p => p.AddOrUpdateProjectAsync( - projectInfo.ProjectKey, - projectInfo.FilePath, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>(), - It.IsAny()), - Times.Once); - - projectServiceMock.VerifyAll(); - } - - [Fact] - public async Task ProjectConfigurationFileChanged_AddThenRemoveThenAddThenUpdate_AddsAndUpdates() - { - // Arrange - var projectInfo = new RazorProjectInfo( - new ProjectKey("/path/to/obj/"), - "path/to/project.csproj", - RazorConfiguration.Default, - rootNamespace: "TestRootNamespace", - displayName: "project", - ProjectWorkspaceState.Create(LanguageVersion.CSharp5), - documents: []); - - var configurationFilePath = projectInfo.ProjectKey.Id + "project.razor.bin"; - - var projectServiceMock = new StrictMock(); - projectServiceMock - .Setup(p => p.AddOrUpdateProjectAsync( - projectInfo.ProjectKey, - projectInfo.FilePath, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>(), - It.IsAny())) - .Returns(Task.CompletedTask); - - using var synchronizer = GetSynchronizer(projectServiceMock.Object); - var synchronizerAccessor = synchronizer.GetTestAccessor(); - - var deserializer = CreateDeserializer(projectInfo); - var addedArgs = new ProjectConfigurationFileChangeEventArgs(configurationFilePath, RazorFileChangeKind.Added, deserializer); - var removedArgs = new ProjectConfigurationFileChangeEventArgs(configurationFilePath, RazorFileChangeKind.Removed, deserializer); - var changedArgs = new ProjectConfigurationFileChangeEventArgs(configurationFilePath, RazorFileChangeKind.Changed, deserializer); - - // Act - synchronizer.ProjectConfigurationFileChanged(addedArgs); - synchronizer.ProjectConfigurationFileChanged(removedArgs); - synchronizer.ProjectConfigurationFileChanged(addedArgs); - synchronizer.ProjectConfigurationFileChanged(changedArgs); - synchronizer.ProjectConfigurationFileChanged(changedArgs); - synchronizer.ProjectConfigurationFileChanged(changedArgs); - - await synchronizerAccessor.WaitUntilCurrentBatchCompletesAsync(); - - // Assert - // Update is only called twice because the Remove-then-Add is changed to an Update, - // then that Update is deduped with the one following - projectServiceMock - .Verify(p => p.AddOrUpdateProjectAsync( - projectInfo.ProjectKey, - projectInfo.FilePath, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>(), - It.IsAny()), - Times.Once); - - projectServiceMock.VerifyAll(); - } - - [Fact] - public async Task ProjectConfigurationFileChanged_RemoveThenAddDifferentProjects_RemovesAndAdds() - { - // Arrange - var projectInfo1 = new RazorProjectInfo( - new ProjectKey("/path/to/obj/"), - "path/to/project.csproj", - RazorConfiguration.Default, - rootNamespace: "TestRootNamespace", - displayName: "project", - ProjectWorkspaceState.Create(LanguageVersion.CSharp5), - documents: []); - - var configurationFilePath1 = projectInfo1.ProjectKey.Id + "project.razor.bin"; - - var projectInfo2 = new RazorProjectInfo( - new ProjectKey("/path/other/obj/"), - "path/other/project.csproj", - RazorConfiguration.Default, - rootNamespace: "TestRootNamespace", - displayName: "project", - ProjectWorkspaceState.Create(LanguageVersion.CSharp5), - documents: []); - - var configurationFilePath2 = projectInfo2.ProjectKey.Id + "project.razor.bin"; - - var projectServiceMock = new StrictMock(); - projectServiceMock - .Setup(p => p.UpdateProjectAsync( - projectInfo1.ProjectKey, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>(), - It.IsAny())) - .Returns(Task.CompletedTask); - projectServiceMock - .Setup(p => p.AddOrUpdateProjectAsync( - projectInfo2.ProjectKey, - projectInfo2.FilePath, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>(), - It.IsAny())) - .Returns(Task.CompletedTask); - - using var synchronizer = GetSynchronizer(projectServiceMock.Object); - var synchronizerAccessor = synchronizer.GetTestAccessor(); - - var removedArgs = new ProjectConfigurationFileChangeEventArgs(configurationFilePath1, RazorFileChangeKind.Removed, CreateDeserializer(projectInfo1)); - var addedArgs = new ProjectConfigurationFileChangeEventArgs(configurationFilePath2, RazorFileChangeKind.Added, CreateDeserializer(projectInfo2)); - - // Act - synchronizer.ProjectConfigurationFileChanged(removedArgs); - synchronizer.ProjectConfigurationFileChanged(addedArgs); - - await synchronizerAccessor.WaitUntilCurrentBatchCompletesAsync(); - - // Assert - projectServiceMock.VerifyAll(); - } - - private TestProjectConfigurationStateSynchronizer GetSynchronizer(IRazorProjectService razorProjectService) - => new(razorProjectService, LoggerFactory, TestLanguageServerFeatureOptions.Instance, TimeSpan.FromMilliseconds(50)); - - private static IRazorProjectInfoDeserializer CreateDeserializer(RazorProjectInfo? projectInfo) - { - var deserializerMock = new StrictMock(); - deserializerMock - .Setup(x => x.DeserializeFromFile(It.IsAny())) - .Returns(projectInfo); - - return deserializerMock.Object; - } - - private sealed class TestProjectConfigurationStateSynchronizer( - IRazorProjectService projectService, - ILoggerFactory loggerFactory, - LanguageServerFeatureOptions options, - TimeSpan delay) - : ProjectConfigurationStateSynchronizer(projectService, loggerFactory, options, delay); -} diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/ProtocolSerializer.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/ProtocolSerializer.cs new file mode 100644 index 00000000000..5be7f54eb80 --- /dev/null +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/ProtocolSerializer.cs @@ -0,0 +1,21 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using Microsoft.VisualStudio.LanguageServer.Protocol; +using Newtonsoft.Json; + +namespace Microsoft.AspNetCore.Razor.LanguageServer.Test; + +internal static class ProtocolSerializer +{ + public static JsonSerializer Instance { get; } = CreateSerializer(); + + private static JsonSerializer CreateSerializer() + { + var serializer = new JsonSerializer(); + serializer.AddVSInternalExtensionConverters(); + serializer.AddVSExtensionConverters(); + + return serializer; + } +} diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/RazorFileChangeDetectorManagerTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/RazorFileChangeDetectorManagerTest.cs index 91f017f679a..bcb359d3616 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/RazorFileChangeDetectorManagerTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/RazorFileChangeDetectorManagerTest.cs @@ -28,15 +28,13 @@ public async Task InitializedAsync_StartsFileChangeDetectors() Path = initialWorkspaceDirectory }; - var clientSettings = new InitializeParams() + var initializeParams = new InitializeParams() { RootUri = uriBuilder.Uri, }; - var languageServerMock = new StrictMock>(); - languageServerMock - .Setup(s => s.GetInitializeParams()) - .Returns(clientSettings); + var capabilitiesManager = new CapabilitiesManager(StrictMock.Of()); + capabilitiesManager.SetInitializeParams(initializeParams); var expectedWorkspaceDirectory = $"\\\\{initialWorkspaceDirectory}"; @@ -56,8 +54,7 @@ public async Task InitializedAsync_StartsFileChangeDetectors() detectorMock2.Setup(x => x.Stop()); - var workspaceDirectoryPathResolver = new DefaultWorkspaceDirectoryPathResolver(languageServerMock.Object); - using (var detectorManager = new RazorFileChangeDetectorManager(workspaceDirectoryPathResolver, [detectorMock1.Object, detectorMock2.Object])) + using (var detectorManager = new RazorFileChangeDetectorManager(capabilitiesManager, [detectorMock1.Object, detectorMock2.Object])) { // Act await detectorManager.OnInitializedAsync(StrictMock.Of(), DisposalToken); @@ -66,7 +63,6 @@ public async Task InitializedAsync_StartsFileChangeDetectors() // Assert detectorMock1.VerifyAll(); detectorMock2.VerifyAll(); - languageServerMock.VerifyAll(); } [Fact] @@ -74,15 +70,13 @@ public async Task InitializedAsync_Disposed_ReStopsFileChangeDetectors() { // Arrange var expectedWorkspaceDirectory = "\\\\testpath"; - var clientSettings = new InitializeParams() + var initializeParams = new InitializeParams() { RootUri = new Uri(expectedWorkspaceDirectory), }; - var languageServerMock = new StrictMock>(); - languageServerMock - .Setup(s => s.GetInitializeParams()) - .Returns(clientSettings); + var capabilitiesManager = new CapabilitiesManager(StrictMock.Of()); + capabilitiesManager.SetInitializeParams(initializeParams); var detectorMock = new StrictMock(); var cts = new TaskCompletionSource(); @@ -94,8 +88,7 @@ public async Task InitializedAsync_Disposed_ReStopsFileChangeDetectors() .Setup(d => d.Stop()) .Callback(() => stopCount++); - var workspaceDirectoryPathResolver = new DefaultWorkspaceDirectoryPathResolver(languageServerMock.Object); - using var detectorManager = new RazorFileChangeDetectorManager(workspaceDirectoryPathResolver, [detectorMock.Object]); + using var detectorManager = new RazorFileChangeDetectorManager(capabilitiesManager, [detectorMock.Object]); // Act var initializeTask = detectorManager.OnInitializedAsync(StrictMock.Of(), DisposalToken); @@ -107,7 +100,5 @@ public async Task InitializedAsync_Disposed_ReStopsFileChangeDetectors() // Assert Assert.Equal(2, stopCount); - - languageServerMock.VerifyAll(); } } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/RazorLanguageServerTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/RazorLanguageServerTest.cs index 786eb4e32e1..d50ba15bf29 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/RazorLanguageServerTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/RazorLanguageServerTest.cs @@ -14,8 +14,6 @@ using Xunit; using Xunit.Abstractions; -using RazorLanguageServerConstants = Microsoft.CodeAnalysis.Razor.Protocol.LanguageServerConstants; - namespace Microsoft.AspNetCore.Razor.LanguageServer.Test; public class RazorLanguageServerTest(ITestOutputHelper testOutput) : ToolingTestBase(testOutput) @@ -65,9 +63,6 @@ public void AllHandlersRegisteredAsync() // CLaSP will throw if two handlers register for the same method, so if THAT doesn't hold it's a CLaSP bug, not a Razor bug. var typeMethods = handlerTypes.Select(t => GetMethodFromType(t)).ToHashSet(); - // razor/projectInfo is currently behind a feature flag, so ignore it for now - typeMethods.Remove(RazorLanguageServerConstants.RazorProjectInfoEndpoint); - if (registeredMethods.Length != typeMethods.Count) { var unregisteredHandlers = typeMethods.Where(t => !registeredMethods.Any(m => m.MethodName == t)); diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/TestRazorProjectService.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/TestRazorProjectService.cs index 58e9a4a28b8..f0acf1af497 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/TestRazorProjectService.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/TestRazorProjectService.cs @@ -8,10 +8,12 @@ using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.LanguageServer.Common; using Microsoft.AspNetCore.Razor.Serialization; +using Microsoft.AspNetCore.Razor.Test.Common; using Microsoft.AspNetCore.Razor.Utilities; -using Microsoft.CodeAnalysis.Razor; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Razor.Logging; using Microsoft.CodeAnalysis.Razor.ProjectSystem; +using Moq; namespace Microsoft.AspNetCore.Razor.LanguageServer.ProjectSystem; @@ -20,10 +22,27 @@ internal class TestRazorProjectService( IDocumentVersionCache documentVersionCache, IProjectSnapshotManager projectManager, ILoggerFactory loggerFactory) - : RazorProjectService(remoteTextLoaderFactory, documentVersionCache, projectManager, loggerFactory) + : RazorProjectService( + projectManager, + CreateProjectInfoDriver(), + documentVersionCache, + remoteTextLoaderFactory, + loggerFactory) { private readonly IProjectSnapshotManager _projectManager = projectManager; + private static IRazorProjectInfoDriver CreateProjectInfoDriver() + { + var mock = new StrictMock(); + + mock.Setup(x => x.GetLatestProjectInfo()) + .Returns([]); + + mock.Setup(x => x.AddListener(It.IsAny())); + + return mock.Object; + } + public async Task AddDocumentToPotentialProjectsAsync(string textDocumentPath, CancellationToken cancellationToken) { foreach (var projectSnapshot in _projectManager.FindPotentialProjects(textDocumentPath)) @@ -39,19 +58,4 @@ await this.UpdateProjectAsync(projectSnapshot.Key, projectSnapshot.Configuration documents, cancellationToken).ConfigureAwait(false); } } - - private static string GetTargetPath(string documentFilePath, string normalizedProjectPath) - { - var targetFilePath = FilePathNormalizer.Normalize(documentFilePath); - if (targetFilePath.StartsWith(normalizedProjectPath, FilePathComparison.Instance)) - { - // Make relative - targetFilePath = documentFilePath[normalizedProjectPath.Length..]; - } - - // Representing all of our host documents with a re-normalized target path to workaround GetRelatedDocument limitations. - var normalizedTargetFilePath = targetFilePath.Replace('/', '\\').TrimStart('\\'); - - return normalizedTargetFilePath; - } } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/LanguageServer/LanguageServerTestBase.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/LanguageServer/LanguageServerTestBase.cs index 2e71b84ae97..f8566ecb6af 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/LanguageServer/LanguageServerTestBase.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/LanguageServer/LanguageServerTestBase.cs @@ -22,31 +22,18 @@ using Microsoft.CodeAnalysis.Razor.Workspaces; using Microsoft.CodeAnalysis.Text; using Microsoft.CommonLanguageServerProtocol.Framework; -using Microsoft.VisualStudio.LanguageServer.Protocol; using Moq; -using Newtonsoft.Json; using Xunit.Abstractions; namespace Microsoft.AspNetCore.Razor.Test.Common.LanguageServer; -public abstract class LanguageServerTestBase : ToolingTestBase +public abstract class LanguageServerTestBase(ITestOutputHelper testOutput) : ToolingTestBase(testOutput) { - private protected IRazorSpanMappingService SpanMappingService { get; } - private protected IFilePathService FilePathService { get; } + private ThrowingRazorSpanMappingService? _spanMappingService; + private LSPFilePathService? _filePathService; - protected JsonSerializer Serializer { get; } - - protected LanguageServerTestBase(ITestOutputHelper testOutput) - : base(testOutput) - { - SpanMappingService = new ThrowingRazorSpanMappingService(); - - Serializer = new JsonSerializer(); - Serializer.AddVSInternalExtensionConverters(); - Serializer.AddVSExtensionConverters(); - - FilePathService = new LSPFilePathService(TestLanguageServerFeatureOptions.Instance); - } + private protected IRazorSpanMappingService SpanMappingService => _spanMappingService ??= new(); + private protected IFilePathService FilePathService => _filePathService ??= new(TestLanguageServerFeatureOptions.Instance); private protected TestProjectSnapshotManager CreateProjectSnapshotManager() => CreateProjectSnapshotManager(ProjectEngineFactories.DefaultProvider); @@ -59,14 +46,10 @@ private protected TestProjectSnapshotManager CreateProjectSnapshotManager( DisposalToken, initializer: static updater => updater.ProjectAdded(MiscFilesHostProject.Instance)); - internal RazorRequestContext CreateRazorRequestContext(VersionedDocumentContext? documentContext, ILspServices? lspServices = null) - { - lspServices ??= new Mock(MockBehavior.Strict).Object; - - var requestContext = new RazorRequestContext(documentContext, lspServices, "lsp/method", uri: null); - - return requestContext; - } + private protected static RazorRequestContext CreateRazorRequestContext( + VersionedDocumentContext? documentContext, + ILspServices? lspServices = null) + => new(documentContext, lspServices ?? StrictMock.Of(), "lsp/method", uri: null); protected static RazorCodeDocument CreateCodeDocument(string text, ImmutableArray tagHelpers = default, string? filePath = null, string? rootNamespace = null) { @@ -104,22 +87,22 @@ @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web """, RazorSourceDocumentProperties.Create(importDocumentName, importDocumentName)); - var codeDocument = projectEngine.ProcessDesignTime(sourceDocument, fileKind, ImmutableArray.Create(defaultImportDocument), tagHelpers); + var codeDocument = projectEngine.ProcessDesignTime(sourceDocument, fileKind, [defaultImportDocument], tagHelpers); return codeDocument; } - internal static IDocumentContextFactory CreateDocumentContextFactory(Uri documentPath, string sourceText) + private protected static IDocumentContextFactory CreateDocumentContextFactory(Uri documentPath, string sourceText) { var codeDocument = CreateCodeDocument(sourceText); return CreateDocumentContextFactory(documentPath, codeDocument); } - internal static VersionedDocumentContext CreateDocumentContext(Uri documentPath, RazorCodeDocument codeDocument) + private protected static VersionedDocumentContext CreateDocumentContext(Uri documentPath, RazorCodeDocument codeDocument) { return TestDocumentContext.From(documentPath.GetAbsoluteOrUNCPath(), codeDocument, hostDocumentVersion: 1337); } - internal static IDocumentContextFactory CreateDocumentContextFactory( + private protected static IDocumentContextFactory CreateDocumentContextFactory( Uri documentPath, RazorCodeDocument codeDocument, bool documentFound = true) @@ -131,7 +114,7 @@ internal static IDocumentContextFactory CreateDocumentContextFactory( return documentContextFactory; } - internal static VersionedDocumentContext CreateDocumentContext(Uri uri, IDocumentSnapshot snapshot) + private protected static VersionedDocumentContext CreateDocumentContext(Uri uri, IDocumentSnapshot snapshot) { return new VersionedDocumentContext(uri, snapshot, projectContext: null, version: 0); } @@ -153,7 +136,7 @@ protected static TextLoader CreateTextLoader(string filePath, SourceText text) return mock.Object; } - internal static RazorLSPOptionsMonitor GetOptionsMonitor(bool enableFormatting = true, bool autoShowCompletion = true, bool autoListParams = true, bool formatOnType = true, bool autoInsertAttributeQuotes = true, bool colorBackground = false, bool codeBlockBraceOnNextLine = false, bool commitElementsWithSpace = true) + private protected static RazorLSPOptionsMonitor GetOptionsMonitor(bool enableFormatting = true, bool autoShowCompletion = true, bool autoListParams = true, bool formatOnType = true, bool autoInsertAttributeQuotes = true, bool colorBackground = false, bool codeBlockBraceOnNextLine = false, bool commitElementsWithSpace = true) { var configService = StrictMock.Of(); diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/Workspaces/TestLanguageServerFeatureOptions.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/Workspaces/TestLanguageServerFeatureOptions.cs index 18a3810e07d..7d15c6ba522 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/Workspaces/TestLanguageServerFeatureOptions.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/Workspaces/TestLanguageServerFeatureOptions.cs @@ -7,7 +7,6 @@ namespace Microsoft.AspNetCore.Razor.Test.Common.Workspaces; internal class TestLanguageServerFeatureOptions( bool includeProjectKeyInGeneratedFilePath = false, - bool monitorWorkspaceFolderForConfigurationFiles = true, bool forceRuntimeCodeGeneration = false) : LanguageServerFeatureOptions { public static readonly LanguageServerFeatureOptions Instance = new TestLanguageServerFeatureOptions(); @@ -36,13 +35,9 @@ internal class TestLanguageServerFeatureOptions( public override bool IncludeProjectKeyInGeneratedFilePath => includeProjectKeyInGeneratedFilePath; - public override bool MonitorWorkspaceFolderForConfigurationFiles => monitorWorkspaceFolderForConfigurationFiles; - public override bool UseRazorCohostServer => false; public override bool DisableRazorLanguageServer => false; public override bool ForceRuntimeCodeGeneration => forceRuntimeCodeGeneration; - - public override bool UseProjectConfigurationEndpoint => false; } diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/LanguageClient/DefaultProjectConfigurationFilePathStoreTest.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/LanguageClient/DefaultProjectConfigurationFilePathStoreTest.cs deleted file mode 100644 index 807f2fca8c8..00000000000 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/LanguageClient/DefaultProjectConfigurationFilePathStoreTest.cs +++ /dev/null @@ -1,216 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -#nullable disable - -using System.Collections.Generic; -using Microsoft.AspNetCore.Razor.Language; -using Microsoft.AspNetCore.Razor.ProjectSystem; -using Microsoft.AspNetCore.Razor.Test.Common; -using Microsoft.AspNetCore.Razor.Test.Common.ProjectSystem; -using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Xunit; -using Xunit.Abstractions; - -namespace Microsoft.VisualStudio.Razor.LanguageClient; - -public class DefaultProjectConfigurationFilePathStoreTest : ToolingTestBase -{ - public DefaultProjectConfigurationFilePathStoreTest(ITestOutputHelper testOutput) - : base(testOutput) - { - } - - [Fact] - public void Set_ResolvesRelativePaths() - { - // Arrange - var store = new DefaultProjectConfigurationFilePathStore(); - var projectFilePath = @"C:\project.csproj"; - var hostProject = new HostProject(projectFilePath, @"C:\project\obj", RazorConfiguration.Default, null); - var configurationFilePath = @"C:\project\subpath\..\obj\project.razor.bin"; - var called = false; - store.Changed += (sender, args) => - { - called = true; - Assert.Equal(hostProject.Key, args.ProjectKey); - Assert.Equal(@"C:\project\obj\project.razor.bin", args.ConfigurationFilePath); - }; - - // Act - store.Set(hostProject.Key, configurationFilePath); - - // Assert - Assert.True(called); - } - - [Fact] - public void Set_InvokesChanged() - { - // Arrange - var store = new DefaultProjectConfigurationFilePathStore(); - var projectFilePath = @"C:\project.csproj"; - var hostProject = new HostProject(projectFilePath, @"C:\project\obj", RazorConfiguration.Default, null); - var configurationFilePath = @"C:\project\obj\project.razor.bin"; - var called = false; - store.Changed += (sender, args) => - { - called = true; - Assert.Equal(hostProject.Key, args.ProjectKey); - Assert.Equal(configurationFilePath, args.ConfigurationFilePath); - }; - - // Act - store.Set(hostProject.Key, configurationFilePath); - - // Assert - Assert.True(called); - } - - [Fact] - public void Set_SameConfigurationFilePath_DoesNotInvokeChanged() - { - // Arrange - var store = new DefaultProjectConfigurationFilePathStore(); - var projectFilePath = @"C:\project.csproj"; - var hostProject = new HostProject(projectFilePath, @"C:\project\obj", RazorConfiguration.Default, null); - var configurationFilePath = @"C:\project\obj\project.razor.bin"; - store.Set(hostProject.Key, configurationFilePath); - var called = false; - store.Changed += (sender, args) => called = true; - - // Act - store.Set(hostProject.Key, configurationFilePath); - - // Assert - Assert.False(called); - } - - [Fact] - public void Set_AllowsTryGet() - { - // Arrange - var store = new DefaultProjectConfigurationFilePathStore(); - var projectFilePath = @"C:\project.csproj"; - var hostProject = new HostProject(projectFilePath, @"C:\project\obj", RazorConfiguration.Default, null); - var expectedConfigurationFilePath = @"C:\project\obj\project.razor.bin"; - store.Set(hostProject.Key, expectedConfigurationFilePath); - - // Act - var result = store.TryGet(hostProject.Key, out var configurationFilePath); - - // Assert - Assert.True(result); - Assert.Equal(expectedConfigurationFilePath, configurationFilePath); - } - - [Fact] - public void Set_OverridesPrevious() - { - // Arrange - var store = new DefaultProjectConfigurationFilePathStore(); - var projectFilePath = @"C:\project.csproj"; - var hostProject = new HostProject(projectFilePath, @"C:\project\obj", RazorConfiguration.Default, null); - var expectedConfigurationFilePath = @"C:\project\obj\project.razor.bin"; - - // Act - store.Set(hostProject.Key, @"C:\other\obj\project.razor.bin"); - store.Set(hostProject.Key, expectedConfigurationFilePath); - - // Assert - var result = store.TryGet(hostProject.Key, out var configurationFilePath); - Assert.True(result); - Assert.Equal(expectedConfigurationFilePath, configurationFilePath); - } - - [Fact] - public void GetMappings_NotMutable() - { - // Arrange - var store = new DefaultProjectConfigurationFilePathStore(); - - // Act - var mappings = store.GetMappings(); - var hostProject = new HostProject(@"C:\project.csproj", @"C:\project\obj", RazorConfiguration.Default, null); - store.Set(hostProject.Key, @"C:\project\obj\project.razor.bin"); - - // Assert - Assert.Empty(mappings); - } - - [Fact] - public void GetMappings_ReturnsAllSetMappings() - { - // Arrange - var store = new DefaultProjectConfigurationFilePathStore(); - var expectedMappings = new Dictionary() - { - [TestProjectKey.Create(@"C:\project1\obj")] = @"C:\project1\obj\project.razor.bin" - }; - foreach (var mapping in expectedMappings) - { - store.Set(mapping.Key, mapping.Value); - } - - // Act - var mappings = store.GetMappings(); - - // Assert - Assert.Equal(expectedMappings, mappings); - } - - [Fact] - public void Remove_InvokesChanged() - { - // Arrange - var store = new DefaultProjectConfigurationFilePathStore(); - var projectFilePath = @"C:\project.csproj"; - var hostProject = new HostProject(projectFilePath, @"C:\project\obj", RazorConfiguration.Default, null); - store.Set(hostProject.Key, @"C:\project\obj\project.razor.bin"); - var called = false; - store.Changed += (sender, args) => - { - called = true; - Assert.Equal(hostProject.Key, args.ProjectKey); - Assert.Null(args.ConfigurationFilePath); - }; - - // Act - store.Remove(hostProject.Key); - - // Assert - Assert.True(called); - } - - [Fact] - public void Remove_UntrackedProject_DoesNotInvokeChanged() - { - // Arrange - var store = new DefaultProjectConfigurationFilePathStore(); - var called = false; - store.Changed += (sender, args) => called = true; - - // Act - store.Remove(TestProjectKey.Create(@"C:\project\obj")); - - // Assert - Assert.False(called); - } - - [Fact] - public void Remove_RemovesGettability() - { - // Arrange - var store = new DefaultProjectConfigurationFilePathStore(); - var projectFilePath = @"C:\project.csproj"; - var hostProject = new HostProject(projectFilePath, @"C:\project\obj", RazorConfiguration.Default, null); - store.Set(hostProject.Key, @"C:\project\obj\project.razor.bin"); - - // Act - store.Remove(hostProject.Key); - var result = store.TryGet(hostProject.Key, out _); - - // Assert - Assert.False(result); - } -} diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/LanguageClient/ProjectSystem/RazorProjectInfoDriverTest.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/LanguageClient/ProjectSystem/RazorProjectInfoDriverTest.cs new file mode 100644 index 00000000000..6ff9e9b0675 --- /dev/null +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/LanguageClient/ProjectSystem/RazorProjectInfoDriverTest.cs @@ -0,0 +1,283 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.LanguageServer.ProjectSystem; +using Microsoft.AspNetCore.Razor.ProjectSystem; +using Microsoft.AspNetCore.Razor.Test.Common.LanguageServer; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.VisualStudio.Razor.LanguageClient.ProjectSystem; + +public class RazorProjectInfoDriverTest(ITestOutputHelper testOutput) : LanguageServerTestBase(testOutput) +{ + private static readonly HostProject s_hostProject1 = new( + projectFilePath: "C:/path/to/project1/project1.csproj", + intermediateOutputPath: "C:/path/to/project1/obj", + razorConfiguration: RazorConfiguration.Default, + rootNamespace: "TestNamespace"); + + private static readonly HostDocument s_hostDocument1 = new("C:/path/to/project1/file.razor", "file.razor"); + + private static readonly HostProject s_hostProject2 = new( + projectFilePath: "C:/path/to/project2/project2.csproj", + intermediateOutputPath: "C:/path/to/project2/obj", + razorConfiguration: RazorConfiguration.Default, + rootNamespace: "TestNamespace"); + + private static readonly HostDocument s_hostDocument2 = new("C:/path/to/project2/file.razor", "file.razor"); + + [UIFact] + public async Task ProcessesExistingProjectsDuringInitialization() + { + var projectManager = CreateProjectSnapshotManager(); + + await projectManager.UpdateAsync(static updater => + { + updater.ProjectAdded(s_hostProject1); + updater.DocumentAdded(s_hostProject1.Key, s_hostDocument1, CreateTextLoader(s_hostDocument1.FilePath, "

Hello World

")); + + updater.ProjectAdded(s_hostProject2); + updater.DocumentAdded(s_hostProject2.Key, s_hostDocument2, CreateTextLoader(s_hostDocument2.FilePath, "

Hello World

")); + }); + + var (driver, testAccessor) = await CreateDriverAndInitializeAsync(projectManager); + + await testAccessor.WaitUntilCurrentBatchCompletesAsync(); + + var latestProjects = driver.GetLatestProjectInfo(); + + // The misc files projects project should be present. + Assert.Contains(latestProjects, x => x.ProjectKey == MiscFilesHostProject.Instance.Key); + + // Sort the remaining projects by project key. + var projects = latestProjects + .WhereAsArray(x => x.ProjectKey != MiscFilesHostProject.Instance.Key) + .Sort((x, y) => x.ProjectKey.Id.CompareTo(y.ProjectKey.Id)); + + Assert.Equal(2, projects.Length); + + var projectInfo1 = projects[0]; + Assert.Equal(s_hostProject1.Key, projectInfo1.ProjectKey); + var document1 = Assert.Single(projectInfo1.Documents); + Assert.Equal(s_hostDocument1.FilePath, document1.FilePath); + + var projectInfo2 = projects[1]; + Assert.Equal(s_hostProject2.Key, projectInfo2.ProjectKey); + var document2 = Assert.Single(projectInfo2.Documents); + Assert.Equal(s_hostDocument2.FilePath, document2.FilePath); + } + + [UIFact] + public async Task ProcessesProjectsAddedAfterInitialization() + { + var projectManager = CreateProjectSnapshotManager(); + + var (driver, testAccessor) = await CreateDriverAndInitializeAsync(projectManager); + + // The misc files projects project should be present after initialization. + await testAccessor.WaitUntilCurrentBatchCompletesAsync(); + + var initialProjects = driver.GetLatestProjectInfo(); + + var miscFilesProject = Assert.Single(initialProjects); + Assert.Equal(MiscFilesHostProject.Instance.Key, miscFilesProject.ProjectKey); + + // Now add some projects + await projectManager.UpdateAsync(static updater => + { + updater.ProjectAdded(s_hostProject1); + updater.DocumentAdded(s_hostProject1.Key, s_hostDocument1, CreateTextLoader(s_hostDocument1.FilePath, "

Hello World

")); + + updater.ProjectAdded(s_hostProject2); + updater.DocumentAdded(s_hostProject2.Key, s_hostDocument2, CreateTextLoader(s_hostDocument2.FilePath, "

Hello World

")); + }); + + await testAccessor.WaitUntilCurrentBatchCompletesAsync(); + + // Sort the non-misc files projects by project key. + var projects = driver + .GetLatestProjectInfo() + .WhereAsArray(x => x.ProjectKey != MiscFilesHostProject.Instance.Key) + .Sort((x, y) => x.ProjectKey.Id.CompareTo(y.ProjectKey.Id)); + + Assert.Equal(2, projects.Length); + + var projectInfo1 = projects[0]; + Assert.Equal(s_hostProject1.Key, projectInfo1.ProjectKey); + var document1 = Assert.Single(projectInfo1.Documents); + Assert.Equal(s_hostDocument1.FilePath, document1.FilePath); + + var projectInfo2 = projects[1]; + Assert.Equal(s_hostProject2.Key, projectInfo2.ProjectKey); + var document2 = Assert.Single(projectInfo2.Documents); + Assert.Equal(s_hostDocument2.FilePath, document2.FilePath); + } + + [UIFact] + public async Task ProcessesDocumentAddedAfterInitialization() + { + var projectManager = CreateProjectSnapshotManager(); + + await projectManager.UpdateAsync(static updater => + { + updater.ProjectAdded(s_hostProject1); + }); + + var (driver, testAccessor) = await CreateDriverAndInitializeAsync(projectManager); + + await projectManager.UpdateAsync(static updater => + { + updater.DocumentAdded(s_hostProject1.Key, s_hostDocument1, CreateTextLoader(s_hostDocument1.FilePath, "

Hello World

")); + }); + + await testAccessor.WaitUntilCurrentBatchCompletesAsync(); + + // Sort the non-misc files projects by project key. + var projects = driver + .GetLatestProjectInfo() + .WhereAsArray(x => x.ProjectKey != MiscFilesHostProject.Instance.Key) + .Sort((x, y) => x.ProjectKey.Id.CompareTo(y.ProjectKey.Id)); + + var projectInfo1 = Assert.Single(projects); + Assert.Equal(s_hostProject1.Key, projectInfo1.ProjectKey); + var document1 = Assert.Single(projectInfo1.Documents); + Assert.Equal(s_hostDocument1.FilePath, document1.FilePath); + } + + [UIFact] + public async Task ProcessesProjectRemovedAfterInitialization() + { + var projectManager = CreateProjectSnapshotManager(); + + await projectManager.UpdateAsync(static updater => + { + updater.ProjectAdded(s_hostProject1); + }); + + var (driver, testAccessor) = await CreateDriverAndInitializeAsync(projectManager); + + await testAccessor.WaitUntilCurrentBatchCompletesAsync(); + + // Sort the non-misc files projects by project key. + var projects = driver + .GetLatestProjectInfo() + .WhereAsArray(x => x.ProjectKey != MiscFilesHostProject.Instance.Key) + .Sort((x, y) => x.ProjectKey.Id.CompareTo(y.ProjectKey.Id)); + + var projectInfo1 = Assert.Single(projects); + Assert.Equal(s_hostProject1.Key, projectInfo1.ProjectKey); + + await projectManager.UpdateAsync(static updater => + { + updater.ProjectRemoved(s_hostProject1.Key); + }); + + await testAccessor.WaitUntilCurrentBatchCompletesAsync(); + + var miscFilesProject = Assert.Single(driver.GetLatestProjectInfo()); + Assert.Equal(MiscFilesHostProject.Instance.Key, miscFilesProject.ProjectKey); + } + + [UIFact] + public async Task ListenerNotifiedOfUpdates() + { + var projectManager = CreateProjectSnapshotManager(); + + await projectManager.UpdateAsync(static updater => + { + updater.ProjectAdded(s_hostProject1); + }); + + var (driver, testAccessor) = await CreateDriverAndInitializeAsync(projectManager); + + await testAccessor.WaitUntilCurrentBatchCompletesAsync(); + + var listener = new TestListener(); + driver.AddListener(listener); + + await projectManager.UpdateAsync(static updater => + { + updater.DocumentAdded(s_hostProject1.Key, s_hostDocument1, CreateTextLoader(s_hostDocument1.FilePath, "

Hello World

")); + }); + + await testAccessor.WaitUntilCurrentBatchCompletesAsync(); + + Assert.Empty(listener.Removes); + + var projectInfo1 = Assert.Single(listener.Updates); + Assert.Equal(s_hostProject1.Key, projectInfo1.ProjectKey); + var document1 = Assert.Single(projectInfo1.Documents); + Assert.Equal(s_hostDocument1.FilePath, document1.FilePath); + } + + [UIFact] + public async Task ListenerNotifiedOfRemoves() + { + var projectManager = CreateProjectSnapshotManager(); + + await projectManager.UpdateAsync(static updater => + { + updater.ProjectAdded(s_hostProject1); + }); + + var (driver, testAccessor) = await CreateDriverAndInitializeAsync(projectManager); + + await testAccessor.WaitUntilCurrentBatchCompletesAsync(); + + var listener = new TestListener(); + driver.AddListener(listener); + + await projectManager.UpdateAsync(static updater => + { + updater.ProjectRemoved(s_hostProject1.Key); + }); + + await testAccessor.WaitUntilCurrentBatchCompletesAsync(); + + var projectKey1 = Assert.Single(listener.Removes); + Assert.Equal(s_hostProject1.Key, projectKey1); + + Assert.Empty(listener.Updates); + } + + private async Task<(RazorProjectInfoDriver, AbstractRazorProjectInfoDriver.TestAccessor)> CreateDriverAndInitializeAsync( + IProjectSnapshotManager projectManager) + { + var driver = new RazorProjectInfoDriver(projectManager, LoggerFactory, delay: TimeSpan.FromMilliseconds(5)); + AddDisposable(driver); + + var testAccessor = driver.GetTestAccessor(); + + await driver.WaitForInitializationAsync(); + + return (driver, testAccessor); + } + + private sealed class TestListener : IRazorProjectInfoListener + { + private readonly ImmutableArray.Builder _removes = ImmutableArray.CreateBuilder(); + private readonly ImmutableArray.Builder _updates = ImmutableArray.CreateBuilder(); + + public ImmutableArray Removes => _removes.ToImmutable(); + public ImmutableArray Updates => _updates.ToImmutable(); + + public Task RemovedAsync(ProjectKey projectKey, CancellationToken cancellationToken) + { + _removes.Add(projectKey); + return Task.CompletedTask; + } + + public Task UpdatedAsync(RazorProjectInfo projectInfo, CancellationToken cancellationToken) + { + _updates.Add(projectInfo); + return Task.CompletedTask; + } + } +} diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/LanguageClient/ProjectSystem/RazorProjectInfoEndpointPublisherTest.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/LanguageClient/ProjectSystem/RazorProjectInfoEndpointPublisherTest.cs deleted file mode 100644 index 353e50027bf..00000000000 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/LanguageClient/ProjectSystem/RazorProjectInfoEndpointPublisherTest.cs +++ /dev/null @@ -1,297 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor.Language; -using Microsoft.AspNetCore.Razor.ProjectSystem; -using Microsoft.AspNetCore.Razor.Test.Common; -using Microsoft.AspNetCore.Razor.Test.Common.LanguageServer; -using Microsoft.AspNetCore.Razor.Test.Common.ProjectSystem; -using Microsoft.CodeAnalysis.Razor; -using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Razor.Protocol; -using Microsoft.CodeAnalysis.Razor.Serialization; -using Microsoft.CodeAnalysis.Razor.Workspaces.Protocol.ProjectSystem; -using Microsoft.VisualStudio.LanguageServer.ContainedLanguage; -using Moq; -using Xunit; -using Xunit.Abstractions; - -namespace Microsoft.VisualStudio.Razor.LanguageClient.ProjectSystem; - -public class RazorProjectInfoEndpointPublisherTest(ITestOutputHelper testOutput) : LanguageServerTestBase(testOutput) -{ - [Fact] - [WorkItem("https://github.com/dotnet/aspnetcore/issues/35945")] - public async Task ProjectManager_Changed_Remove_Change_NoopsOnDelayedPublish() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - - var callCount = 0; - var tagHelpers = ImmutableArray.Create( - new TagHelperDescriptor(FileKinds.Component, "Namespace.FileNameOther", "Assembly", "FileName", "FileName document", "FileName hint", - caseSensitive: false, tagMatchingRules: default, attributeDescriptors: default, allowedChildTags: default, metadata: null!, diagnostics: default)); - - var initialProjectSnapshot = CreateProjectSnapshot( - @"C:\path\to\project.csproj", ProjectWorkspaceState.Create(tagHelpers, CodeAnalysis.CSharp.LanguageVersion.Preview)); - var expectedProjectSnapshot = CreateProjectSnapshot( - @"C:\path\to\project.csproj", ProjectWorkspaceState.Create(CodeAnalysis.CSharp.LanguageVersion.Preview)); - var requestInvoker = new Mock(MockBehavior.Strict); - requestInvoker - .Setup(r => r.ReinvokeRequestOnServerAsync( - LanguageServerConstants.RazorProjectInfoEndpoint, - RazorLSPConstants.RazorLanguageServerName, - It.IsAny(), - It.IsAny())) - .Callback((s1, s2, param, ct) => callCount++) - .ReturnsAsync(new ReinvokeResponse()); - - var serializer = new TestRazorProjectInfoFileSerializer(); - - using var publisher = CreateRazorProjectInfoEndpointPublisher(requestInvoker.Object, projectManager, serializer); - var publisherAccessor = publisher.GetTestAccessor(); - - var documentRemovedArgs = ProjectChangeEventArgs.CreateTestInstance( - initialProjectSnapshot, initialProjectSnapshot, documentFilePath: @"C:\path\to\file.razor", ProjectChangeKind.DocumentRemoved); - var projectChangedArgs = ProjectChangeEventArgs.CreateTestInstance( - initialProjectSnapshot, expectedProjectSnapshot, documentFilePath: null!, ProjectChangeKind.ProjectChanged); - - // Act - publisherAccessor.ProjectManager_Changed(null!, documentRemovedArgs); - publisherAccessor.ProjectManager_Changed(null!, projectChangedArgs); - await publisherAccessor.WaitUntilCurrentBatchCompletesAsync(); - - // Assert - Assert.Equal(1, callCount); - } - - [Fact] - public async Task ProjectManager_Changed_NotActive_Noops() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - - var hostProject = new HostProject(@"C:\path\to\project.csproj", @"C:\path\to\obj", RazorConfiguration.Default, rootNamespace: "TestRootNamespace"); - var hostDocument = new HostDocument(@"C:\path\to\file.razor", "file.razor"); - - await projectManager.UpdateAsync(updater => - { - updater.ProjectAdded(hostProject); - }); - - var callCount = 0; - var requestInvoker = new Mock(MockBehavior.Strict); - requestInvoker - .Setup(r => r.ReinvokeRequestOnServerAsync( - LanguageServerConstants.RazorProjectInfoEndpoint, - RazorLSPConstants.RazorLanguageServerName, - It.IsAny(), - It.IsAny())) - .Callback((s1, s2, param, ct) => callCount++) - .ReturnsAsync(new ReinvokeResponse()); - - var serializer = new TestRazorProjectInfoFileSerializer(); - - using var publisher = CreateRazorProjectInfoEndpointPublisher(requestInvoker.Object, projectManager, serializer); - var publisherAccessor = publisher.GetTestAccessor(); - - // Act - await projectManager.UpdateAsync(updater => - { - updater.DocumentAdded(hostProject.Key, hostDocument, new EmptyTextLoader(hostDocument.FilePath)); - }); - await publisherAccessor.WaitUntilCurrentBatchCompletesAsync(); - - // Assert - Assert.Equal(0, callCount); - } - - [Fact] - public async Task ProjectManager_Changed_ServerStarted_InitializedProject_Publishes() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - - var hostProject = new HostProject(@"C:\path\to\project.csproj", @"C:\path\to\obj", RazorConfiguration.Default, rootNamespace: "TestRootNamespace"); - var hostDocument = new HostDocument(@"C:\path\to\file.razor", "file.razor"); - - await projectManager.UpdateAsync(updater => - { - updater.ProjectAdded(hostProject); - updater.ProjectWorkspaceStateChanged(hostProject.Key, ProjectWorkspaceState.Default); - updater.DocumentAdded(hostProject.Key, hostDocument, new EmptyTextLoader(hostDocument.FilePath)); - }); - - var projectSnapshot = projectManager.GetProjects()[0]; - - var callCount = 0; - var requestInvoker = new Mock(MockBehavior.Strict); - requestInvoker - .Setup(r => r.ReinvokeRequestOnServerAsync( - LanguageServerConstants.RazorProjectInfoEndpoint, - RazorLSPConstants.RazorLanguageServerName, - It.IsAny(), - It.IsAny())) - .Callback((s1, s2, param, ct) => callCount++) - .ReturnsAsync(new ReinvokeResponse()); - - var serializer = new TestRazorProjectInfoFileSerializer(); - - using var publisher = CreateRazorProjectInfoEndpointPublisher(requestInvoker.Object, projectManager, serializer); - - // Act - publisher.StartSending(); - - // Assert - Assert.Equal(1, callCount); - } - - [Theory] - [InlineData(ProjectChangeKind.DocumentAdded, true, false)] - [InlineData(ProjectChangeKind.DocumentRemoved, true, false)] - [InlineData(ProjectChangeKind.ProjectChanged, true, false)] - [InlineData(ProjectChangeKind.ProjectRemoved, true, true)] - [InlineData(ProjectChangeKind.ProjectAdded, false, false)] - internal async Task ProjectManager_Changed_EnqueuesPublishAsync(ProjectChangeKind changeKind, bool waitForQueueEmpty, bool expectNullProjectInfo) - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var projectConfigurationFilePathStore = new DefaultProjectConfigurationFilePathStore(); - - var projectSnapshot = CreateProjectSnapshot(@"C:\path\to\project.csproj", ProjectWorkspaceState.Create(CodeAnalysis.CSharp.LanguageVersion.CSharp7_3)); - var expectedProjectInfo = projectSnapshot.ToRazorProjectInfo(); - var callCount = 0; - var requestInvoker = new Mock(MockBehavior.Strict); - ProjectInfoParams? projectInfoParams = null; - requestInvoker - .Setup(r => r.ReinvokeRequestOnServerAsync( - LanguageServerConstants.RazorProjectInfoEndpoint, - RazorLSPConstants.RazorLanguageServerName, - It.IsAny(), - It.IsAny())) - .Callback((s1, s2, param, ct) => - { - callCount++; - projectInfoParams = param; - }) - .ReturnsAsync(new ReinvokeResponse()); - - var serializer = new TestRazorProjectInfoFileSerializer(); - - using var publisher = CreateRazorProjectInfoEndpointPublisher(requestInvoker.Object, projectManager, serializer); - var publisherAccessor = publisher.GetTestAccessor(); - - var args = ProjectChangeEventArgs.CreateTestInstance(projectSnapshot, projectSnapshot, documentFilePath: null!, changeKind); - - // Act - publisherAccessor.ProjectManager_Changed(null!, args); - if (waitForQueueEmpty) - { - await publisherAccessor.WaitUntilCurrentBatchCompletesAsync(); - } - - // Assert - Assert.Equal(1, callCount); - Assert.NotNull(projectInfoParams); - var filePath = Assert.Single(projectInfoParams.FilePaths); - - if (expectNullProjectInfo) - { - Assert.Null(filePath); - } - else - { - Assert.NotNull(filePath); - var projectInfo = await serializer.DeserializeFromFileAndDeleteAsync(filePath, DisposalToken); - Assert.NotNull(projectInfo); - Assert.Equal(expectedProjectInfo, projectInfo); - } - } - - [Fact] - public async Task EnqueuePublish_BatchesPublishRequestsAsync() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - - var firstSnapshot = CreateProjectSnapshot(@"C:\path\to\project.csproj"); - var secondSnapshot = CreateProjectSnapshot(@"C:\path\to\project.csproj", [@"C:\path\to\file.cshtml"]); - var expectedProjectInfo = secondSnapshot.ToRazorProjectInfo(); - - ProjectInfoParams? projectInfoParams = null; - var callCount = 0; - var requestInvoker = new Mock(MockBehavior.Strict); - requestInvoker - .Setup(r => r.ReinvokeRequestOnServerAsync( - LanguageServerConstants.RazorProjectInfoEndpoint, - RazorLSPConstants.RazorLanguageServerName, - It.IsAny(), - It.IsAny())) - .Callback((s1, s2, param, ct) => - { - callCount++; - projectInfoParams = param; - }) - .ReturnsAsync(new ReinvokeResponse()); - - var serializer = new TestRazorProjectInfoFileSerializer(); - - using var publisher = CreateRazorProjectInfoEndpointPublisher(requestInvoker.Object, projectManager, serializer); - var publisherAccessor = publisher.GetTestAccessor(); - - // Act - publisherAccessor.EnqueuePublish(firstSnapshot); - publisherAccessor.EnqueuePublish(secondSnapshot); - await publisherAccessor.WaitUntilCurrentBatchCompletesAsync(); - - // Assert - Assert.Equal(1, callCount); - Assert.NotNull(projectInfoParams); - var filePath = Assert.Single(projectInfoParams.FilePaths); - Assert.NotNull(filePath); - var projectInfo = await serializer.DeserializeFromFileAndDeleteAsync(filePath, DisposalToken); - Assert.Equal(expectedProjectInfo, projectInfo); - } - - internal static IProjectSnapshot CreateProjectSnapshot( - string projectFilePath, - ProjectWorkspaceState? projectWorkspaceState = null, - string[]? documentFilePaths = null) - { - return TestProjectSnapshot.Create(projectFilePath, documentFilePaths ?? [], projectWorkspaceState); - } - - internal static IProjectSnapshot CreateProjectSnapshot(string projectFilePath, string[] documentFilePaths) - { - return TestProjectSnapshot.Create(projectFilePath, documentFilePaths); - } - - private static RazorProjectInfoEndpointPublisher CreateRazorProjectInfoEndpointPublisher( - LSPRequestInvoker requestInvoker, - IProjectSnapshotManager projectSnapshotManager, - IRazorProjectInfoFileSerializer serializer) - => new(requestInvoker, projectSnapshotManager, serializer, TimeSpan.FromMilliseconds(5)); - - private sealed class TestRazorProjectInfoFileSerializer : IRazorProjectInfoFileSerializer - { - private readonly Dictionary _filePathToProjectInfoMap = new(FilePathComparer.Instance); - - public Task DeserializeFromFileAndDeleteAsync(string filePath, CancellationToken cancellationToken) - { - return Task.FromResult(_filePathToProjectInfoMap[filePath]); - } - - public Task SerializeToTempFileAsync(RazorProjectInfo projectInfo, CancellationToken cancellationToken) - { - var filePath = Guid.NewGuid().ToString("D"); - _filePathToProjectInfoMap[filePath] = projectInfo; - - return Task.FromResult(filePath); - } - } -} diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/LanguageClient/RazorProjectInfoPublisherTest.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/LanguageClient/RazorProjectInfoPublisherTest.cs deleted file mode 100644 index 9f92a5e4d01..00000000000 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/LanguageClient/RazorProjectInfoPublisherTest.cs +++ /dev/null @@ -1,742 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System; -using System.Collections.Immutable; -using System.IO; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor.Language; -using Microsoft.AspNetCore.Razor.ProjectSystem; -using Microsoft.AspNetCore.Razor.Test.Common; -using Microsoft.AspNetCore.Razor.Test.Common.ProjectSystem; -using Microsoft.AspNetCore.Razor.Test.Common.VisualStudio; -using Microsoft.CodeAnalysis.Razor.Logging; -using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Razor.Workspaces; -using Microsoft.CodeAnalysis.Text; -using Xunit; -using Xunit.Abstractions; -using Xunit.Sdk; - -namespace Microsoft.VisualStudio.Razor.LanguageClient; - -public class RazorProjectInfoPublisherTest(ITestOutputHelper testOutput) : VisualStudioWorkspaceTestBase(testOutput) -{ - [Fact] - [WorkItem("https://github.com/dotnet/aspnetcore/issues/35945")] - public async Task ProjectManager_Changed_Remove_Change_NoopsOnDelayedPublish() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var projectConfigurationFilePathStore = new DefaultProjectConfigurationFilePathStore(); - - var serializationSuccessful = false; - var tagHelpers = ImmutableArray.Create( - new TagHelperDescriptor(FileKinds.Component, "Namespace.FileNameOther", "Assembly", "FileName", "FileName document", "FileName hint", - caseSensitive: false, tagMatchingRules: default, attributeDescriptors: default, allowedChildTags: default, metadata: null!, diagnostics: default)); - - var initialProjectSnapshot = CreateProjectSnapshot( - @"C:\path\to\project.csproj", ProjectWorkspaceState.Create(tagHelpers, CodeAnalysis.CSharp.LanguageVersion.Preview)); - var expectedProjectSnapshot = CreateProjectSnapshot( - @"C:\path\to\project.csproj", ProjectWorkspaceState.Create(CodeAnalysis.CSharp.LanguageVersion.Preview)); - var expectedConfigurationFilePath = @"C:\path\to\obj\bin\Debug\project.razor.bin"; - var publisher = new TestRazorProjectInfoPublisher( - projectManager, - projectConfigurationFilePathStore, - LoggerFactory, - onSerializeToFile: (snapshot, configurationFilePath) => - { - Assert.Same(expectedProjectSnapshot, snapshot); - Assert.Equal(expectedConfigurationFilePath, configurationFilePath); - serializationSuccessful = true; - }) - { - EnqueueDelay = 10, - _active = true, - }; - - projectConfigurationFilePathStore.Set(expectedProjectSnapshot.Key, expectedConfigurationFilePath); - var documentRemovedArgs = ProjectChangeEventArgs.CreateTestInstance( - initialProjectSnapshot, initialProjectSnapshot, documentFilePath: @"C:\path\to\file.razor", ProjectChangeKind.DocumentRemoved); - var projectChangedArgs = ProjectChangeEventArgs.CreateTestInstance( - initialProjectSnapshot, expectedProjectSnapshot, documentFilePath: null!, ProjectChangeKind.ProjectChanged); - - // Act - publisher.ProjectManager_Changed(null!, documentRemovedArgs); - publisher.ProjectManager_Changed(null!, projectChangedArgs); - - // Assert - var stalePublishTask = Assert.Single(publisher.DeferredPublishTasks); - await stalePublishTask.Value; - Assert.True(serializationSuccessful); - } - - [Fact] - public async Task ProjectManager_Changed_NotActive_Noops() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var projectConfigurationFilePathStore = new DefaultProjectConfigurationFilePathStore(); - - var attemptedToSerialize = false; - var hostProject = new HostProject(@"C:\path\to\project.csproj", @"C:\path\to\obj", RazorConfiguration.Default, rootNamespace: "TestRootNamespace"); - var hostDocument = new HostDocument(@"C:\path\to\file.razor", "file.razor"); - - await projectManager.UpdateAsync(updater => - { - updater.ProjectAdded(hostProject); - }); - - var publisher = new TestRazorProjectInfoPublisher( - projectManager, - projectConfigurationFilePathStore, - LoggerFactory, - onSerializeToFile: (snapshot, configurationFilePath) => attemptedToSerialize = true) - { - EnqueueDelay = 10, - }; - - // Act - await projectManager.UpdateAsync(updater => - { - updater.DocumentAdded(hostProject.Key, hostDocument, new EmptyTextLoader(hostDocument.FilePath)); - }); - - // Assert - Assert.Empty(publisher.DeferredPublishTasks); - Assert.False(attemptedToSerialize); - } - - [Fact] - public async Task ProjectManager_Changed_DocumentOpened_UninitializedProject_NotActive_Noops() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var projectConfigurationFilePathStore = new DefaultProjectConfigurationFilePathStore(); - - var attemptedToSerialize = false; - var hostProject = new HostProject(@"C:\path\to\project.csproj", @"C:\path\to\obj", RazorConfiguration.Default, rootNamespace: "TestRootNamespace"); - var hostDocument = new HostDocument(@"C:\path\to\file.razor", "file.razor"); - - await projectManager.UpdateAsync(updater => - { - updater.ProjectAdded(hostProject); - updater.DocumentAdded(hostProject.Key, hostDocument, new EmptyTextLoader(hostDocument.FilePath)); - }); - - var publisher = new TestRazorProjectInfoPublisher( - projectManager, - projectConfigurationFilePathStore, - LoggerFactory, - onSerializeToFile: (snapshot, configurationFilePath) => attemptedToSerialize = true) - { - EnqueueDelay = 10, - }; - - // Act - await projectManager.UpdateAsync(updater => - { - updater.DocumentOpened(hostProject.Key, hostDocument.FilePath, SourceText.From(string.Empty)); - }); - - // Assert - Assert.Empty(publisher.DeferredPublishTasks); - Assert.False(attemptedToSerialize); - } - - [Fact] - public async Task ProjectManager_Changed_DocumentOpened_InitializedProject_NotActive_Publishes() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var projectConfigurationFilePathStore = new DefaultProjectConfigurationFilePathStore(); - - var serializationSuccessful = false; - var hostProject = new HostProject(@"C:\path\to\project.csproj", @"C:\path\to\obj", RazorConfiguration.Default, rootNamespace: "TestRootNamespace"); - var hostDocument = new HostDocument(@"C:\path\to\file.razor", "file.razor"); - - await projectManager.UpdateAsync(updater => - { - updater.ProjectAdded(hostProject); - updater.ProjectWorkspaceStateChanged(hostProject.Key, ProjectWorkspaceState.Default); - updater.DocumentAdded(hostProject.Key, hostDocument, new EmptyTextLoader(hostDocument.FilePath)); - }); - - var projectSnapshot = projectManager.GetProjects()[0]; - var expectedConfigurationFilePath = @"C:\path\to\obj\bin\Debug\project.razor.bin"; - projectConfigurationFilePathStore.Set(projectSnapshot.Key, expectedConfigurationFilePath); - var publisher = new TestRazorProjectInfoPublisher( - projectManager, - projectConfigurationFilePathStore, - LoggerFactory, - onSerializeToFile: (snapshot, configurationFilePath) => - { - Assert.Equal(expectedConfigurationFilePath, configurationFilePath); - serializationSuccessful = true; - }) - { - EnqueueDelay = 10, - }; - - // Act - await projectManager.UpdateAsync(updater => - { - updater.DocumentOpened(hostProject.Key, hostDocument.FilePath, SourceText.From(string.Empty)); - }); - - // Assert - Assert.Empty(publisher.DeferredPublishTasks); - Assert.True(serializationSuccessful); - } - - [Fact] - public async Task ProjectManager_Changed_DocumentOpened_InitializedProject_NoFile_Active_Publishes() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var projectConfigurationFilePathStore = new DefaultProjectConfigurationFilePathStore(); - - var serializationSuccessful = false; - var hostProject = new HostProject(@"C:\path\to\project.csproj", @"C:\path\to\obj", RazorConfiguration.Default, rootNamespace: "TestRootNamespace"); - var hostDocument = new HostDocument(@"C:\path\to\file.razor", "file.razor"); - - await projectManager.UpdateAsync(updater => - { - updater.ProjectAdded(hostProject); - updater.ProjectWorkspaceStateChanged(hostProject.Key, ProjectWorkspaceState.Default); - updater.DocumentAdded(hostProject.Key, hostDocument, new EmptyTextLoader(hostDocument.FilePath)); - }); - - var projectSnapshot = projectManager.GetProjects()[0]; - var expectedConfigurationFilePath = @"C:\path\to\obj\bin\Debug\project.razor.bin"; - projectConfigurationFilePathStore.Set(projectSnapshot.Key, expectedConfigurationFilePath); - var publisher = new TestRazorProjectInfoPublisher( - projectManager, - projectConfigurationFilePathStore, - LoggerFactory, - onSerializeToFile: (snapshot, configurationFilePath) => - { - Assert.Equal(expectedConfigurationFilePath, configurationFilePath); - serializationSuccessful = true; - }, - configurationFileExists: false) - { - EnqueueDelay = 10000, // Long enqueue delay to make sure this test doesn't pass due to slow running, but broken product code - _active = true - }; - - // Act - await projectManager.UpdateAsync(updater => - { - updater.DocumentOpened(hostProject.Key, hostDocument.FilePath, SourceText.From(string.Empty)); - }); - - // Assert - Assert.Empty(publisher.DeferredPublishTasks); - Assert.True(serializationSuccessful); - } - - [Theory] - [InlineData(ProjectChangeKind.DocumentAdded)] - [InlineData(ProjectChangeKind.DocumentRemoved)] - [InlineData(ProjectChangeKind.ProjectChanged)] - internal async Task ProjectManager_Changed_EnqueuesPublishAsync(ProjectChangeKind changeKind) - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var projectConfigurationFilePathStore = new DefaultProjectConfigurationFilePathStore(); - - var serializationSuccessful = false; - var projectSnapshot = CreateProjectSnapshot(@"C:\path\to\project.csproj", ProjectWorkspaceState.Create(CodeAnalysis.CSharp.LanguageVersion.CSharp7_3)); - var expectedConfigurationFilePath = @"C:\path\to\obj\bin\Debug\project.razor.bin"; - var publisher = new TestRazorProjectInfoPublisher( - projectManager, - projectConfigurationFilePathStore, - LoggerFactory, - onSerializeToFile: (snapshot, configurationFilePath) => - { - Assert.Same(projectSnapshot, snapshot); - Assert.Equal(expectedConfigurationFilePath, configurationFilePath); - serializationSuccessful = true; - }) - { - EnqueueDelay = 10, - _active = true, - }; - - projectConfigurationFilePathStore.Set(projectSnapshot.Key, expectedConfigurationFilePath); - var args = ProjectChangeEventArgs.CreateTestInstance(projectSnapshot, projectSnapshot, documentFilePath: null!, changeKind); - - // Act - publisher.ProjectManager_Changed(null!, args); - - // Assert - var kvp = Assert.Single(publisher.DeferredPublishTasks); - await kvp.Value; - Assert.True(serializationSuccessful); - } - - [Fact] - internal async Task ProjectManager_ChangedTagHelpers_PublishesImmediately() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var projectConfigurationFilePathStore = new DefaultProjectConfigurationFilePathStore(); - - var serializationSuccessful = false; - var projectSnapshot = CreateProjectSnapshot(@"C:\path\to\project.csproj", ProjectWorkspaceState.Default); - var changedProjectSnapshot = CreateProjectSnapshot(@"C:\path\to\project.csproj", ProjectWorkspaceState.Create(CodeAnalysis.CSharp.LanguageVersion.CSharp8)); - var expectedConfigurationFilePath = @"C:\path\to\obj\bin\Debug\project.razor.bin"; - var aboutToChange = false; - var publisher = new TestRazorProjectInfoPublisher( - projectManager, - projectConfigurationFilePathStore, - LoggerFactory, - onSerializeToFile: (snapshot, configurationFilePath) => - { - if (!aboutToChange) - { - return; - } - - Assert.Same(changedProjectSnapshot, snapshot); - Assert.Equal(expectedConfigurationFilePath, configurationFilePath); - serializationSuccessful = true; - }) - { - EnqueueDelay = 10, - _active = true, - }; - - projectConfigurationFilePathStore.Set(projectSnapshot.Key, expectedConfigurationFilePath); - var args = ProjectChangeEventArgs.CreateTestInstance(projectSnapshot, projectSnapshot, documentFilePath: null!, ProjectChangeKind.ProjectChanged); - publisher.ProjectManager_Changed(null!, args); - - // Flush publish task - var kvp = Assert.Single(publisher.DeferredPublishTasks); - await kvp.Value; - aboutToChange = true; - publisher.DeferredPublishTasks.Clear(); - - var changedTagHelpersArgs = ProjectChangeEventArgs.CreateTestInstance( - projectSnapshot, changedProjectSnapshot, documentFilePath: null!, ProjectChangeKind.ProjectChanged); - - // Act - publisher.ProjectManager_Changed(null!, changedTagHelpersArgs); - - // Assert - Assert.Empty(publisher.DeferredPublishTasks); - Assert.True(serializationSuccessful); - } - - [Fact] - public async Task ProjectManager_Changed_ProjectRemoved_AfterEnqueuedPublishAsync() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var projectConfigurationFilePathStore = new DefaultProjectConfigurationFilePathStore(); - - var attemptedToSerialize = false; - var projectSnapshot = CreateProjectSnapshot(@"C:\path\to\project.csproj"); - var expectedConfigurationFilePath = @"C:\path\to\obj\bin\Debug\project.razor.bin"; - var publisher = new TestRazorProjectInfoPublisher( - projectManager, - projectConfigurationFilePathStore, - LoggerFactory, - onSerializeToFile: (snapshot, configurationFilePath) => attemptedToSerialize = true) - { - EnqueueDelay = 10, - _active = true, - }; - - projectConfigurationFilePathStore.Set(projectSnapshot.Key, expectedConfigurationFilePath); - publisher.EnqueuePublish(projectSnapshot); - var args = ProjectChangeEventArgs.CreateTestInstance(projectSnapshot, newer: null!, documentFilePath: null!, ProjectChangeKind.ProjectRemoved); - - // Act - publisher.ProjectManager_Changed(null!, args); - - // Assert - var kvp = Assert.Single(publisher.DeferredPublishTasks); - await kvp.Value; - - Assert.False(attemptedToSerialize); - } - - [Fact] - public async Task EnqueuePublish_BatchesPublishRequestsAsync() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var projectConfigurationFilePathStore = new DefaultProjectConfigurationFilePathStore(); - - var serializationSuccessful = false; - var firstSnapshot = CreateProjectSnapshot(@"C:\path\to\project.csproj"); - var secondSnapshot = CreateProjectSnapshot(@"C:\path\to\project.csproj", [@"C:\path\to\file.cshtml"]); - var expectedConfigurationFilePath = @"C:\path\to\obj\bin\Debug\project.razor.bin"; - var publisher = new TestRazorProjectInfoPublisher( - projectManager, - projectConfigurationFilePathStore, - LoggerFactory, - onSerializeToFile: (snapshot, configurationFilePath) => - { - Assert.Same(secondSnapshot, snapshot); - Assert.Equal(expectedConfigurationFilePath, configurationFilePath); - serializationSuccessful = true; - }) - { - EnqueueDelay = 10, - _active = true, - }; - - projectConfigurationFilePathStore.Set(firstSnapshot.Key, expectedConfigurationFilePath); - - // Act - publisher.EnqueuePublish(firstSnapshot); - publisher.EnqueuePublish(secondSnapshot); - - // Assert - var kvp = Assert.Single(publisher.DeferredPublishTasks); - await kvp.Value; - Assert.True(serializationSuccessful); - } - - [Fact] - public async Task EnqueuePublish_OnProjectWithoutRazor_Publishes() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var projectConfigurationFilePathStore = new DefaultProjectConfigurationFilePathStore(); - - var serializationSuccessful = false; - var firstSnapshot = CreateProjectSnapshot(@"C:\path\to\project.csproj"); - var secondSnapshot = CreateProjectSnapshot(@"C:\path\to\project.csproj", [@"C:\path\to\file.cshtml"]); - var expectedConfigurationFilePath = @"C:\path\to\objbin\Debug\project.razor.bin"; - var publisher = new TestRazorProjectInfoPublisher( - projectManager, - projectConfigurationFilePathStore, - LoggerFactory, - onSerializeToFile: (snapshot, configurationFilePath) => - { - Assert.Same(secondSnapshot, snapshot); - Assert.Equal(expectedConfigurationFilePath, configurationFilePath); - serializationSuccessful = true; - }, - useRealShouldSerialize: true) - { - EnqueueDelay = 10, - _active = true, - }; - - projectConfigurationFilePathStore.Set(secondSnapshot.Key, expectedConfigurationFilePath); - - // Act - publisher.EnqueuePublish(secondSnapshot); - - // Assert - var kvp = Assert.Single(publisher.DeferredPublishTasks); - await kvp.Value; - Assert.True(serializationSuccessful); - } - - [Fact] - public async Task EnqueuePublish_OnProjectBeforeTagHelperProcessed_DoesNotPublish() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var projectConfigurationFilePathStore = new DefaultProjectConfigurationFilePathStore(); - - var serializationSuccessful = false; - var firstSnapshot = CreateProjectSnapshot(@"C:\path\to\project.csproj"); - var tagHelpers = ImmutableArray.Create( - new TagHelperDescriptor(FileKinds.Component, "Namespace.FileNameOther", "Assembly", "FileName", "FileName document", "FileName hint", - caseSensitive: false, tagMatchingRules: default, attributeDescriptors: default, allowedChildTags: default, metadata: null!, diagnostics: default)); - - var secondSnapshot = CreateProjectSnapshot( - @"C:\path\to\project.csproj", - ProjectWorkspaceState.Create(tagHelpers, CodeAnalysis.CSharp.LanguageVersion.CSharp8), - ["FileName.razor"]); - - var expectedConfigurationFilePath = @"C:\path\to\obj\bin\Debug\project.razor.bin"; - var publisher = new TestRazorProjectInfoPublisher( - projectManager, - projectConfigurationFilePathStore, - LoggerFactory, - onSerializeToFile: (snapshot, configurationFilePath) => - { - Assert.Same(secondSnapshot, snapshot); - Assert.Equal(expectedConfigurationFilePath, configurationFilePath); - serializationSuccessful = true; - }, - useRealShouldSerialize: true) - { - EnqueueDelay = 10, - _active = true, - }; - - projectConfigurationFilePathStore.Set(firstSnapshot.Key, expectedConfigurationFilePath); - - // Act - publisher.EnqueuePublish(secondSnapshot); - - // Assert - var kvp = Assert.Single(publisher.DeferredPublishTasks); - await kvp.Value; - Assert.False(serializationSuccessful); - } - - [Fact] - public void Publish_UnsetConfigurationFilePath_Noops() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var projectConfigurationFilePathStore = new DefaultProjectConfigurationFilePathStore(); - - var publisher = new TestRazorProjectInfoPublisher(projectManager, projectConfigurationFilePathStore, LoggerFactory) - { - _active = true, - }; - - var omniSharpProjectSnapshot = CreateProjectSnapshot(@"C:\path\to\project.csproj"); - - // Act & Assert - publisher.Publish(omniSharpProjectSnapshot); - } - - [Fact] - public void Publish_PublishesToSetPublishFilePath() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var projectConfigurationFilePathStore = new DefaultProjectConfigurationFilePathStore(); - - var serializationSuccessful = false; - var omniSharpProjectSnapshot = CreateProjectSnapshot(@"C:\path\to\project.csproj"); - var expectedConfigurationFilePath = @"C:\path\to\obj\bin\Debug\project.razor.bin"; - var publisher = new TestRazorProjectInfoPublisher( - projectManager, - projectConfigurationFilePathStore, - LoggerFactory, - onSerializeToFile: (snapshot, configurationFilePath) => - { - Assert.Same(omniSharpProjectSnapshot, snapshot); - Assert.Equal(expectedConfigurationFilePath, configurationFilePath); - serializationSuccessful = true; - }) - { - _active = true, - }; - - projectConfigurationFilePathStore.Set(omniSharpProjectSnapshot.Key, expectedConfigurationFilePath); - - // Act - publisher.Publish(omniSharpProjectSnapshot); - - // Assert - Assert.True(serializationSuccessful); - } - - [UIFact] - public async Task ProjectAdded_PublishesToCorrectFilePathAsync() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var projectConfigurationFilePathStore = new DefaultProjectConfigurationFilePathStore(); - - var serializationSuccessful = false; - var expectedConfigurationFilePath = @"C:\path\to\obj\bin\Debug\project.razor.bin"; - - var publisher = new TestRazorProjectInfoPublisher( - projectManager, - projectConfigurationFilePathStore, - LoggerFactory, - onSerializeToFile: (snapshot, configurationFilePath) => - { - Assert.Equal(expectedConfigurationFilePath, configurationFilePath); - serializationSuccessful = true; - }) - { - _active = true, - }; - - var projectFilePath = @"C:\path\to\project.csproj"; - var hostProject = new HostProject(projectFilePath, Path.Combine(Path.GetDirectoryName(projectFilePath), "obj"), RazorConfiguration.Default, "TestRootNamespace"); - projectConfigurationFilePathStore.Set(hostProject.Key, expectedConfigurationFilePath); - var projectWorkspaceState = ProjectWorkspaceState.Default; - - // Act - await projectManager.UpdateAsync(updater => - { - updater.ProjectAdded(hostProject); - updater.ProjectWorkspaceStateChanged(hostProject.Key, projectWorkspaceState); - }); - - // Assert - Assert.True(serializationSuccessful); - } - - [UIFact] - public async Task ProjectAdded_DoesNotPublishWithoutProjectWorkspaceStateAsync() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var projectConfigurationFilePathStore = new DefaultProjectConfigurationFilePathStore(); - - var serializationSuccessful = false; - var expectedConfigurationFilePath = @"C:\path\to\obj\bin\Debug\project.razor.bin"; - - var publisher = new TestRazorProjectInfoPublisher( - projectManager, - projectConfigurationFilePathStore, - LoggerFactory, - onSerializeToFile: (snapshot, configurationFilePath) => - { - Assert.Fail("Serialization should not have been attempted because there is no ProjectWorkspaceState."); - serializationSuccessful = true; - }) - { - _active = true, - }; - - var hostProject = new HostProject(@"C:\path\to\project.csproj", @"C:\path\to\obj", RazorConfiguration.Default, "TestRootNamespace"); - projectConfigurationFilePathStore.Set(hostProject.Key, expectedConfigurationFilePath); - - // Act - await projectManager.UpdateAsync( - updater => updater.ProjectAdded(hostProject)); - - Assert.Empty(publisher.DeferredPublishTasks); - - // Assert - Assert.False(serializationSuccessful); - } - - [UIFact] - public async Task ProjectRemoved_UnSetPublishFilePath_NoopsAsync() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var projectConfigurationFilePathStore = new DefaultProjectConfigurationFilePathStore(); - - var publisher = new TestRazorProjectInfoPublisher(projectManager, projectConfigurationFilePathStore, LoggerFactory) - { - _active = true, - }; - - var hostProject = new HostProject(@"C:\path\to\project.csproj", @"C:\path\to\obj", RazorConfiguration.Default, "TestRootNamespace"); - await projectManager.UpdateAsync( - updater => updater.ProjectAdded(hostProject)); - - // Act & Assert - await projectManager.UpdateAsync( - updater => updater.ProjectRemoved(hostProject.Key)); - - Assert.Empty(publisher.DeferredPublishTasks); - } - - [UIFact] - public async Task ProjectAdded_DoesNotFireWhenNotReadyAsync() - { - // Arrange - var projectManager = CreateProjectSnapshotManager(); - var projectConfigurationFilePathStore = new DefaultProjectConfigurationFilePathStore(); - - var serializationSuccessful = false; - var expectedConfigurationFilePath = @"C:\path\to\obj\bin\Debug\project.razor.bin"; - - var publisher = new TestRazorProjectInfoPublisher( - projectManager, - projectConfigurationFilePathStore, - LoggerFactory, - onSerializeToFile: (snapshot, configurationFilePath) => - { - Assert.Equal(expectedConfigurationFilePath, configurationFilePath); - serializationSuccessful = true; - }, - shouldSerialize: false) - { - _active = true, - }; - - var projectFilePath = @"C:\path\to\project.csproj"; - var hostProject = new HostProject( - projectFilePath, - Path.Combine(Path.GetDirectoryName(projectFilePath), "obj"), - RazorConfiguration.Default, - "TestRootNamespace"); - projectConfigurationFilePathStore.Set(hostProject.Key, expectedConfigurationFilePath); - var projectWorkspaceState = ProjectWorkspaceState.Default; - - // Act - await projectManager.UpdateAsync(updater => - { - updater.ProjectAdded(hostProject); - updater.ProjectWorkspaceStateChanged(hostProject.Key, projectWorkspaceState); - }); - - // Assert - Assert.False(serializationSuccessful); - } - - internal static IProjectSnapshot CreateProjectSnapshot( - string projectFilePath, - ProjectWorkspaceState? projectWorkspaceState = null, - string[]? documentFilePaths = null) - { - return TestProjectSnapshot.Create(projectFilePath, documentFilePaths ?? [], projectWorkspaceState); - } - - internal static IProjectSnapshot CreateProjectSnapshot(string projectFilePath, string[] documentFilePaths) - { - return TestProjectSnapshot.Create(projectFilePath, documentFilePaths); - } - - private class TestRazorProjectInfoPublisher( - IProjectSnapshotManager projectManager, - ProjectConfigurationFilePathStore projectStatePublishFilePathStore, - ILoggerFactory loggerFactory, - Action? onSerializeToFile = null, - bool shouldSerialize = true, - bool useRealShouldSerialize = false, - bool configurationFileExists = true) - : RazorProjectInfoPublisher( - s_lspEditorFeatureDetector.Object, - projectManager, - projectStatePublishFilePathStore, - loggerFactory) - { - private static readonly StrictMock s_lspEditorFeatureDetector = new(); - - private readonly Action _onSerializeToFile = onSerializeToFile ?? ((_1, _2) => throw new XunitException("SerializeToFile should not have been called.")); - - private readonly bool _shouldSerialize = shouldSerialize; - private readonly bool _useRealShouldSerialize = useRealShouldSerialize; - private readonly bool _configurationFileExists = configurationFileExists; - - static TestRazorProjectInfoPublisher() - { - s_lspEditorFeatureDetector - .Setup(t => t.IsLSPEditorAvailable()) - .Returns(true); - } - - protected override bool FileExists(string file) - { - return _configurationFileExists; - } - - protected override void SerializeToFile(IProjectSnapshot projectSnapshot, string configurationFilePath) => _onSerializeToFile?.Invoke(projectSnapshot, configurationFilePath); - - protected override bool ShouldSerialize(IProjectSnapshot projectSnapshot, string configurationFilePath) - { - if (_useRealShouldSerialize) - { - return base.ShouldSerialize(projectSnapshot, configurationFilePath); - } - else - { - return _shouldSerialize; - } - } - } -} diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/ProjectSystem/DefaultWindowsRazorProjectHostTest.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/ProjectSystem/DefaultWindowsRazorProjectHostTest.cs index 493237acc64..99ebb5d5cc9 100644 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/ProjectSystem/DefaultWindowsRazorProjectHostTest.cs +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/ProjectSystem/DefaultWindowsRazorProjectHostTest.cs @@ -10,15 +10,11 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language; -using Microsoft.AspNetCore.Razor.ProjectSystem; using Microsoft.AspNetCore.Razor.Test.Common; using Microsoft.AspNetCore.Razor.Test.Common.ProjectSystem; using Microsoft.AspNetCore.Razor.Test.Common.VisualStudio; using Microsoft.AspNetCore.Razor.Test.Common.Workspaces; -using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.VisualStudio.Editor.Razor; using Microsoft.VisualStudio.ProjectSystem.Properties; -using Moq; using Xunit; using Xunit.Abstractions; using Rules = Microsoft.CodeAnalysis.Razor.ProjectSystem.Rules; @@ -30,7 +26,6 @@ public class DefaultWindowsRazorProjectHostTest : VisualStudioWorkspaceTestBase private readonly IServiceProvider _serviceProvider; private readonly ItemCollection _configurationItems; private readonly ItemCollection _extensionItems; - private readonly ProjectConfigurationFilePathStore _projectConfigurationFilePathStore; private readonly ItemCollection _razorComponentWithTargetPathItems; private readonly ItemCollection _razorGenerateWithTargetPathItems; private readonly PropertyCollection _razorGeneralProperties; @@ -49,11 +44,6 @@ public DefaultWindowsRazorProjectHostTest(ITestOutputHelper testOutput) _projectManager = CreateProjectSnapshotManager(); - var projectConfigurationFilePathStore = new Mock(MockBehavior.Strict); - projectConfigurationFilePathStore.Setup(s => s.Remove(It.IsAny())).Verifiable(); - projectConfigurationFilePathStore.Setup(s => s.Set(It.IsAny(), It.IsAny())).Verifiable(); - _projectConfigurationFilePathStore = projectConfigurationFilePathStore.Object; - _configurationItems = new ItemCollection(Rules.RazorConfiguration.SchemaName); _extensionItems = new ItemCollection(Rules.RazorExtension.SchemaName); _razorComponentWithTargetPathItems = new ItemCollection(Rules.RazorComponentWithTargetPath.SchemaName); @@ -634,7 +624,7 @@ public async Task DefaultRazorProjectHost_UIThread_CreateAndDispose_Succeeds() { // Arrange var services = new TestProjectSystemServices(TestProjectData.SomeProject.FilePath); - var host = new DefaultWindowsRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore, languageServerFeatureOptions: new TestLanguageServerFeatureOptions()); + var host = new DefaultWindowsRazorProjectHost(services, _serviceProvider, _projectManager, languageServerFeatureOptions: new TestLanguageServerFeatureOptions()); // Act & Assert await host.LoadAsync(); @@ -649,7 +639,7 @@ public async Task DefaultRazorProjectHost_BackgroundThread_CreateAndDispose_Succ { // Arrange var services = new TestProjectSystemServices(TestProjectData.SomeProject.FilePath); - var host = new DefaultWindowsRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore, languageServerFeatureOptions: new TestLanguageServerFeatureOptions()); + var host = new DefaultWindowsRazorProjectHost(services, _serviceProvider, _projectManager, languageServerFeatureOptions: new TestLanguageServerFeatureOptions()); // Act & Assert await Task.Run(async () => await host.LoadAsync()); @@ -668,7 +658,7 @@ public async Task DefaultRazorProjectHost_OnProjectChanged_NoRulesDefined() }; var services = new TestProjectSystemServices(TestProjectData.SomeProject.FilePath); - var host = new DefaultWindowsRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore, languageServerFeatureOptions: new TestLanguageServerFeatureOptions()); + var host = new DefaultWindowsRazorProjectHost(services, _serviceProvider, _projectManager, languageServerFeatureOptions: new TestLanguageServerFeatureOptions()); // Act & Assert await Task.Run(async () => await host.LoadAsync()); @@ -712,7 +702,7 @@ public async Task OnProjectChanged_ReadsProperties_InitializesProject() var services = new TestProjectSystemServices(TestProjectData.SomeProject.FilePath); - var host = new DefaultWindowsRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore, languageServerFeatureOptions: new TestLanguageServerFeatureOptions()); + var host = new DefaultWindowsRazorProjectHost(services, _serviceProvider, _projectManager, languageServerFeatureOptions: new TestLanguageServerFeatureOptions()); host.SkipIntermediateOutputPathExistCheck_TestOnly = true; await Task.Run(async () => await host.LoadAsync()); @@ -765,7 +755,7 @@ public async Task OnProjectChanged_ReadsProperties_InitializesProject() public void IntermediateOutputPathCalculationHandlesRelativePaths(string baseIntermediateOutputPath, string intermediateOutputPath, string expectedCombinedIOP) { var services = new TestProjectSystemServices(TestProjectData.SomeProject.FilePath); - var host = new DefaultWindowsRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore, languageServerFeatureOptions: new TestLanguageServerFeatureOptions()); + var host = new DefaultWindowsRazorProjectHost(services, _serviceProvider, _projectManager, languageServerFeatureOptions: new TestLanguageServerFeatureOptions()); var state = TestProjectRuleSnapshot.CreateProperties( WindowsRazorProjectHostBase.ConfigurationGeneralSchemaName, @@ -808,7 +798,7 @@ public async Task OnProjectChanged_NoVersionFound_DoesNotInitializeProject() }; var services = new TestProjectSystemServices(TestProjectData.SomeProject.FilePath); - var host = new DefaultWindowsRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore, languageServerFeatureOptions: new TestLanguageServerFeatureOptions()); + var host = new DefaultWindowsRazorProjectHost(services, _serviceProvider, _projectManager, languageServerFeatureOptions: new TestLanguageServerFeatureOptions()); await Task.Run(async () => await host.LoadAsync()); Assert.Empty(_projectManager.GetProjects()); @@ -859,7 +849,7 @@ public async Task OnProjectChanged_UpdateProject_MarksSolutionOpen() }; var services = new TestProjectSystemServices(TestProjectData.SomeProject.FilePath); - var host = new DefaultWindowsRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore, languageServerFeatureOptions: new TestLanguageServerFeatureOptions()); + var host = new DefaultWindowsRazorProjectHost(services, _serviceProvider, _projectManager, languageServerFeatureOptions: new TestLanguageServerFeatureOptions()); host.SkipIntermediateOutputPathExistCheck_TestOnly = true; await Task.Run(async () => await host.LoadAsync()); @@ -913,7 +903,7 @@ public async Task OnProjectChanged_UpdateProject_Succeeds() }; var services = new TestProjectSystemServices(TestProjectData.SomeProject.FilePath); - var host = new DefaultWindowsRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore, languageServerFeatureOptions: new TestLanguageServerFeatureOptions()); + var host = new DefaultWindowsRazorProjectHost(services, _serviceProvider, _projectManager, languageServerFeatureOptions: new TestLanguageServerFeatureOptions()); host.SkipIntermediateOutputPathExistCheck_TestOnly = true; await Task.Run(async () => await host.LoadAsync()); @@ -1069,7 +1059,7 @@ public async Task OnProjectChanged_VersionRemoved_DeInitializesProject() }; var services = new TestProjectSystemServices(TestProjectData.SomeProject.FilePath); - var host = new DefaultWindowsRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore, languageServerFeatureOptions: new TestLanguageServerFeatureOptions()); + var host = new DefaultWindowsRazorProjectHost(services, _serviceProvider, _projectManager, languageServerFeatureOptions: new TestLanguageServerFeatureOptions()); host.SkipIntermediateOutputPathExistCheck_TestOnly = true; await Task.Run(async () => await host.LoadAsync()); @@ -1144,7 +1134,7 @@ public async Task OnProjectChanged_AfterDispose_IgnoresUpdate() }; var services = new TestProjectSystemServices(TestProjectData.SomeProject.FilePath); - var host = new DefaultWindowsRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore, languageServerFeatureOptions: new TestLanguageServerFeatureOptions()); + var host = new DefaultWindowsRazorProjectHost(services, _serviceProvider, _projectManager, languageServerFeatureOptions: new TestLanguageServerFeatureOptions()); host.SkipIntermediateOutputPathExistCheck_TestOnly = true; await Task.Run(async () => await host.LoadAsync()); @@ -1224,7 +1214,7 @@ public async Task OnProjectRenamed_RemovesHostProject_CopiesConfiguration() var services = new TestProjectSystemServices(TestProjectData.SomeProject.FilePath); - var host = new DefaultWindowsRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore, languageServerFeatureOptions: new TestLanguageServerFeatureOptions()); + var host = new DefaultWindowsRazorProjectHost(services, _serviceProvider, _projectManager, languageServerFeatureOptions: new TestLanguageServerFeatureOptions()); host.SkipIntermediateOutputPathExistCheck_TestOnly = true; await Task.Run(async () => await host.LoadAsync()); @@ -1321,7 +1311,7 @@ public async Task OnProjectChanged_ChangeIntermediateOutputPath_RemovesAndAddsPr }; var services = new TestProjectSystemServices(TestProjectData.SomeProject.FilePath); - var host = new DefaultWindowsRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore, languageServerFeatureOptions: new TestLanguageServerFeatureOptions()); + var host = new DefaultWindowsRazorProjectHost(services, _serviceProvider, _projectManager, languageServerFeatureOptions: new TestLanguageServerFeatureOptions()); host.SkipIntermediateOutputPathExistCheck_TestOnly = true; await Task.Run(async () => await host.LoadAsync()); diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/ProjectSystem/FallbackProjectManagerTest.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/ProjectSystem/FallbackProjectManagerTest.cs index aa083c0c6cc..82845dea721 100644 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/ProjectSystem/FallbackProjectManagerTest.cs +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/ProjectSystem/FallbackProjectManagerTest.cs @@ -22,13 +22,11 @@ public class FallbackProjectManagerTest : VisualStudioWorkspaceTestBase { private readonly FallbackProjectManager _fallbackProjectManger; private readonly TestProjectSnapshotManager _projectManager; - private readonly TestProjectConfigurationFilePathStore _projectConfigurationFilePathStore; public FallbackProjectManagerTest(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { var languageServerFeatureOptions = TestLanguageServerFeatureOptions.Instance; - _projectConfigurationFilePathStore = new TestProjectConfigurationFilePathStore(); var serviceProvider = VsMocks.CreateServiceProvider(static b => b.AddComponentModel(static b => @@ -41,7 +39,6 @@ public FallbackProjectManagerTest(ITestOutputHelper testOutputHelper) _fallbackProjectManger = new FallbackProjectManager( serviceProvider, - _projectConfigurationFilePathStore, languageServerFeatureOptions, _projectManager, WorkspaceProvider, @@ -243,31 +240,6 @@ public async Task DynamicFileAdded_TrackedProject_IgnoresDocumentFromOutsideCone Assert.Equal(SomeProjectFile1.TargetPath, project.GetDocument(SomeProjectFile1.FilePath)!.TargetPath); } - [UIFact] - public async Task DynamicFileAdded_UnknownProject_SetsConfigurationFileStore() - { - var projectId = ProjectId.CreateNewId(); - var projectInfo = ProjectInfo.Create( - projectId, VersionStamp.Default, "DisplayName", "AssemblyName", LanguageNames.CSharp, filePath: SomeProject.FilePath) - .WithCompilationOutputInfo(new CompilationOutputInfo().WithAssemblyPath(Path.Combine(SomeProject.IntermediateOutputPath, "SomeProject.dll"))) - .WithDefaultNamespace("RootNamespace"); - - Workspace.TryApplyChanges(Workspace.CurrentSolution.AddProject(projectInfo)); - - _fallbackProjectManger.DynamicFileAdded( - projectId, - SomeProject.Key, - SomeProject.FilePath, - SomeProjectFile1.FilePath, - DisposalToken); - - await WaitForProjectManagerUpdatesAsync(); - - var kvp = Assert.Single(_projectConfigurationFilePathStore.GetMappings()); - Assert.Equal(SomeProject.Key, kvp.Key); - Assert.Equal(Path.Combine(SomeProject.IntermediateOutputPath, "project.razor.bin"), kvp.Value); - } - private Task WaitForProjectManagerUpdatesAsync() { // The FallbackProjectManager fires and forgets any updates to the project manager. diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/ProjectSystem/FallbackWindowsRazorProjectHostTest.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/ProjectSystem/FallbackWindowsRazorProjectHostTest.cs index 4a068275ef5..171a3f96949 100644 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/ProjectSystem/FallbackWindowsRazorProjectHostTest.cs +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/ProjectSystem/FallbackWindowsRazorProjectHostTest.cs @@ -10,13 +10,10 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language; -using Microsoft.AspNetCore.Razor.ProjectSystem; using Microsoft.AspNetCore.Razor.Test.Common.ProjectSystem; using Microsoft.AspNetCore.Razor.Test.Common.VisualStudio; using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.VisualStudio.Editor.Razor; using Microsoft.VisualStudio.ProjectSystem.Properties; -using Moq; using Xunit; using Xunit.Abstractions; using ItemReference = Microsoft.VisualStudio.Razor.ProjectSystem.ManagedProjectSystemSchema.ItemReference; @@ -28,7 +25,6 @@ public class FallbackWindowsRazorProjectHostTest : VisualStudioWorkspaceTestBase private readonly IServiceProvider _serviceProvider; private readonly ItemCollection _referenceItems; private readonly TestProjectSnapshotManager _projectManager; - private readonly ProjectConfigurationFilePathStore _projectConfigurationFilePathStore; private readonly ItemCollection _contentItems; private readonly ItemCollection _noneItems; @@ -44,10 +40,6 @@ public FallbackWindowsRazorProjectHostTest(ITestOutputHelper testOutput) _projectManager = CreateProjectSnapshotManager(); - var projectConfigurationFilePathStore = new Mock(MockBehavior.Strict); - projectConfigurationFilePathStore.Setup(s => s.Remove(It.IsAny())).Verifiable(); - _projectConfigurationFilePathStore = projectConfigurationFilePathStore.Object; - _referenceItems = new ItemCollection(ManagedProjectSystemSchema.ResolvedCompilationReference.SchemaName); _contentItems = new ItemCollection(ManagedProjectSystemSchema.ContentItem.SchemaName); _noneItems = new ItemCollection(ManagedProjectSystemSchema.NoneItem.SchemaName); @@ -71,7 +63,7 @@ public void GetChangedAndRemovedDocuments_ReturnsChangedContentAndNoneItems() }); var services = new TestProjectSystemServices("C:\\To\\Test.csproj"); - var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore); + var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager); var changes = new TestProjectChangeDescription[] { afterChangeContentItems.ToChange(_contentItems.ToSnapshot()), @@ -113,11 +105,11 @@ public void GetCurrentDocuments_ReturnsContentAndNoneItems() }); var services = new TestProjectSystemServices("C:\\To\\Test.csproj"); - var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore); + var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager); var changes = new TestProjectChangeDescription[] { - _contentItems.ToChange(), - _noneItems.ToChange(), + _contentItems.ToChange(), + _noneItems.ToChange(), }; var update = services.CreateUpdate(changes).Value; @@ -156,11 +148,11 @@ public void GetCurrentDocuments_IgnoresDotRazorFiles() }); var services = new TestProjectSystemServices("C:\\To\\Test.csproj"); - var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore); + var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager); var changes = new TestProjectChangeDescription[] { - _contentItems.ToChange(), - _noneItems.ToChange(), + _contentItems.ToChange(), + _noneItems.ToChange(), }; var update = services.CreateUpdate(changes).Value; @@ -177,7 +169,7 @@ public void TryGetRazorDocument_NoFilePath_ReturnsFalse() // Arrange var services = new TestProjectSystemServices("C:\\To\\Test.csproj"); - var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore); + var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager); var itemState = new Dictionary() { [ItemReference.LinkPropertyName] = "Index.cshtml", @@ -197,7 +189,7 @@ public void TryGetRazorDocument_NonRazorFilePath_ReturnsFalse() // Arrange var services = new TestProjectSystemServices("C:\\Path\\Test.csproj"); - var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore); + var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager); var itemState = new Dictionary() { [ItemReference.FullPathPropertyName] = "C:\\Path\\site.css", @@ -217,7 +209,7 @@ public void TryGetRazorDocument_NonRazorTargetPath_ReturnsFalse() // Arrange var services = new TestProjectSystemServices("C:\\Path\\To\\Test.csproj"); - var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore); + var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager); var itemState = new Dictionary() { [ItemReference.LinkPropertyName] = "site.html", @@ -239,7 +231,7 @@ public void TryGetRazorDocument_JustFilePath_ReturnsTrue() var expectedPath = "C:\\Path\\Index.cshtml"; var services = new TestProjectSystemServices("C:\\Path\\Test.csproj"); - var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore); + var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager); var itemState = new Dictionary() { [ItemReference.FullPathPropertyName] = expectedPath, @@ -262,7 +254,7 @@ public void TryGetRazorDocument_LinkedFilepath_ReturnsTrue() var expectedTargetPath = "C:\\Path\\To\\Index.cshtml"; var services = new TestProjectSystemServices("C:\\Path\\To\\Test.csproj"); - var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore); + var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager); var itemState = new Dictionary() { [ItemReference.LinkPropertyName] = "Index.cshtml", @@ -286,7 +278,7 @@ public void TryGetRazorDocument_SetsLegacyFileKind() var expectedTargetPath = "C:\\Path\\To\\Index.cshtml"; var services = new TestProjectSystemServices("C:\\Path\\To\\Test.csproj"); - var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore); + var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager); var itemState = new Dictionary() { [ItemReference.LinkPropertyName] = "Index.cshtml", @@ -309,7 +301,7 @@ public async Task FallbackRazorProjectHost_UIThread_CreateAndDispose_Succeeds() // Arrange var services = new TestProjectSystemServices("C:\\To\\Test.csproj"); - var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore); + var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager); // Act & Assert await host.LoadAsync(); @@ -325,7 +317,7 @@ public async Task FallbackRazorProjectHost_BackgroundThread_CreateAndDispose_Suc // Arrange var services = new TestProjectSystemServices("Test.csproj"); - var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore); + var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager); // Act & Assert await Task.Run(async () => await host.LoadAsync()); @@ -345,7 +337,7 @@ public async Task OnProjectChanged_NoRulesDefined() var services = new TestProjectSystemServices("Test.csproj"); - var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore) + var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager) { AssemblyVersion = new Version(2, 0), }; @@ -381,7 +373,7 @@ public async Task OnProjectChanged_ReadsProperties_InitializesProject() var services = new TestProjectSystemServices("C:\\Path\\Test.csproj"); - var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore) + var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager) { AssemblyVersion = new Version(2, 0), // Mock for reading the assembly's version }; @@ -416,7 +408,7 @@ public async Task OnProjectChanged_NoAssemblyFound_DoesNotInitializeProject() }; var services = new TestProjectSystemServices("Test.csproj"); - var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore); + var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager); await Task.Run(async () => await host.LoadAsync()); Assert.Empty(_projectManager.GetProjects()); @@ -444,7 +436,7 @@ public async Task OnProjectChanged_AssemblyFoundButCannotReadVersion_DoesNotInit var services = new TestProjectSystemServices("Test.csproj"); - var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore); + var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager); await Task.Run(async () => await host.LoadAsync()); Assert.Empty(_projectManager.GetProjects()); @@ -483,7 +475,7 @@ public async Task OnProjectChanged_UpdateProject_Succeeds() var services = new TestProjectSystemServices("C:\\Path\\Test.csproj"); - var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore) + var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager) { AssemblyVersion = new Version(2, 0), }; @@ -528,7 +520,7 @@ public async Task OnProjectChanged_VersionRemoved_DeinitializesProject() var services = new TestProjectSystemServices("Test.csproj"); - var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore) + var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager) { AssemblyVersion = new Version(2, 0), }; @@ -573,7 +565,7 @@ public async Task OnProjectChanged_AfterDispose_IgnoresUpdate() var services = new TestProjectSystemServices("C:\\Path\\Test.csproj"); - var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore) + var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager) { AssemblyVersion = new Version(2, 0), }; @@ -618,7 +610,7 @@ public async Task OnProjectRenamed_RemovesHostProject_CopiesConfiguration() var services = new TestProjectSystemServices("Test.csproj"); - var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager, _projectConfigurationFilePathStore) + var host = new TestFallbackRazorProjectHost(services, _serviceProvider, _projectManager) { AssemblyVersion = new Version(2, 0), // Mock for reading the assembly's version }; @@ -652,9 +644,8 @@ private class TestFallbackRazorProjectHost : FallbackWindowsRazorProjectHost internal TestFallbackRazorProjectHost( IUnconfiguredProjectCommonServices commonServices, IServiceProvider serviceProvider, - IProjectSnapshotManager projectManager, - ProjectConfigurationFilePathStore projectConfigurationFilePathStore) - : base(commonServices, serviceProvider, projectManager, projectConfigurationFilePathStore, languageServerFeatureOptions: null) + IProjectSnapshotManager projectManager) + : base(commonServices, serviceProvider, projectManager) { SkipIntermediateOutputPathExistCheck_TestOnly = true; } diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/ProjectSystem/RazorDynamicFileInfoProviderTest.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/ProjectSystem/RazorDynamicFileInfoProviderTest.cs index 9f69174c2dd..92963ea1b30 100644 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/ProjectSystem/RazorDynamicFileInfoProviderTest.cs +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/ProjectSystem/RazorDynamicFileInfoProviderTest.cs @@ -75,7 +75,6 @@ await _projectManager.UpdateAsync(updater => var fallbackProjectManager = new FallbackProjectManager( serviceProvider, - StrictMock.Of(), languageServerFeatureOptions, _projectManager, WorkspaceProvider, diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/ProjectSystem/TestProjectConfigurationFilePathStore.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/ProjectSystem/TestProjectConfigurationFilePathStore.cs deleted file mode 100644 index 51d031dfca2..00000000000 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/ProjectSystem/TestProjectConfigurationFilePathStore.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Microsoft.AspNetCore.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Razor.ProjectSystem; - -namespace Microsoft.VisualStudio.Razor.ProjectSystem; - -internal class TestProjectConfigurationFilePathStore : ProjectConfigurationFilePathStore -{ - private Dictionary _mappings = new(); - - public override event EventHandler? Changed { add { } remove { } } - - public override IReadOnlyDictionary GetMappings() - => _mappings; - - public override void Remove(ProjectKey projectKey) - => _mappings.Remove(projectKey); - - public override void Set(ProjectKey projectKey, string configurationFilePath) - => _mappings[projectKey] = configurationFilePath; - - public override bool TryGet(ProjectKey projectKey, [NotNullWhen(true)] out string? configurationFilePath) - => _mappings.TryGetValue(projectKey, out configurationFilePath); -} diff --git a/src/Razor/test/Microsoft.VisualStudio.Razor.IntegrationTests/AbstractRazorEditorTest.cs b/src/Razor/test/Microsoft.VisualStudio.Razor.IntegrationTests/AbstractRazorEditorTest.cs index 5d25a4893ca..851d3aaec12 100644 --- a/src/Razor/test/Microsoft.VisualStudio.Razor.IntegrationTests/AbstractRazorEditorTest.cs +++ b/src/Razor/test/Microsoft.VisualStudio.Razor.IntegrationTests/AbstractRazorEditorTest.cs @@ -9,6 +9,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor; using Microsoft.CodeAnalysis.Razor.Logging; using Microsoft.Internal.VisualStudio.Shell.Interop; using Microsoft.VisualStudio.Settings; @@ -26,6 +27,7 @@ public abstract class AbstractRazorEditorTest(ITestOutputHelper testOutputHelper private readonly ITestOutputHelper _testOutputHelper = testOutputHelper; private ILogger? _testLogger; + private string? _projectFilePath; protected virtual bool ComponentClassificationExpected => true; @@ -33,6 +35,8 @@ public abstract class AbstractRazorEditorTest(ITestOutputHelper testOutputHelper protected virtual string TargetFrameworkElement => $"""{TargetFramework}"""; + protected string ProjectFilePath => _projectFilePath.AssumeNotNull(); + public override async Task InitializeAsync() { await base.InitializeAsync(); @@ -43,15 +47,15 @@ public override async Task InitializeAsync() VisualStudioLogging.AddCustomLoggers(); - var projectFilePath = await CreateAndOpenBlazorProjectAsync(ControlledHangMitigatingCancellationToken); + _projectFilePath = await CreateAndOpenBlazorProjectAsync(ControlledHangMitigatingCancellationToken); await TestServices.SolutionExplorer.RestoreNuGetPackagesAsync(ControlledHangMitigatingCancellationToken); await TestServices.Workspace.WaitForProjectSystemAsync(ControlledHangMitigatingCancellationToken); - await TestServices.RazorProjectSystem.WaitForProjectFileAsync(projectFilePath, ControlledHangMitigatingCancellationToken); + await TestServices.RazorProjectSystem.WaitForProjectFileAsync(_projectFilePath, ControlledHangMitigatingCancellationToken); var razorFilePath = await TestServices.SolutionExplorer.GetAbsolutePathForProjectRelativeFilePathAsync(RazorProjectConstants.BlazorProjectName, RazorProjectConstants.IndexRazorFile, ControlledHangMitigatingCancellationToken); - await TestServices.RazorProjectSystem.WaitForRazorFileInProjectAsync(projectFilePath, razorFilePath, ControlledHangMitigatingCancellationToken); + await TestServices.RazorProjectSystem.WaitForRazorFileInProjectAsync(_projectFilePath, razorFilePath, ControlledHangMitigatingCancellationToken); // We open the Index.razor file, and wait for 3 RazorComponentElement's to be classified, as that // way we know the LSP server is up, running, and has processed both local and library-sourced Components diff --git a/src/Razor/test/Microsoft.VisualStudio.Razor.IntegrationTests/InProcess/RazorProjectSystemInProcess.cs b/src/Razor/test/Microsoft.VisualStudio.Razor.IntegrationTests/InProcess/RazorProjectSystemInProcess.cs index 04265cf350c..168c4c5c0fd 100644 --- a/src/Razor/test/Microsoft.VisualStudio.Razor.IntegrationTests/InProcess/RazorProjectSystemInProcess.cs +++ b/src/Razor/test/Microsoft.VisualStudio.Razor.IntegrationTests/InProcess/RazorProjectSystemInProcess.cs @@ -11,6 +11,7 @@ using Xunit; using Microsoft.VisualStudio.Razor.LanguageClient; using Microsoft.AspNetCore.Razor.Threading; +using System.Collections.Immutable; namespace Microsoft.VisualStudio.Extensibility.Testing; @@ -71,6 +72,16 @@ await Helper.RetryAsync(ct => }, TimeSpan.FromMilliseconds(100), cancellationToken); } + public async Task> GetProjectKeyIdsForProjectAsync(string projectFilePath, CancellationToken cancellationToken) + { + var projectManager = await TestServices.Shell.GetComponentModelServiceAsync(cancellationToken); + Assert.NotNull(projectManager); + + var projectKeys = projectManager.GetAllProjectKeys(projectFilePath); + + return projectKeys.SelectAsArray(key => key.Id); + } + public async Task WaitForCSharpVirtualDocumentAsync(string razorFilePath, CancellationToken cancellationToken) { var documentManager = await TestServices.Shell.GetComponentModelServiceAsync(cancellationToken); @@ -93,4 +104,3 @@ await Helper.RetryAsync(ct => }, TimeSpan.FromMilliseconds(100), cancellationToken); } } - diff --git a/src/Razor/test/Microsoft.VisualStudio.Razor.IntegrationTests/MultiTargetProjectTests.cs b/src/Razor/test/Microsoft.VisualStudio.Razor.IntegrationTests/MultiTargetProjectTests.cs index e13bd2417a9..006bccb5f92 100644 --- a/src/Razor/test/Microsoft.VisualStudio.Razor.IntegrationTests/MultiTargetProjectTests.cs +++ b/src/Razor/test/Microsoft.VisualStudio.Razor.IntegrationTests/MultiTargetProjectTests.cs @@ -2,7 +2,6 @@ // Licensed under the MIT license. See License.txt in the project root for license information. using System.IO; -using System.Linq; using System.Threading.Tasks; using Xunit; using Xunit.Abstractions; @@ -11,19 +10,23 @@ namespace Microsoft.VisualStudio.Razor.IntegrationTests; public class MultiTargetProjectTests(ITestOutputHelper testOutputHelper) : AbstractRazorEditorTest(testOutputHelper) { - protected override string TargetFrameworkElement => $"""net7.0;{TargetFramework}"""; + private const string OtherTargetFramework = "net7.0"; + + protected override string TargetFrameworkElement => $"""{OtherTargetFramework};{TargetFramework}"""; [IdeFact] - public async Task ValidateMultipleJsonFiles() + public async Task ValidateMultipleProjects() { - var solutionPath = await TestServices.SolutionExplorer.GetDirectoryNameAsync(ControlledHangMitigatingCancellationToken); + // This just verifies that there are actually two projects present with the same file path: + // one for each target framework. + + var projectKeyIds = await TestServices.RazorProjectSystem.GetProjectKeyIdsForProjectAsync(ProjectFilePath, ControlledHangMitigatingCancellationToken); - // This is a little odd, but there is no "real" way to check this via VS, and one of the most important things this test can do - // is ensure that each target framework gets its own project.razor.bin file, and doesn't share one from a cache or anything. - Assert.Equal(2, GetProjectRazorJsonFileCount()); + projectKeyIds = projectKeyIds.Sort(); - int GetProjectRazorJsonFileCount() - => Directory.EnumerateFiles(solutionPath, "project.razor.*.bin", SearchOption.AllDirectories).Count(); + Assert.Equal(2, projectKeyIds.Length); + Assert.Contains(OtherTargetFramework, projectKeyIds[0]); + Assert.Contains(TargetFramework, projectKeyIds[1]); } [IdeFact] @@ -53,9 +56,8 @@ public async Task OpenExistingProject() Assert.Equal(expectedProjectFileName, actualProjectFileName); } - [IdeTheory] - [CombinatorialData] - public async Task OpenExistingProject_WithReopenedFile(bool deleteProjectRazorJson) + [IdeFact] + public async Task OpenExistingProject_WithReopenedFile() { var solutionPath = await TestServices.SolutionExplorer.GetDirectoryNameAsync(ControlledHangMitigatingCancellationToken); var expectedProjectFileName = await TestServices.SolutionExplorer.GetAbsolutePathForProjectRelativeFilePathAsync(RazorProjectConstants.BlazorProjectName, RazorProjectConstants.ProjectFile, ControlledHangMitigatingCancellationToken); @@ -66,13 +68,6 @@ public async Task OpenExistingProject_WithReopenedFile(bool deleteProjectRazorJs await TestServices.SolutionExplorer.CloseSolutionAsync(ControlledHangMitigatingCancellationToken); - if (deleteProjectRazorJson) - { - // Clear out the project.razor.bin file which ensures our restored file will have to be in the Misc Project - var projectRazorJsonFileName = Directory.EnumerateFiles(solutionPath, "project.razor.*.bin", SearchOption.AllDirectories).First(); - File.Delete(projectRazorJsonFileName); - } - var solutionFileName = Path.Combine(solutionPath, RazorProjectConstants.BlazorSolutionName + ".sln"); await TestServices.SolutionExplorer.OpenSolutionAsync(solutionFileName, ControlledHangMitigatingCancellationToken); diff --git a/src/Razor/test/Microsoft.VisualStudio.Razor.IntegrationTests/ProjectTests.cs b/src/Razor/test/Microsoft.VisualStudio.Razor.IntegrationTests/ProjectTests.cs index 00431964460..ad744cb6260 100644 --- a/src/Razor/test/Microsoft.VisualStudio.Razor.IntegrationTests/ProjectTests.cs +++ b/src/Razor/test/Microsoft.VisualStudio.Razor.IntegrationTests/ProjectTests.cs @@ -114,36 +114,4 @@ public async Task OpenExistingProject_WithReopenedFile() await TestServices.Editor.CloseCodeFileAsync(RazorProjectConstants.BlazorProjectName, RazorProjectConstants.ErrorCshtmlFile, saveFile: false, ControlledHangMitigatingCancellationToken); } - - [IdeFact] - public async Task OpenExistingProject_WithReopenedFile_NoProjectRazorJson() - { - var solutionPath = await TestServices.SolutionExplorer.GetDirectoryNameAsync(ControlledHangMitigatingCancellationToken); - var expectedProjectFileName = await TestServices.SolutionExplorer.GetAbsolutePathForProjectRelativeFilePathAsync(RazorProjectConstants.BlazorProjectName, RazorProjectConstants.ProjectFile, ControlledHangMitigatingCancellationToken); - - // Open SurveyPrompt and make sure its all up and running - await TestServices.SolutionExplorer.OpenFileAsync(RazorProjectConstants.BlazorProjectName, RazorProjectConstants.ErrorCshtmlFile, ControlledHangMitigatingCancellationToken); - await TestServices.Editor.WaitForSemanticClassificationAsync("class name", ControlledHangMitigatingCancellationToken, count: 1); - - await TestServices.SolutionExplorer.CloseSolutionAsync(ControlledHangMitigatingCancellationToken); - - // Clear out the project.razor.bin file which ensures our restored file will have to be in the Misc Project - var projectRazorJsonFileName = Directory.EnumerateFiles(solutionPath, "project.razor.*.bin", SearchOption.AllDirectories).First(); - File.Delete(projectRazorJsonFileName); - - var solutionFileName = Path.Combine(solutionPath, RazorProjectConstants.BlazorSolutionName + ".sln"); - await TestServices.SolutionExplorer.OpenSolutionAsync(solutionFileName, ControlledHangMitigatingCancellationToken); - - await TestServices.Workspace.WaitForProjectSystemAsync(ControlledHangMitigatingCancellationToken); - - await TestServices.Editor.WaitForSemanticClassificationAsync("class name", ControlledHangMitigatingCancellationToken, count: 1); - - TestServices.Input.Send("1"); - - // Make sure the test framework didn't do something weird and create new project - var actualProjectFileName = await TestServices.SolutionExplorer.GetAbsolutePathForProjectRelativeFilePathAsync(RazorProjectConstants.BlazorProjectName, RazorProjectConstants.ProjectFile, ControlledHangMitigatingCancellationToken); - Assert.Equal(expectedProjectFileName, actualProjectFileName); - - await TestServices.Editor.CloseCodeFileAsync(RazorProjectConstants.BlazorProjectName, RazorProjectConstants.ErrorCshtmlFile, saveFile: false, ControlledHangMitigatingCancellationToken); - } }