diff --git a/.github/memory/testing/ide.md b/.github/memory/testing/ide.md index 7da57bf11ffd..2f32170883d7 100644 --- a/.github/memory/testing/ide.md +++ b/.github/memory/testing/ide.md @@ -35,3 +35,7 @@ public class MyTests test source code. - Keep tests focused — avoid unnecessary intermediary assertions; use `.Single()` rather than asserting a count then indexing. +- Language Server orchestration tests can pass additional MEF parts to + `LanguageServerTestComposition.GetSharedExportProvider`. A controllable + `PartNotDiscoverable` project loader can provide deterministic design-time + build timing and results without invoking MSBuild. diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/LanguageServerProjectLoaderTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/LanguageServerProjectLoaderTests.cs new file mode 100644 index 000000000000..99618dbd5c37 --- /dev/null +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/LanguageServerProjectLoaderTests.cs @@ -0,0 +1,553 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +extern alias MSBuildWorkspacesContracts; + +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Composition; +using Microsoft.CodeAnalysis.Host; +using Microsoft.CodeAnalysis.Host.Mef; +using Microsoft.CodeAnalysis.LanguageServer.Handler; +using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace; +using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.ProjectTelemetry; +using Microsoft.CodeAnalysis.LanguageServer.Services; +using Microsoft.CodeAnalysis.MSBuild; +using Microsoft.CodeAnalysis.Options; +using Microsoft.CodeAnalysis.ProjectSystem; +using Microsoft.CodeAnalysis.Shared.TestHooks; +using Microsoft.CodeAnalysis.Test.Utilities; +using Microsoft.CodeAnalysis.Workspaces.ProjectSystem; +using Microsoft.CommonLanguageServerProtocol.Framework; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.Composition; +using Roslyn.Test.Utilities; +using Roslyn.Utilities; +using Xunit.Abstractions; +using LSP = Roslyn.LanguageServer.Protocol; +using ProjectFileInfo = MSBuildWorkspacesContracts::Microsoft.CodeAnalysis.MSBuild.ProjectFileInfo; + +namespace Microsoft.CodeAnalysis.LanguageServer.UnitTests; + +[UseExportProvider] +public sealed class LanguageServerProjectLoaderTests(ITestOutputHelper testOutputHelper) : AbstractLanguageServerHostTests(testOutputHelper) +{ + private protected override Task CreateExportProviderAsync( + ServerConfiguration serverConfiguration, + ILoggerFactory loggerFactory, + ExtensionAssemblyManager extensionManager, + IAssemblyLoader assemblyLoader) + => Task.FromResult(LanguageServerTestComposition.GetSharedExportProvider( + serverConfiguration, loggerFactory, typeof(TestProjectLoaderFactory))); + + [Fact] + public async Task ConcurrentCallersShareLoadedProjectAndCompleteAfterWorkspaceCommit() + { + await using var server = await CreateLanguageServerAsync(serverConfiguration: ServerConfigurationWithoutDevKit); + var loader = server.GetRequiredLspService(); + var designTimeBuild = loader.QueueDesignTimeBuild(); + var projectPath = Path.Combine(TempRoot.Root, "Project.csproj"); + var equivalentPath = Path.Combine(TempRoot.Root, "directory", "..", "Project.csproj"); + + var firstLoadedProject = await loader.BeginLoadAsync(projectPath); + var secondLoadedProject = await loader.BeginLoadAsync(equivalentPath); + + Assert.Same(firstLoadedProject, secondLoadedProject); + Assert.False(firstLoadedProject.WaitForLoadAsync(CancellationToken.None).AsTask().IsCompleted); + await designTimeBuild.Started.Task.WaitAsync(TestHelpers.HangMitigatingTimeout); + Assert.Equal(1, loader.DesignTimeBuildCount); + + designTimeBuild.CompleteSuccessfully(loader.WorkspaceFactory.HostProjectFactory, projectPath); + var loadedSuccessfully = await firstLoadedProject.WaitForLoadAsync(CancellationToken.None).AsTask().WaitAsync(TestHelpers.HangMitigatingTimeout); + + Assert.True(loadedSuccessfully); + Assert.NotEmpty(loader.WorkspaceFactory.HostWorkspace.CurrentSolution.Projects); + } + + [Fact] + public async Task LoadedProjectReturnsWithoutAnotherDesignTimeBuild() + { + await using var server = await CreateLanguageServerAsync(serverConfiguration: ServerConfigurationWithoutDevKit); + var loader = server.GetRequiredLspService(); + var designTimeBuild = loader.QueueDesignTimeBuild(); + var projectPath = Path.Combine(TempRoot.Root, "Project.csproj"); + + var firstLoadedProject = await loader.BeginLoadAsync(projectPath); + await designTimeBuild.Started.Task.WaitAsync(TestHelpers.HangMitigatingTimeout); + designTimeBuild.CompleteSuccessfully(loader.WorkspaceFactory.HostProjectFactory, projectPath); + var firstStatus = await firstLoadedProject.WaitForLoadAsync(CancellationToken.None).AsTask().WaitAsync(TestHelpers.HangMitigatingTimeout); + + var loadedProject = await loader.BeginLoadAsync(projectPath); + + Assert.Same(firstLoadedProject, loadedProject); + Assert.Equal(firstStatus, await loadedProject.WaitForLoadAsync(CancellationToken.None)); + Assert.Equal(1, loader.DesignTimeBuildCount); + } + + [Fact] + public async Task NeedsReloadTriggersAnotherDesignTimeBuildAfterInitialLoadCompletes() + { + await using var server = await CreateLanguageServerAsync(serverConfiguration: ServerConfigurationWithoutDevKit); + var loader = server.GetRequiredLspService(); + var firstDesignTimeBuild = loader.QueueDesignTimeBuild(); + var secondDesignTimeBuild = loader.QueueDesignTimeBuild(); + var projectPath = Path.Combine(TempRoot.Root, "Project.csproj"); + + var loadedProject = await loader.BeginLoadAsync(projectPath); + await firstDesignTimeBuild.Started.Task.WaitAsync(TestHelpers.HangMitigatingTimeout); + firstDesignTimeBuild.CompleteSuccessfully(loader.WorkspaceFactory.HostProjectFactory, projectPath); + await loadedProject.WaitForLoadAsync(CancellationToken.None).AsTask().WaitAsync(TestHelpers.HangMitigatingTimeout); + + loadedProject.GetTestAccessor().RaiseNeedsReload(); + + // A file-change-triggered reload after the initial load has committed must still reach the MSBuild host, + // rather than being dropped because it carries the (already-completed) load operation from the initial request. + await secondDesignTimeBuild.Started.Task.WaitAsync(TestHelpers.HangMitigatingTimeout); + secondDesignTimeBuild.CompleteSuccessfully(loader.WorkspaceFactory.HostProjectFactory, projectPath); + await loader.WaitForCurrentBatchAsync(); + + Assert.Equal(2, loader.DesignTimeBuildCount); + } + + [Fact] + public async Task FailedProjectOnlyRetriesForFileChange() + { + await using var server = await CreateLanguageServerAsync(serverConfiguration: ServerConfigurationWithoutDevKit); + var loader = server.GetRequiredLspService(); + var failedDesignTimeBuild = loader.QueueDesignTimeBuild(); + var successfulReload = loader.QueueDesignTimeBuild(); + var projectPath = Path.Combine(TempRoot.Root, "Project.csproj"); + + var firstLoadedProject = await loader.BeginLoadAsync(projectPath); + await failedDesignTimeBuild.Started.Task.WaitAsync(TestHelpers.HangMitigatingTimeout); + failedDesignTimeBuild.Fail(new InvalidOperationException("Expected test failure")); + Assert.False(await firstLoadedProject.WaitForLoadAsync(CancellationToken.None).AsTask().WaitAsync(TestHelpers.HangMitigatingTimeout)); + await loader.WaitForCurrentBatchAsync().WaitAsync(TestHelpers.HangMitigatingTimeout); + + var loadedProject = await loader.BeginLoadAsync(projectPath); + + Assert.Same(firstLoadedProject, loadedProject); + Assert.False(await loadedProject.WaitForLoadAsync(CancellationToken.None)); + Assert.Equal(1, loader.DesignTimeBuildCount); + + loadedProject.GetTestAccessor().RaiseNeedsReload(); + await successfulReload.Started.Task.WaitAsync(TestHelpers.HangMitigatingTimeout); + successfulReload.CompleteSuccessfully(loader.WorkspaceFactory.HostProjectFactory, projectPath); + await loader.WaitForCurrentBatchAsync().WaitAsync(TestHelpers.HangMitigatingTimeout); + + Assert.True(await loadedProject.WaitForLoadAsync(CancellationToken.None)); + Assert.Equal(2, loader.DesignTimeBuildCount); + } + + [Fact] + public async Task FailureCompletesOnlyAffectedProject() + { + await using var server = await CreateLanguageServerAsync(serverConfiguration: ServerConfigurationWithoutDevKit); + var loader = server.GetRequiredLspService(); + var firstDesignTimeBuild = loader.QueueDesignTimeBuild(); + var secondDesignTimeBuild = loader.QueueDesignTimeBuild(); + var failedPath = Path.Combine(TempRoot.Root, "Failed.csproj"); + var successfulPath = Path.Combine(TempRoot.Root, "Successful.csproj"); + + var failedProject = await loader.BeginLoadAsync(failedPath); + var successfulProject = await loader.BeginLoadAsync(successfulPath); + await Task.WhenAll(firstDesignTimeBuild.Started.Task, secondDesignTimeBuild.Started.Task).WaitAsync(TestHelpers.HangMitigatingTimeout); + + var failedDesignTimeBuild = firstDesignTimeBuild.Started.Task.Result == failedPath ? firstDesignTimeBuild : secondDesignTimeBuild; + var successfulDesignTimeBuild = firstDesignTimeBuild.Started.Task.Result == successfulPath ? firstDesignTimeBuild : secondDesignTimeBuild; + failedDesignTimeBuild.Fail(new InvalidOperationException("Expected test failure")); + successfulDesignTimeBuild.CompleteSuccessfully(loader.WorkspaceFactory.HostProjectFactory, successfulPath); + + Assert.False(await failedProject.WaitForLoadAsync(CancellationToken.None).AsTask().WaitAsync(TestHelpers.HangMitigatingTimeout)); + Assert.True(await successfulProject.WaitForLoadAsync(CancellationToken.None).AsTask().WaitAsync(TestHelpers.HangMitigatingTimeout)); + } + + [Fact] + public async Task ExplicitLoadDoesNotWaitForUnrelatedQueuedWork() + { + await using var server = await CreateLanguageServerAsync(serverConfiguration: ServerConfigurationWithoutDevKit); + var loader = server.GetRequiredLspService(); + var firstDesignTimeBuild = loader.QueueDesignTimeBuild(); + var secondDesignTimeBuild = loader.QueueDesignTimeBuild(); + var requestedPath = Path.Combine(TempRoot.Root, "Requested.csproj"); + var unrelatedPath = Path.Combine(TempRoot.Root, "Unrelated.csproj"); + + var requestedProject = await loader.BeginLoadAsync(requestedPath); + var unrelatedProject = await loader.BeginLoadAsync(unrelatedPath); + await Task.WhenAll(firstDesignTimeBuild.Started.Task, secondDesignTimeBuild.Started.Task).WaitAsync(TestHelpers.HangMitigatingTimeout); + + var requestedDesignTimeBuild = firstDesignTimeBuild.Started.Task.Result == requestedPath ? firstDesignTimeBuild : secondDesignTimeBuild; + var unrelatedDesignTimeBuild = firstDesignTimeBuild.Started.Task.Result == unrelatedPath ? firstDesignTimeBuild : secondDesignTimeBuild; + var explicitLoad = loader.WaitForExplicitLoadsAsync([requestedProject]); + + requestedDesignTimeBuild.CompleteSuccessfully(loader.WorkspaceFactory.HostProjectFactory, requestedPath); + await explicitLoad.WaitAsync(TestHelpers.HangMitigatingTimeout); + Assert.False(unrelatedProject.WaitForLoadAsync(CancellationToken.None).AsTask().IsCompleted); + + unrelatedDesignTimeBuild.CompleteSuccessfully(loader.WorkspaceFactory.HostProjectFactory, unrelatedPath); + await unrelatedProject.WaitForLoadAsync(CancellationToken.None).AsTask().WaitAsync(TestHelpers.HangMitigatingTimeout); + } + + [Fact] + public async Task ExplicitLoadWaitsForAllRequestedProjectsDespiteFailure() + { + await using var server = await CreateLanguageServerAsync(serverConfiguration: ServerConfigurationWithoutDevKit); + var loader = server.GetRequiredLspService(); + var firstDesignTimeBuild = loader.QueueDesignTimeBuild(); + var secondDesignTimeBuild = loader.QueueDesignTimeBuild(); + var firstPath = Path.Combine(TempRoot.Root, "First.csproj"); + var secondPath = Path.Combine(TempRoot.Root, "Second.csproj"); + + var firstProject = await loader.BeginLoadAsync(firstPath); + var secondProject = await loader.BeginLoadAsync(secondPath); + await Task.WhenAll(firstDesignTimeBuild.Started.Task, secondDesignTimeBuild.Started.Task).WaitAsync(TestHelpers.HangMitigatingTimeout); + + var failedDesignTimeBuild = firstDesignTimeBuild.Started.Task.Result == firstPath ? firstDesignTimeBuild : secondDesignTimeBuild; + var successfulDesignTimeBuild = firstDesignTimeBuild.Started.Task.Result == secondPath ? firstDesignTimeBuild : secondDesignTimeBuild; + var explicitLoad = loader.WaitForExplicitLoadsAsync([firstProject, secondProject]); + + failedDesignTimeBuild.Fail(new InvalidOperationException("Expected test failure")); + await firstProject.WaitForLoadAsync(CancellationToken.None).AsTask().WaitAsync(TestHelpers.HangMitigatingTimeout); + Assert.False(explicitLoad.IsCompleted); + + successfulDesignTimeBuild.CompleteSuccessfully(loader.WorkspaceFactory.HostProjectFactory, secondPath); + await explicitLoad.WaitAsync(TestHelpers.HangMitigatingTimeout); + } + + [Fact] + public async Task JoinedExplicitLoadsReportProgressIndependently() + { + await using var server = await CreateLanguageServerAsync(serverConfiguration: ServerConfigurationWithoutDevKit); + var loader = server.GetRequiredLspService(); + var designTimeBuild = loader.QueueDesignTimeBuild(); + var projectPath = Path.Combine(TempRoot.Root, "Project.csproj"); + var firstReporter = new TestProgressReporter(); + var secondReporter = new TestProgressReporter(); + + var firstLoadedProject = await loader.BeginLoadAsync(projectPath); + var secondLoadedProject = await loader.BeginLoadAsync(projectPath); + Assert.Same(firstLoadedProject, secondLoadedProject); + + await using (var firstProgress = new LanguageServerProjectLoader.WorkDoneProgressTracker(firstReporter, totalItems: 1)) + await using (var secondProgress = new LanguageServerProjectLoader.WorkDoneProgressTracker(secondReporter, totalItems: 1)) + { + var firstLoad = loader.WaitForExplicitLoadsAsync([firstLoadedProject], firstProgress); + var secondLoad = loader.WaitForExplicitLoadsAsync([secondLoadedProject], secondProgress); + + await designTimeBuild.Started.Task.WaitAsync(TestHelpers.HangMitigatingTimeout); + designTimeBuild.CompleteSuccessfully(loader.WorkspaceFactory.HostProjectFactory, projectPath); + await Task.WhenAll(firstLoad, secondLoad).WaitAsync(TestHelpers.HangMitigatingTimeout); + } + + // Disposing the trackers ensures their asynchronous progress queues have finished reporting. + Assert.Contains(firstReporter.Reports, report => report is LSP.WorkDoneProgressReport { Percentage: 99 }); + Assert.Contains(secondReporter.Reports, report => report is LSP.WorkDoneProgressReport { Percentage: 99 }); + Assert.Equal(1, loader.DesignTimeBuildCount); + } + + [Fact] + public async Task UnsupportedProjectReturnsCanonicalCompletedStatus() + { + await using var server = await CreateLanguageServerAsync(serverConfiguration: ServerConfigurationWithoutDevKit); + var loader = server.GetRequiredLspService(); + var designTimeBuild = loader.QueueDesignTimeBuild(); + var projectPath = Path.Combine(TempRoot.Root, "Unsupported.csproj"); + + var loadedProject = await loader.BeginLoadAsync(projectPath); + await designTimeBuild.Started.Task.WaitAsync(TestHelpers.HangMitigatingTimeout); + designTimeBuild.CompleteAsUnsupported(); + var loadedSuccessfully = await loadedProject.WaitForLoadAsync(CancellationToken.None).AsTask().WaitAsync(TestHelpers.HangMitigatingTimeout); + + var laterLoadedProject = await loader.BeginLoadAsync(projectPath); + Assert.False(loadedSuccessfully); + Assert.Same(loadedProject, laterLoadedProject); + Assert.False(await laterLoadedProject.WaitForLoadAsync(CancellationToken.None)); + Assert.Equal(1, loader.DesignTimeBuildCount); + } + + [Fact] + public async Task UnloadCompletesProjectAndStaleDesignTimeBuildDoesNotCommit() + { + await using var server = await CreateLanguageServerAsync(serverConfiguration: ServerConfigurationWithoutDevKit); + var loader = server.GetRequiredLspService(); + var designTimeBuild = loader.QueueDesignTimeBuild(); + var projectPath = Path.Combine(TempRoot.Root, "Project.csproj"); + + var loadedProject = await loader.BeginLoadAsync(projectPath); + await designTimeBuild.Started.Task.WaitAsync(TestHelpers.HangMitigatingTimeout); + Assert.True(await loader.UnloadAsync(projectPath)); + Assert.False(await loadedProject.WaitForLoadAsync(CancellationToken.None).AsTask().WaitAsync(TestHelpers.HangMitigatingTimeout)); + + designTimeBuild.CompleteSuccessfully(loader.WorkspaceFactory.HostProjectFactory, projectPath); + await loader.WaitForCurrentBatchAsync(); + Assert.Empty(loader.WorkspaceFactory.HostWorkspace.CurrentSolution.Projects); + } + + [Fact] + public async Task UnloadAndRequeueBeforeBatchDrainsPreservesNewProject() + { + await using var server = await CreateLanguageServerAsync(serverConfiguration: ServerConfigurationWithoutDevKit); + var loader = server.GetRequiredLspService(); + var currentDesignTimeBuild = loader.QueueDesignTimeBuild(); + var projectPath = Path.Combine(TempRoot.Root, "Project.csproj"); + + var staleLoadedProject = await loader.BeginLoadAsync(projectPath); + Assert.True(await loader.UnloadAsync(projectPath)); + var currentLoadedProject = await loader.BeginLoadAsync(projectPath); + + Assert.NotSame(staleLoadedProject, currentLoadedProject); + Assert.False(await staleLoadedProject.WaitForLoadAsync(CancellationToken.None)); + await currentDesignTimeBuild.Started.Task.WaitAsync(TestHelpers.HangMitigatingTimeout); + + currentDesignTimeBuild.CompleteSuccessfully(loader.WorkspaceFactory.HostProjectFactory, projectPath); + var loadedSuccessfully = await currentLoadedProject.WaitForLoadAsync(CancellationToken.None).AsTask().WaitAsync(TestHelpers.HangMitigatingTimeout); + + Assert.True(loadedSuccessfully); + Assert.Equal(1, loader.DesignTimeBuildCount); + Assert.Single(loader.WorkspaceFactory.HostWorkspace.CurrentSolution.Projects); + } + + [Fact] + public async Task WaitForAllProjectLoadsAsyncUsesCanonicalSnapshot() + { + await using var server = await CreateLanguageServerAsync(serverConfiguration: ServerConfigurationWithoutDevKit); + var loader = server.GetRequiredLspService(); + var firstDesignTimeBuild = loader.QueueDesignTimeBuild(); + var secondDesignTimeBuild = loader.QueueDesignTimeBuild(); + var firstProjectPath = Path.Combine(TempRoot.Root, "First.csproj"); + var secondProjectPath = Path.Combine(TempRoot.Root, "Second.csproj"); + + var firstLoadedProject = await loader.BeginLoadAsync(firstProjectPath); + await firstDesignTimeBuild.Started.Task.WaitAsync(TestHelpers.HangMitigatingTimeout); + var allLoads = loader.WaitForAllTrackedProjectLoadsAsync(); + var secondLoadedProject = await loader.BeginLoadAsync(secondProjectPath); + + Assert.False(allLoads.IsCompleted); + firstDesignTimeBuild.CompleteSuccessfully(loader.WorkspaceFactory.HostProjectFactory, firstProjectPath); + await allLoads.WaitAsync(TestHelpers.HangMitigatingTimeout); + Assert.False(secondLoadedProject.WaitForLoadAsync(CancellationToken.None).AsTask().IsCompleted); + + await secondDesignTimeBuild.Started.Task.WaitAsync(TestHelpers.HangMitigatingTimeout); + secondDesignTimeBuild.CompleteSuccessfully(loader.WorkspaceFactory.HostProjectFactory, secondProjectPath); + await secondLoadedProject.WaitForLoadAsync(CancellationToken.None).AsTask().WaitAsync(TestHelpers.HangMitigatingTimeout); + Assert.True(await firstLoadedProject.WaitForLoadAsync(CancellationToken.None)); + } + + [Fact] + public async Task ShutdownCompletesOutstandingLoadAsUnloaded() + { + var server = await CreateLanguageServerAsync(serverConfiguration: ServerConfigurationWithoutDevKit); + var loader = server.GetRequiredLspService(); + var designTimeBuild = loader.QueueDesignTimeBuild(); + var loadedProject = await loader.BeginLoadAsync(Path.Combine(TempRoot.Root, "Project.csproj")); + await designTimeBuild.Started.Task.WaitAsync(TestHelpers.HangMitigatingTimeout); + + await server.DisposeAsync(); + + Assert.False(await loadedProject.WaitForLoadAsync(CancellationToken.None)); + } + + [Fact] + public async Task ProjectPathIdentityUsesPlatformSemantics() + { + await using var server = await CreateLanguageServerAsync(serverConfiguration: ServerConfigurationWithoutDevKit); + var loader = server.GetRequiredLspService(); + _ = loader.QueueDesignTimeBuild(); + _ = loader.QueueDesignTimeBuild(); + + var lowerCaseHandle = await loader.BeginLoadAsync(Path.Combine(TempRoot.Root, "project.csproj")); + var upperCaseHandle = await loader.BeginLoadAsync(Path.Combine(TempRoot.Root, "PROJECT.csproj")); + + if (PathUtilities.IsUnixLikePlatform) + Assert.NotSame(lowerCaseHandle, upperCaseHandle); + else + Assert.Same(lowerCaseHandle, upperCaseHandle); + } + + [Fact] + public async Task MalformedAbsoluteProjectPathDoesNotThrowDuringNormalization() + { + await using var server = await CreateLanguageServerAsync(serverConfiguration: ServerConfigurationWithoutDevKit); + var loader = server.GetRequiredLspService(); + var projectPath = Path.GetPathRoot(TempRoot.Root) + "\0Invalid.csproj"; + + Assert.False(await loader.UnloadAsync(projectPath)); + } + + [Fact] + public async Task PrimordialProjectWithoutDesignTimeBuildIsNotUpgraded() + { + await using var server = await CreateLanguageServerAsync(serverConfiguration: ServerConfigurationWithoutDevKit); + var loader = server.GetRequiredLspService(); + var projectPath = Path.Combine(TempRoot.Root, "Project.csproj"); + + var primordialProject = await loader.CreatePrimordialProjectAsync(projectPath, doDesignTimeBuild: false); + var sameProject = await loader.CreatePrimordialProjectAsync(projectPath, doDesignTimeBuild: true); + await loader.WaitForCurrentBatchAsync().WaitAsync(TestHelpers.HangMitigatingTimeout); + + Assert.Equal(primordialProject.Id, sameProject.Id); + Assert.Equal(0, loader.DesignTimeBuildCount); + } + + [Fact] + public async Task PrimordialProjectStartsOneDesignTimeBuild() + { + await using var server = await CreateLanguageServerAsync(serverConfiguration: ServerConfigurationWithoutDevKit); + var loader = server.GetRequiredLspService(); + var designTimeBuild = loader.QueueDesignTimeBuild(); + var projectPath = Path.Combine(TempRoot.Root, "Project.csproj"); + + var primordialProject = await loader.CreatePrimordialProjectAsync(projectPath, doDesignTimeBuild: true); + _ = await loader.CreatePrimordialProjectAsync(projectPath, doDesignTimeBuild: true); + + await designTimeBuild.Started.Task.WaitAsync(TestHelpers.HangMitigatingTimeout); + designTimeBuild.CompleteSuccessfully(loader.WorkspaceFactory.HostProjectFactory, projectPath); + await loader.WaitForCurrentBatchAsync().WaitAsync(TestHelpers.HangMitigatingTimeout); + + Assert.Null(loader.WorkspaceFactory.MiscellaneousFilesWorkspaceProjectFactory.Workspace.CurrentSolution.GetProject(primordialProject.Id)); + Assert.NotEmpty(loader.WorkspaceFactory.HostWorkspace.CurrentSolution.Projects); + Assert.Equal(1, loader.DesignTimeBuildCount); + } + + [Fact] + public async Task PrimordialProjectUsesNormalizedPath() + { + await using var server = await CreateLanguageServerAsync(serverConfiguration: ServerConfigurationWithoutDevKit); + var loader = server.GetRequiredLspService(); + var projectPath = Path.Combine(TempRoot.Root, "Project.csproj"); + var nonCanonicalProjectPath = Path.Combine(TempRoot.Root, "directory", "..", "Project.csproj"); + + var project = await loader.CreatePrimordialProjectAsync(nonCanonicalProjectPath, doDesignTimeBuild: false); + var projectFromCanonicalPath = await loader.CreatePrimordialProjectAsync(projectPath, doDesignTimeBuild: false); + + Assert.Equal(projectPath, project.FilePath); + Assert.Equal(project.Id, projectFromCanonicalPath.Id); + } + + [ExportCSharpVisualBasicLspServiceFactory(typeof(TestProjectLoader)), PartNotDiscoverable, Shared] + [method: ImportingConstructor] + [method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] + internal sealed class TestProjectLoaderFactory( + IGlobalOptionService globalOptionService, + IAsynchronousOperationListenerProvider listenerProvider, + ServerConfigurationFactory serverConfigurationFactory) : ILspServiceFactory + { + public ILspService CreateILspService(LspServices lspServices, WellKnownLspServerKinds serverKind) + => new TestProjectLoader( + lspServices, + globalOptionService, + lspServices.GetRequiredService(), + listenerProvider, + serverConfigurationFactory, + lspServices.GetRequiredService(), + lspServices.GetRequiredService()); + } + + /// + /// A project loader whose design-time builds are supplied by the test so load ordering and results are deterministic. + /// + internal sealed class TestProjectLoader : LanguageServerProjectLoader, ILspService + { + private readonly ConcurrentQueue _expectedDesignTimeBuilds = new(); + private int _designTimeBuildCount; + + public LanguageServerWorkspaceFactory WorkspaceFactory => _workspaceFactory; + public int DesignTimeBuildCount => Volatile.Read(ref _designTimeBuildCount); + + public TestProjectLoader( + ILspServices lspServices, + IGlobalOptionService globalOptionService, + ILoggerFactory loggerFactory, + IAsynchronousOperationListenerProvider listenerProvider, + ServerConfigurationFactory serverConfigurationFactory, + IBinLogPathProvider binLogPathProvider, + DotnetCliHelper dotnetCliHelper) + : base(lspServices, globalOptionService, loggerFactory, listenerProvider, serverConfigurationFactory, binLogPathProvider, dotnetCliHelper) + { + } + + public ExpectedDesignTimeBuild QueueDesignTimeBuild() + { + var designTimeBuild = new ExpectedDesignTimeBuild(); + _expectedDesignTimeBuilds.Enqueue(designTimeBuild); + return designTimeBuild; + } + + public Task BeginLoadAsync(string projectPath) + => BeginLoadingProjectAsync(projectPath); + + public Task WaitForCurrentBatchAsync() + => WaitForProjectsToFinishLoadingAsync(); + + public Task WaitForAllTrackedProjectLoadsAsync(CancellationToken cancellationToken = default) + => WaitForAllProjectLoadsAsync(cancellationToken); + + public Task WaitForExplicitLoadsAsync(ImmutableArray loadedProjects, WorkDoneProgressTracker? progressTracker = null) + => WaitForProjectLoadsAsync(loadedProjects, progressTracker); + + public ValueTask UnloadAsync(string projectPath) + => TryUnloadProjectAsync(projectPath); + + public async ValueTask CreatePrimordialProjectAsync(string projectPath, bool doDesignTimeBuild) + { + var projectFactory = WorkspaceFactory.MiscellaneousFilesWorkspaceProjectFactory; + return (await GetOrLoadProjectAsync( + projectPath, + projectFactory, + (_, normalizedProjectPath) => ProjectInfo.Create( + ProjectId.CreateNewId(), + VersionStamp.Default, + name: "Primordial", + assemblyName: "Primordial", + LanguageNames.CSharp, + filePath: normalizedProjectPath), + doDesignTimeBuild)).Single(); + } + + protected override async Task TryLoadProjectInMSBuildHostAsync( + BuildHostProcessManager buildHostProcessManager, string projectPath, CancellationToken cancellationToken) + { + Interlocked.Increment(ref _designTimeBuildCount); + Assert.True(_expectedDesignTimeBuilds.TryDequeue(out var designTimeBuild)); + designTimeBuild.Started.TrySetResult(projectPath); + return await designTimeBuild.Result.Task.WaitAsync(cancellationToken); + } + } + + /// Controls the result of one expected design-time build. + internal sealed class ExpectedDesignTimeBuild + { + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Result { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public void CompleteSuccessfully(ProjectSystemProjectFactory projectFactory, string projectPath, string? targetFramework = null) + => Result.SetResult(new() + { + ProjectFileInfos = [ProjectFileInfo.CreateEmpty(LanguageNames.CSharp, projectPath) with { CommandLineArgs = ["/target:library"], TargetFramework = targetFramework }], + DiagnosticLogItems = [], + ProjectRestorePath = projectPath, + ProjectFactory = projectFactory, + IsFileBasedProgram = false, + IsMiscellaneousFile = false, + HasFileBasedAppDirectives = false, + HasAllInformation = true, + PreferredBuildHostKind = BuildHostProcessKind.NetCore, + ActualBuildHostKind = BuildHostProcessKind.NetCore, + }); + + public void CompleteAsUnsupported() + => Result.SetResult(null); + + public void Fail(Exception exception) + => Result.SetException(exception); + } + + private sealed class TestProgressReporter : IProgress + { + public ConcurrentQueue Reports { get; } = new(); + + public void Report(LSP.WorkDoneProgress value) + => Reports.Enqueue(value); + } +} diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/Microsoft.CodeAnalysis.LanguageServer.UnitTests.csproj b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/Microsoft.CodeAnalysis.LanguageServer.UnitTests.csproj index 02ba69587331..545df7090fec 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/Microsoft.CodeAnalysis.LanguageServer.UnitTests.csproj +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/Microsoft.CodeAnalysis.LanguageServer.UnitTests.csproj @@ -15,6 +15,7 @@ + @@ -27,12 +28,6 @@ - - - - - diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/MiscellaneousFiles/FileBasedProgramsWorkspaceTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/MiscellaneousFiles/FileBasedProgramsWorkspaceTests.cs index b92e73011678..c62acef56f3e 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/MiscellaneousFiles/FileBasedProgramsWorkspaceTests.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/MiscellaneousFiles/FileBasedProgramsWorkspaceTests.cs @@ -5,6 +5,7 @@ using System.Collections.Immutable; using System.Text; using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Features.Workspaces; using Microsoft.CodeAnalysis.LanguageServer.FileBasedPrograms; using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace; using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.FileWatching; @@ -26,6 +27,44 @@ namespace Microsoft.CodeAnalysis.LanguageServer.UnitTests.FileBasedPrograms; public sealed class FileBasedProgramsWorkspaceTests(ITestOutputHelper testOutputHelper) : AbstractLspMiscellaneousFilesWorkspaceTests(testOutputHelper) { + [Fact] + public async Task GetOrLoadEntryPointDocument_NormalizesPath() + { + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace: false, new InitializationOptions { ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer }); + var projectSystem = (FileBasedProgramsProjectSystem)testLspServer.GetRequiredLspService(); + var sourceFile = CreateTempDirectoryWithGlobalJson().CreateFile("SomeFile.cs"); + var nonCanonicalPath = Path.Combine(Path.GetDirectoryName(sourceFile.Path)!, "directory", "..", Path.GetFileName(sourceFile.Path)); + var sourceText = SourceText.From("Console.WriteLine(\"Hello World!\");"); + var languageInformation = new LanguageInformation(LanguageNames.CSharp, "csx"); + + var documents = await projectSystem.GetOrLoadEntryPointDocumentAsync( + nonCanonicalPath, new SourceTextLoader(sourceText, nonCanonicalPath), languageInformation, SourceHashAlgorithms.Default, doDesignTimeBuild: false); + var document = Assert.Single(documents); + var documentsFromCanonicalPath = await projectSystem.GetOrLoadEntryPointDocumentAsync( + sourceFile.Path, new SourceTextLoader(sourceText, sourceFile.Path), languageInformation, SourceHashAlgorithms.Default, doDesignTimeBuild: false); + + Assert.Equal(sourceFile.Path, document.FilePath); + Assert.Equal(document.Id, Assert.Single(documentsFromCanonicalPath).Id); + } + + [ConditionalFact(typeof(WindowsOnly))] + public async Task GetOrLoadEntryPointDocument_MatchesPathCaseInsensitively() + { + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace: false, new InitializationOptions { ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer }); + var projectSystem = (FileBasedProgramsProjectSystem)testLspServer.GetRequiredLspService(); + var sourceFile = CreateTempDirectoryWithGlobalJson().CreateFile("SomeFile.cs"); + var sourceText = SourceText.From("Console.WriteLine(\"Hello World!\");"); + var languageInformation = new LanguageInformation(LanguageNames.CSharp, "csx"); + + var document = Assert.Single(await projectSystem.GetOrLoadEntryPointDocumentAsync( + sourceFile.Path, new SourceTextLoader(sourceText, sourceFile.Path), languageInformation, SourceHashAlgorithms.Default, doDesignTimeBuild: false)); + var pathWithDifferentCasing = sourceFile.Path.ToUpperInvariant(); + var documentsFromDifferentCasing = await projectSystem.GetOrLoadEntryPointDocumentAsync( + pathWithDifferentCasing, new SourceTextLoader(sourceText, pathWithDifferentCasing), languageInformation, SourceHashAlgorithms.Default, doDesignTimeBuild: false); + + Assert.Equal(document.Id, Assert.Single(documentsFromDifferentCasing).Id); + } + [Theory, CombinatorialData] public async Task TestFileBasedProgram_Simple(bool mutatingLspWorkspace) { diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/Utilities/LanguageServerTestComposition.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/Utilities/LanguageServerTestComposition.cs index c54c5cbdf74b..5e6065a84df5 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/Utilities/LanguageServerTestComposition.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/Utilities/LanguageServerTestComposition.cs @@ -22,12 +22,14 @@ public static Task CreateLanguageServerExportProviderAsync( return LanguageServerExportProviderBuilder.CreateExportProviderAsync(TestPaths.GetLanguageServerDirectory(), extensionManager, assemblyLoader, serverConfiguration, cacheDirectory, loggerFactory, CancellationToken.None); } - public static ExportProvider GetSharedExportProvider(ServerConfiguration serverConfiguration, ILoggerFactory loggerFactory) + public static ExportProvider GetSharedExportProvider(ServerConfiguration serverConfiguration, ILoggerFactory loggerFactory, params Type[] additionalParts) { Contract.ThrowIfTrue(serverConfiguration.ExtensionAssemblyPaths.Any(), "Tests that require extension assemblies should use AbstractLanguageServerMefHost instead"); - var exportProvider = serverConfiguration.DevKitDependencyPath != null - ? s_devKit.ExportProviderFactory.CreateExportProvider() - : s_languageServer.ExportProviderFactory.CreateExportProvider(); + var composition = serverConfiguration.DevKitDependencyPath != null ? s_devKit : s_languageServer; + if (additionalParts.Length > 0) + composition = composition.AddParts(additionalParts); + + var exportProvider = composition.ExportProviderFactory.CreateExportProvider(); LanguageServerExportProviderBuilder.TestAccessor.InitializeManualExports(exportProvider, new ExtensionAssemblyManager([], [], []), loggerFactory, serverConfiguration); return exportProvider; diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsEntryPointDiscovery.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsEntryPointDiscovery.cs index 0d2e737ff48f..72b7f62fba72 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsEntryPointDiscovery.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsEntryPointDiscovery.cs @@ -77,7 +77,7 @@ public Task OnInitializedAsync(ClientCapabilities clientCapabilities, RequestCon return Task.CompletedTask; } - private void OnWorkspaceFoldersChanged() + private void OnWorkspaceFoldersChanged(object? sender, EventArgs e) => _discoveryQueue.AddWork(); public void Dispose() diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs index dd5417c55147..a6ce15ba35b4 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs @@ -283,13 +283,14 @@ public async ValueTask TryBeginLoadingFileBasedAppAsync(string documentFilePath) public async ValueTask> GetOrLoadEntryPointDocumentAsync(string documentFilePath, TextLoader textLoader, LanguageInformation languageInformation, SourceHashAlgorithm checksumAlgorithm, bool doDesignTimeBuild) { + documentFilePath = NormalizeProjectPath(documentFilePath); var projects = await base.GetOrLoadProjectAsync(documentFilePath, _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory, CreatePrimordialProjectInfo, doDesignTimeBuild); return projects.Select(p => LookupExistingDocument(p)).WhereNotNull().ToImmutableArray(); TextDocument? LookupExistingDocument(Project project) { - var document = project.Documents.FirstOrDefault(document => document.FilePath == documentFilePath) - ?? project.AdditionalDocuments.FirstOrDefault(document => document.FilePath == documentFilePath); + var document = project.Documents.FirstOrDefault(document => PathUtilities.Comparer.Equals(document.FilePath, documentFilePath)) + ?? project.AdditionalDocuments.FirstOrDefault(document => PathUtilities.Comparer.Equals(document.FilePath, documentFilePath)); if (document is null) { _logger.LogWarning("Could not get a document for '{documentFilePath}' because its project doesn't contain a document for it", documentFilePath); @@ -298,11 +299,11 @@ public async ValueTask> GetOrLoadEntryPointDocument return document; } - ProjectInfo CreatePrimordialProjectInfo(ProjectSystemProjectFactory projectFactory) + ProjectInfo CreatePrimordialProjectInfo(ProjectSystemProjectFactory projectFactory, string normalizedDocumentFilePath) { var enableFileBasedPrograms = GlobalOptionService.GetOption(LanguageServerProjectSystemOptionsStorage.EnableFileBasedPrograms); return MiscellaneousFileUtilities.CreateMiscellaneousProjectInfoForDocument( - projectFactory.Workspace, documentFilePath, textLoader, languageInformation, checksumAlgorithm, projectFactory.Workspace.Services.SolutionServices, [], enableFileBasedPrograms); + projectFactory.Workspace, normalizedDocumentFilePath, textLoader, languageInformation, checksumAlgorithm, projectFactory.Workspace.Services.SolutionServices, [], enableFileBasedPrograms); } } diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.ProjectToLoad.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.ProjectToLoad.cs deleted file mode 100644 index 43ea6af1ec20..000000000000 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.ProjectToLoad.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Diagnostics.CodeAnalysis; - -namespace Microsoft.CodeAnalysis.LanguageServer.HostWorkspace; - -internal abstract partial class LanguageServerProjectLoader -{ - /// - /// The project path of the project to load. - /// - private sealed record ProjectToLoad(string Path, WorkDoneProgressTracker? ProgressTracker = null) - { - public static IEqualityComparer Comparer = new ProjectToLoadComparer(); - - private sealed class ProjectToLoadComparer : IEqualityComparer - { - public bool Equals(ProjectToLoad? x, ProjectToLoad? y) - { - return StringComparer.Ordinal.Equals(x?.Path, y?.Path); - } - - public int GetHashCode([DisallowNull] ProjectToLoad obj) - { - return StringComparer.Ordinal.GetHashCode(obj.Path); - } - } - } -} diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs index aad7a7c31f8f..5062d579bb3d 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs @@ -12,6 +12,7 @@ using Microsoft.CodeAnalysis.ProjectSystem; using Microsoft.CodeAnalysis.Shared.Extensions; using Microsoft.CodeAnalysis.Shared.TestHooks; +using Microsoft.CodeAnalysis.Shared.Utilities; using Microsoft.CodeAnalysis.Threading; using Microsoft.CodeAnalysis.Workspaces.ProjectSystem; using Microsoft.CommonLanguageServerProtocol.Framework; @@ -25,7 +26,7 @@ internal abstract partial class LanguageServerProjectLoader : IAsyncDisposable { private static readonly string s_razorDesignTimePath = Path.Combine(AppContext.BaseDirectory, "Targets", "Microsoft.NET.Sdk.Razor.DesignTime.targets"); - private readonly AsyncBatchingWorkQueue _projectsToReload; + private readonly AsyncBatchingWorkQueue _projectsToReload; private bool _isDisposed; protected readonly LanguageServerWorkspaceFactory _workspaceFactory; @@ -55,7 +56,7 @@ internal abstract partial class LanguageServerProjectLoader : IAsyncDisposable /// instance is expected to be a no-op, since it's possible we might have had some scheduled asynchronous work /// (a design time build, a file change notification) that might have scheduled and could also be in flight. /// - private readonly Dictionary _loadedProjects = []; + private readonly Dictionary _loadedProjects = new(PathUtilities.Comparer); /// /// Indicates whether loads should report UI progress to the client for this loader. @@ -103,10 +104,10 @@ protected LanguageServerProjectLoader( AdditionalProperties = BuildAdditionalProperties(serverConfigurationFactory.ServerConfiguration); - _projectsToReload = new AsyncBatchingWorkQueue( + _projectsToReload = new AsyncBatchingWorkQueue( TimeSpan.FromMilliseconds(100), ReloadProjectsAsync, - ProjectToLoad.Comparer, + PathUtilities.Comparer, Listener); } @@ -144,7 +145,7 @@ public async Task ReportErrorAsync(LSP.MessageType errorKind, string message, Ca } } - private async ValueTask ReloadProjectsAsync(ImmutableSegmentedList projectsToLoadOrReload, CancellationToken cancellationToken) + private async ValueTask ReloadProjectsAsync(ImmutableSegmentedList projectsToLoadOrReload, CancellationToken cancellationToken) { // TODO: support configuration switching var stopwatch = Stopwatch.StartNew(); @@ -166,21 +167,14 @@ private async ValueTask ReloadProjectsAsync(ImmutableSegmentedList.RunParallelAsync( source: projectsToLoadOrReload, - produceItems: static async (projectToLoad, produceItem, args, cancellationToken) => + produceItems: static async (projectPath, produceItem, args, cancellationToken) => { var (@this, toastErrorReporter, buildHostProcessManager) = args; - try - { - var projectRestorePath = await @this.ReloadProjectAsync( - projectToLoad, toastErrorReporter, buildHostProcessManager, cancellationToken); - - if (projectRestorePath is not null) - produceItem(projectRestorePath); - } - finally - { - projectToLoad.ProgressTracker?.OnItemProcessed(); - } + var projectRestorePath = await @this.ReloadProjectAsync( + projectPath, toastErrorReporter, buildHostProcessManager, cancellationToken); + + if (projectRestorePath is not null) + produceItem(projectRestorePath); }, args: (@this: this, toastErrorReporter, buildHostProcessManager), cancellationToken).ConfigureAwait(false); @@ -223,10 +217,9 @@ internal sealed record RemoteProjectLoadResult => null; /// The project file path that needs a NuGet restore, if any. - private async Task ReloadProjectAsync(ProjectToLoad projectToLoad, ToastErrorReporter toastErrorReporter, BuildHostProcessManager buildHostProcessManager, CancellationToken cancellationToken) + private async Task ReloadProjectAsync(string projectPath, ToastErrorReporter toastErrorReporter, BuildHostProcessManager buildHostProcessManager, CancellationToken cancellationToken) { BuildHostProcessKind? preferredBuildHostKindThatWeDidNotGet = null; - var projectPath = projectToLoad.Path; LoadedProject? loadedProject; // Before doing any work, check if the project has already been unloaded @@ -246,7 +239,7 @@ internal sealed record RemoteProjectLoadResult // - Loading VB projects // - Reloading file-based app projects, where edits were performed to e.g. delete all `#:` directives, // making the file no longer a file-based app entry point. - _logger.LogDebug("Reload of '{projectPath}' was canceled.", projectPath); + _logger.LogDebug("Reload of '{ProjectPath}' was canceled.", projectPath); return null; } @@ -269,9 +262,7 @@ internal sealed record RemoteProjectLoadResult // language in-process. var projectLanguage = loadedProjectInfos.FirstOrDefault()?.Language; if (projectLanguage != null && projectFactory.Workspace.Services.GetLanguageService(projectLanguage) == null) - { return null; - } var applied = await loadedProject.TryApplyLoadedProjectInfosAsync( loadedProjectInfos, @@ -315,6 +306,10 @@ await loadedProject.ReportTelemetryIfNotPreviouslyReportedAsync( return null; } + finally + { + loadedProject.CompleteInitialLoad(); + } async Task LogDiagnosticsAsync(ImmutableArray diagnosticLogItems) { @@ -339,8 +334,9 @@ async Task LogDiagnosticsAsync(ImmutableArray diagnosticLogIt } } - protected async ValueTask> GetOrLoadProjectAsync(string projectPath, ProjectSystemProjectFactory primordialProjectFactory, Func createPrimordialProjectInfo, bool doDesignTimeBuild) + protected async ValueTask> GetOrLoadProjectAsync(string projectPath, ProjectSystemProjectFactory primordialProjectFactory, Func createPrimordialProjectInfo, bool doDesignTimeBuild) { + projectPath = NormalizeProjectPath(projectPath); using (await _gate.DisposableWaitAsync(CancellationToken.None)) { Contract.ThrowIfTrue(_isDisposed, "Project loader is already disposed"); @@ -348,7 +344,7 @@ protected async ValueTask> GetOrLoadProjectAsync(string if (_loadedProjects.TryGetValue(projectPath, out var existingLoadedProject)) return await existingLoadedProject.GetExistingProjectsAsync(); - var primordialProjectInfo = createPrimordialProjectInfo(primordialProjectFactory); + var primordialProjectInfo = createPrimordialProjectInfo(primordialProjectFactory, projectPath); var newLoadedProject = new LoadedProject(projectPath, _fileChangeWatcher); _loadedProjects.Add(projectPath, newLoadedProject); @@ -356,8 +352,12 @@ protected async ValueTask> GetOrLoadProjectAsync(string if (doDesignTimeBuild) { - _projectsToReload.AddWork(new ProjectToLoad(projectPath)); newLoadedProject.NeedsReload += LoadedProject_NeedsReload; + _projectsToReload.AddWork(newLoadedProject.ProjectFilePath); + } + else + { + newLoadedProject.CompleteInitialLoad(); } return [newProject]; @@ -367,8 +367,9 @@ protected async ValueTask> GetOrLoadProjectAsync(string /// /// Begins loading a project. If the project has already begun loading, returns without doing any additional work. /// - protected async Task BeginLoadingProjectAsync(string projectPath, string? projectGuid, WorkDoneProgressTracker? progressTracker = null) + internal async Task BeginLoadingProjectAsync(string projectPath) { + projectPath = NormalizeProjectPath(projectPath); LoadedProject? loadedProject; using (await _gate.DisposableWaitAsync(CancellationToken.None)) @@ -381,13 +382,9 @@ protected async Task BeginLoadingProjectAsync(string projectPath, string? projec loadedProject = new LoadedProject(projectPath, _fileChangeWatcher); _loadedProjects.Add(projectPath, loadedProject); - _projectsToReload.AddWork(new ProjectToLoad(Path: projectPath, progressTracker)); - loadedProject.NeedsReload += LoadedProject_NeedsReload; + _projectsToReload.AddWork(loadedProject.ProjectFilePath); } - - if (projectGuid is not null) - await loadedProject.SetProjectGuidForTelemetryAsync(Guid.Parse(projectGuid)); } // Try to load the contents from the project cache if we have one; we'll do this outside the lock @@ -414,6 +411,8 @@ await loadedProject.TryApplyLoadedProjectInfosAsync( { _logger.LogWarning(e, "Exception encountered while trying to load cached state for {ProjectPath}", projectPath); } + + return loadedProject; } protected void LoadedProject_NeedsReload(object? sender, string triggeringFilePath) @@ -421,11 +420,40 @@ protected void LoadedProject_NeedsReload(object? sender, string triggeringFilePa var loadedProject = (LoadedProject)sender!; _logger.LogTrace("Project {ProjectPath} needs reload due to change in {TriggeringFilePath}", loadedProject.ProjectFilePath, triggeringFilePath); - _projectsToReload.AddWork(new ProjectToLoad(Path: loadedProject.ProjectFilePath, ProgressTracker: null)); + _projectsToReload.AddWork(loadedProject.ProjectFilePath); } protected Task WaitForProjectsToFinishLoadingAsync() => _projectsToReload.WaitUntilCurrentBatchCompletesAsync(); + protected static async Task WaitForProjectLoadsAsync( + ImmutableArray loadedProjects, + WorkDoneProgressTracker? progressTracker = null, + CancellationToken cancellationToken = default) + { + await Task.WhenAll(loadedProjects.SelectAsArray(async loadedProject => + { + try + { + await loadedProject.WaitForLoadAsync(cancellationToken); + } + finally + { + progressTracker?.OnItemProcessed(); + } + })); + } + + internal async Task WaitForAllProjectLoadsAsync(CancellationToken cancellationToken) + { + ImmutableArray loadedProjects; + using (await _gate.DisposableWaitAsync(cancellationToken)) + { + loadedProjects = [.. _loadedProjects.Values]; + } + + await WaitForProjectLoadsAsync(loadedProjects, cancellationToken: cancellationToken); + } + /// Unloads all projects associated with this project loader. internal async ValueTask UnloadAllProjectsAsync() { @@ -457,6 +485,7 @@ public virtual async ValueTask DisposeAsync() internal async ValueTask TryUnloadProjectAsync(string projectPath, ProjectSystemProjectFactory? fromProjectFactory = null) { + projectPath = NormalizeProjectPath(projectPath); using (await _gate.DisposableWaitAsync(CancellationToken.None)) { if (!_loadedProjects.TryGetValue(projectPath, out var loadedProject)) @@ -476,4 +505,7 @@ internal async ValueTask TryUnloadProjectAsync(string projectPath, Project return true; } } + + protected static string NormalizeProjectPath(string projectPath) + => PathUtilities.IsAbsolute(projectPath) ? IOUtilities.PerformIO(() => Path.GetFullPath(projectPath), projectPath) : projectPath; } diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs index 4ecf2e35dbeb..ecb7eb8b0f3c 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs @@ -6,9 +6,11 @@ using System.Composition; using System.Runtime.InteropServices; using Microsoft.CodeAnalysis.FileBasedPrograms; +using Microsoft.CodeAnalysis.Host; using Microsoft.CodeAnalysis.Host.Mef; using Microsoft.CodeAnalysis.LanguageServer.Handler; using Microsoft.CodeAnalysis.Options; +using Microsoft.CodeAnalysis.Shared.Extensions; using Microsoft.CodeAnalysis.Shared.TestHooks; using Microsoft.CodeAnalysis.Workspaces.ProjectSystem; using Microsoft.CommonLanguageServerProtocol.Framework; @@ -159,12 +161,17 @@ public async Task OpenSolutionAsync(string solutionFilePath, IProgress(projects.Length); foreach (var (path, guid) in projects) { - await BeginLoadingProjectAsync(path, guid, progressTracker); + var loadedProject = await BeginLoadingProjectAsync(path); + if (guid is not null) + await loadedProject.SetProjectGuidForTelemetryAsync(Guid.Parse(guid)); + + loadedProjects.Add(loadedProject); } - await WaitForProjectsToFinishLoadingAsync(); + await WaitForProjectLoadsAsync(loadedProjects.MoveToImmutable(), progressTracker); await ProjectInitializationHandler.SendProjectInitializationCompleteNotificationAsync(_clientLanguageServerManager); } @@ -177,15 +184,24 @@ public async Task OpenProjectsAsync(ImmutableArray projectFilePaths, IPr ? new WorkDoneProgressTracker(progressReporter, projectFilePaths.Length) : null; + var loadedProjects = ImmutableArray.CreateBuilder(projectFilePaths.Length); foreach (var path in projectFilePaths) { - await BeginLoadingProjectAsync(NormalizeDriveLetter(path), projectGuid: null, progressTracker); + var loadedProject = await BeginLoadingProjectAsync(NormalizeDriveLetter(path)); + loadedProjects.Add(loadedProject); } - await WaitForProjectsToFinishLoadingAsync(); + await WaitForProjectLoadsAsync(loadedProjects.MoveToImmutable(), progressTracker, CancellationToken.None); await ProjectInitializationHandler.SendProjectInitializationCompleteNotificationAsync(_clientLanguageServerManager); } + internal ImmutableArray GetSupportedProjectFileExtensions() + { + var supportedLanguages = _hostProjectFactory.Workspace.Services.SolutionServices.GetSupportedLanguages(); + return _projectFileExtensionRegistry.GetRegisteredProjectFileExtensions().WhereAsArray( + extension => _projectFileExtensionRegistry.TryGetLanguageNameFromExtension(extension, out var languageName) && supportedLanguages.Contains(languageName)); + } + protected override async Task TryLoadProjectInMSBuildHostAsync( BuildHostProcessManager buildHostProcessManager, string projectPath, CancellationToken cancellationToken) { diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LoadedProject.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LoadedProject.cs index a0106aa7911f..ba6b5cf3aac8 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LoadedProject.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LoadedProject.cs @@ -49,6 +49,7 @@ internal sealed partial class LoadedProject : IAsyncDisposable private readonly List _targets = []; private (ProjectSystemProjectFactory ProjectFactory, ProjectId Id)? _primordialProjectInfo; + private readonly TaskCompletionSource _initialLoadCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); private bool _reportedTelemetry = false; private Guid? _projectGuidForTelemetry = null; @@ -78,6 +79,26 @@ public LoadedProject(string projectFilePath, IFileChangeWatcher fileWatcher) /// public event EventHandler? NeedsReload; + /// + /// Waits for the initial project load to settle. + /// + /// + /// if the project remains loaded and contains a primordial project or at least one evaluated + /// target; otherwise, . + /// + public async ValueTask WaitForLoadAsync(CancellationToken cancellationToken) + { + await _initialLoadCompletionSource.Task.WaitAsync(cancellationToken); + + using (await _gate.DisposableWaitAsync(cancellationToken)) + { + return !_disposed && (_primordialProjectInfo.HasValue || _targets.Count > 0); + } + } + + public void CompleteInitialLoad() + => _initialLoadCompletionSource.TrySetResult(); + private void ProjectFileChangeContext_FileChanged(object? sender, FileChangedEventArgs e) { NeedsReload?.Invoke(this, e.FilePath); @@ -322,6 +343,8 @@ public async ValueTask DisposeAsync() if (_disposed) return; + _initialLoadCompletionSource.TrySetResult(); + _sourceFileCreatedOrDeletedChangeContext?.Dispose(); _projectFileChangeContext.Dispose(); @@ -340,6 +363,13 @@ public async ValueTask DisposeAsync() } } + internal TestAccessor GetTestAccessor() => new(this); + + internal readonly struct TestAccessor(LoadedProject loadedProject) + { + public void RaiseNeedsReload() => loadedProject.NeedsReload?.Invoke(loadedProject, loadedProject.ProjectFilePath); + } + private sealed class DocumentFileInfoComparer : IEqualityComparer { public static IEqualityComparer Instance = new DocumentFileInfoComparer(); diff --git a/src/LanguageServer/Protocol/Handler/IWorkspaceFolderTracker.cs b/src/LanguageServer/Protocol/Handler/IWorkspaceFolderTracker.cs index 584cfcd67995..718f28ba11e1 100644 --- a/src/LanguageServer/Protocol/Handler/IWorkspaceFolderTracker.cs +++ b/src/LanguageServer/Protocol/Handler/IWorkspaceFolderTracker.cs @@ -10,7 +10,7 @@ namespace Microsoft.CodeAnalysis.LanguageServer.Handler; internal interface IWorkspaceFolderTracker : ILspService { - event Action? WorkspaceFoldersChanged; + event EventHandler? WorkspaceFoldersChanged; ImmutableHashSet GetRequiredWorkspaceFolderPaths(); diff --git a/src/LanguageServer/Protocol/Handler/WorkspaceFolderTracker.cs b/src/LanguageServer/Protocol/Handler/WorkspaceFolderTracker.cs index fa1787e529b4..188f46d33f39 100644 --- a/src/LanguageServer/Protocol/Handler/WorkspaceFolderTracker.cs +++ b/src/LanguageServer/Protocol/Handler/WorkspaceFolderTracker.cs @@ -12,13 +12,11 @@ namespace Microsoft.CodeAnalysis.LanguageServer.Handler; internal sealed class WorkspaceFolderTracker : IWorkspaceFolderTracker { - /// - /// Mutations are serialized by the request queue, but non-mutating requests may read the current folders concurrently. - /// + // The gate makes updates atomic; volatile allows lock-free reads of the latest immutable snapshot. private readonly object _gate = new(); private volatile ImmutableHashSet _workspaceFolderPaths = ImmutableHashSet.Create(PathUtilities.Comparer); - public event Action? WorkspaceFoldersChanged; + public event EventHandler? WorkspaceFoldersChanged; public void Update(WorkspaceFolder[]? addedFolders, WorkspaceFolder[]? removedFolders) { @@ -54,7 +52,7 @@ public void Update(WorkspaceFolder[]? addedFolders, WorkspaceFolder[]? removedFo _workspaceFolderPaths = updatedWorkspaceFolderPaths; } - WorkspaceFoldersChanged?.Invoke(); + WorkspaceFoldersChanged?.Invoke(this, EventArgs.Empty); } public ImmutableHashSet GetRequiredWorkspaceFolderPaths() diff --git a/src/LanguageServer/ProtocolUnitTests/HandlerTests.cs b/src/LanguageServer/ProtocolUnitTests/HandlerTests.cs index 447c059d011e..8a1fb4ec95f2 100644 --- a/src/LanguageServer/ProtocolUnitTests/HandlerTests.cs +++ b/src/LanguageServer/ProtocolUnitTests/HandlerTests.cs @@ -49,7 +49,7 @@ public void WorkspaceFolderTrackerPreservesSetForEquivalentUpdate() var workspaceFolder = new WorkspaceFolder { DocumentUri = new("file:///Workspace"), Name = "Workspace" }; var equivalentWorkspaceFolder = new WorkspaceFolder { DocumentUri = new("file:///Workspace/"), Name = "Workspace" }; var eventCount = 0; - tracker.WorkspaceFoldersChanged += () => eventCount++; + tracker.WorkspaceFoldersChanged += (_, _) => eventCount++; tracker.Update([workspaceFolder], removedFolders: null); var workspaceFolders = tracker.GetRequiredWorkspaceFolderPaths(); diff --git a/src/Workspaces/MSBuild/Contracts/Microsoft.CodeAnalysis.Workspaces.MSBuild.Contracts.csproj b/src/Workspaces/MSBuild/Contracts/Microsoft.CodeAnalysis.Workspaces.MSBuild.Contracts.csproj index fe5837599ffa..203582a1fa0c 100644 --- a/src/Workspaces/MSBuild/Contracts/Microsoft.CodeAnalysis.Workspaces.MSBuild.Contracts.csproj +++ b/src/Workspaces/MSBuild/Contracts/Microsoft.CodeAnalysis.Workspaces.MSBuild.Contracts.csproj @@ -21,6 +21,7 @@ + diff --git a/src/Workspaces/MSBuild/Core/MSBuild/ProjectFileExtensionRegistry.cs b/src/Workspaces/MSBuild/Core/MSBuild/ProjectFileExtensionRegistry.cs index fc7f72c399be..0c98a29e4f2b 100644 --- a/src/Workspaces/MSBuild/Core/MSBuild/ProjectFileExtensionRegistry.cs +++ b/src/Workspaces/MSBuild/Core/MSBuild/ProjectFileExtensionRegistry.cs @@ -46,7 +46,7 @@ public void AssociateFileExtensionWithLanguage(string fileExtension, string lang } /// - /// Gets the registered project file extensions with a leading '.'. + /// Gets the registered project file extensions. Non-empty extensions have a leading '.'. /// public ImmutableArray GetRegisteredProjectFileExtensions() { diff --git a/src/Workspaces/MSBuild/Core/Microsoft.CodeAnalysis.Workspaces.MSBuild.csproj b/src/Workspaces/MSBuild/Core/Microsoft.CodeAnalysis.Workspaces.MSBuild.csproj index e43707714b72..d8bd4720e11b 100644 --- a/src/Workspaces/MSBuild/Core/Microsoft.CodeAnalysis.Workspaces.MSBuild.csproj +++ b/src/Workspaces/MSBuild/Core/Microsoft.CodeAnalysis.Workspaces.MSBuild.csproj @@ -70,6 +70,7 @@ + diff --git a/src/Workspaces/MSBuild/Test/NetCoreTests.cs b/src/Workspaces/MSBuild/Test/NetCoreTests.cs index 695308d42ddb..ef1f6ab832fa 100644 --- a/src/Workspaces/MSBuild/Test/NetCoreTests.cs +++ b/src/Workspaces/MSBuild/Test/NetCoreTests.cs @@ -747,6 +747,7 @@ public async Task TestOpenProject_FileBasedApp_AssociateFileExtensionWithLanguag var sourceFilePath = GetSolutionFileName("Program.cs"); using var workspace = CreateMSBuildWorkspace(); + // Verify extensions registered with the leading dot produced by Path.GetExtension are accepted. workspace.AssociateFileExtensionWithLanguage(".cs", LanguageNames.CSharp); await workspace.OpenProjectAsync(sourceFilePath); @@ -763,10 +764,10 @@ public void ProjectFileExtensionRegistryUsesLeadingDots() var registry = new ProjectFileExtensionRegistry(new DiagnosticReporter(workspace), fileBasedProgramService: null); var registeredExtensions = registry.GetRegisteredProjectFileExtensions(); - Assert.Equal(3, registeredExtensions.Length); Assert.Contains(".csproj", registeredExtensions); Assert.Contains(".vbproj", registeredExtensions); Assert.Contains(".fsproj", registeredExtensions); + Assert.All(registeredExtensions, extension => Assert.StartsWith(".", extension)); Assert.True(registry.TryGetLanguageNameFromExtension(".csproj", out var languageName)); Assert.Equal(LanguageNames.CSharp, languageName);