From d10097e188fae5d46507d9e4d768fc474144130a Mon Sep 17 00:00:00 2001 From: Jason Malinowski Date: Fri, 13 Jun 2025 18:18:18 -0700 Subject: [PATCH 1/3] Allow LanguageServerProjectSystems to load into more than one workspace This allows us to place file-based programs in the host workspace so project-to-project references work; truly "miscellaneous files" projects will still stay in the MiscellaneousFiles workspace. --- .../FileBasedProgramsProjectSystem.cs | 32 +++++++++---- .../LanguageServerProjectLoader.cs | 46 +++++++++++-------- .../LanguageServerProjectSystem.cs | 10 ++-- .../HostWorkspace/LoadedProject.cs | 6 ++- ...ILspMiscellaneousFilesWorkspaceProvider.cs | 12 ++++- .../LspMiscellaneousFilesWorkspaceProvider.cs | 7 ++- .../Workspaces/LspWorkspaceManager.cs | 25 +++++----- 7 files changed, 88 insertions(+), 50 deletions(-) diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs index f39faed8a6094..576902dc68961 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs @@ -27,6 +27,7 @@ internal sealed class FileBasedProgramsProjectSystem : LanguageServerProjectLoad private readonly ILogger _logger; private readonly IMetadataAsSourceFileService _metadataAsSourceFileService; private readonly VirtualProjectXmlProvider _projectXmlProvider; + private readonly LanguageServerWorkspaceFactory _workspaceFactory; public FileBasedProgramsProjectSystem( ILspServices lspServices, @@ -41,7 +42,6 @@ public FileBasedProgramsProjectSystem( ServerConfigurationFactory serverConfigurationFactory, IBinLogPathProvider binLogPathProvider) : base( - workspaceFactory.FileBasedProgramsProjectFactory, workspaceFactory.TargetFrameworkManager, workspaceFactory.ProjectSystemHostInfo, fileChangeWatcher, @@ -56,12 +56,20 @@ public FileBasedProgramsProjectSystem( _logger = loggerFactory.CreateLogger(); _metadataAsSourceFileService = metadataAsSourceFileService; _projectXmlProvider = projectXmlProvider; + _workspaceFactory = workspaceFactory; } - public Workspace Workspace => ProjectFactory.Workspace; - private string GetDocumentFilePath(DocumentUri uri) => uri.ParsedUri is { } parsedUri ? ProtocolConversions.GetDocumentFilePathFromUri(parsedUri) : uri.UriString; + public async ValueTask IsMiscellaneousFilesDocumentAsync(TextDocument document, CancellationToken cancellationToken) + { + // There are two cases here: if it's a primordial document, it'll be in the MiscellaneousFilesWorkspace and thus we definitely know it's + // a miscellaneous file. Otherwise, it might be a file-based program that we loaded in the main workspace; in this case, the project's path + // is also the source file path, and that's what we consider the 'project' path that is loaded. + return document.Project.Solution.Workspace == _workspaceFactory.FileBasedProgramsProjectFactory.Workspace || + document.Project.FilePath is not null && await IsProjectLoadedAsync(document.Project.FilePath, cancellationToken); + } + public async ValueTask AddMiscellaneousDocumentAsync(DocumentUri uri, SourceText documentText, string languageId, ILspLogger logger) { var documentFilePath = GetDocumentFilePath(uri); @@ -80,7 +88,7 @@ public FileBasedProgramsProjectSystem( var doDesignTimeBuild = uri.ParsedUri?.IsFile is true && primordialDoc.Project.Language == LanguageNames.CSharp && GlobalOptionService.GetOption(LanguageServerProjectSystemOptionsStorage.EnableFileBasedPrograms); - await BeginLoadingProjectWithPrimordialAsync(primordialDoc.FilePath, primordialProjectId: primordialDoc.Project.Id, doDesignTimeBuild); + await BeginLoadingProjectWithPrimordialAsync(primordialDoc.FilePath, _workspaceFactory.FileBasedProgramsProjectFactory, primordialProjectId: primordialDoc.Project.Id, doDesignTimeBuild); return primordialDoc; @@ -92,12 +100,12 @@ TextDocument AddPrimordialDocument(DocumentUri uri, SourceText documentText, str Contract.Fail($"Could not find language information for {uri} with absolute path {documentFilePath}"); } - var workspace = Workspace; + var workspace = _workspaceFactory.FileBasedProgramsProjectFactory.Workspace; var sourceTextLoader = new SourceTextLoader(documentText, documentFilePath); var projectInfo = MiscellaneousFileUtilities.CreateMiscellaneousProjectInfoForDocument( workspace, documentFilePath, sourceTextLoader, languageInformation, documentText.ChecksumAlgorithm, workspace.Services.SolutionServices, []); - ProjectFactory.ApplyChangeToWorkspace(workspace => workspace.OnProjectAdded(projectInfo)); + _workspaceFactory.FileBasedProgramsProjectFactory.ApplyChangeToWorkspace(workspace => workspace.OnProjectAdded(projectInfo)); // https://github.com/dotnet/roslyn/pull/78267 // Work around an issue where opening a Razor file in the misc workspace causes a crash. @@ -145,13 +153,21 @@ public async ValueTask TryRemoveMiscellaneousDocumentAsync(DocumentUri uri, bool // This is necessary in order to get msbuild to apply the standard c# props/targets to the project. var virtualProjectPath = VirtualProjectXmlProvider.GetVirtualProjectPath(documentPath); - var loader = ProjectFactory.CreateFileTextLoader(documentPath); + var loader = _workspaceFactory.FileBasedProgramsProjectFactory.CreateFileTextLoader(documentPath); var textAndVersion = await loader.LoadTextAsync(new LoadTextOptions(SourceHashAlgorithms.Default), cancellationToken); var isFileBasedProgram = VirtualProjectXmlProvider.IsFileBasedProgram(documentPath, textAndVersion.Text); const BuildHostProcessKind buildHostKind = BuildHostProcessKind.NetCore; var buildHost = await buildHostProcessManager.GetBuildHostAsync(buildHostKind, cancellationToken); var loadedFile = await buildHost.LoadProjectAsync(virtualProjectPath, virtualProjectContent, languageName: LanguageNames.CSharp, cancellationToken); - return new RemoteProjectLoadResult(loadedFile, HasAllInformation: isFileBasedProgram, Preferred: buildHostKind, Actual: buildHostKind); + + return new RemoteProjectLoadResult( + loadedFile, + // If it's a proper file based program, we'll put it in the main host workspace factory since we want cross-project references to work. + // Otherwise, we'll keep it in miscellaneous files. + ProjectFactory: isFileBasedProgram ? _workspaceFactory.HostProjectFactory : _workspaceFactory.FileBasedProgramsProjectFactory, + HasAllInformation: isFileBasedProgram, + Preferred: buildHostKind, + Actual: buildHostKind); } } diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs index 9ec4ea507ef4d..8362c6cb6208c 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs @@ -29,7 +29,6 @@ internal abstract class LanguageServerProjectLoader { private readonly AsyncBatchingWorkQueue _projectsToReload; - protected readonly ProjectSystemProjectFactory ProjectFactory; private readonly ProjectTargetFrameworkManager _targetFrameworkManager; private readonly ProjectSystemHostInfo _projectSystemHostInfo; private readonly IFileChangeWatcher _fileChangeWatcher; @@ -66,11 +65,15 @@ private ProjectLoadState() { } /// Represents a project which has not yet had a design-time build performed for it, /// and which has an associated "primordial project" in the workspace. /// + /// + /// The project factory for the workspace that the primordial project lives within. This + /// factory was not used to create the project, but still needs to be used during removal to avoid locking issues. + /// /// /// ID of the project which LSP uses to fulfill requests until the first design-time build is complete. /// The project with this ID is removed from the workspace when unloading or when transitioning to state. /// - public sealed record Primordial(ProjectId PrimordialProjectId) : ProjectLoadState; + public sealed record Primordial(ProjectSystemProjectFactory PrimordialProjectFactory, ProjectId PrimordialProjectId) : ProjectLoadState; /// /// Represents a project for which we have loaded zero or more targets. @@ -83,7 +86,6 @@ public sealed record LoadedTargets(ImmutableArray LoadedProjectTa } protected LanguageServerProjectLoader( - ProjectSystemProjectFactory projectFactory, ProjectTargetFrameworkManager targetFrameworkManager, ProjectSystemHostInfo projectSystemHostInfo, IFileChangeWatcher fileChangeWatcher, @@ -94,7 +96,6 @@ protected LanguageServerProjectLoader( ServerConfigurationFactory serverConfigurationFactory, IBinLogPathProvider binLogPathProvider) { - ProjectFactory = projectFactory; _targetFrameworkManager = targetFrameworkManager; _projectSystemHostInfo = projectSystemHostInfo; _fileChangeWatcher = fileChangeWatcher; @@ -103,7 +104,6 @@ protected LanguageServerProjectLoader( _logger = loggerFactory.CreateLogger(nameof(LanguageServerProjectLoader)); _projectLoadTelemetryReporter = projectLoadTelemetry; _binLogPathProvider = binLogPathProvider; - var workspace = projectFactory.Workspace; var razorDesignTimePath = serverConfigurationFactory.ServerConfiguration?.RazorDesignTimePath; AdditionalProperties = razorDesignTimePath is null @@ -174,7 +174,7 @@ private async ValueTask ReloadProjectsAsync(ImmutableSegmentedListLoads a project in the MSBuild host. /// Caller needs to catch exceptions to avoid bringing down the project loader queue. @@ -209,7 +209,7 @@ private async Task ReloadProjectAsync(ProjectToLoad projectToLoad, ToastEr return false; } - (RemoteProjectFile remoteProjectFile, bool hasAllInformation, BuildHostProcessKind preferredBuildHostKind, BuildHostProcessKind actualBuildHostKind) = remoteProjectLoadResult; + (RemoteProjectFile remoteProjectFile, ProjectSystemProjectFactory projectFactory, bool hasAllInformation, BuildHostProcessKind preferredBuildHostKind, BuildHostProcessKind actualBuildHostKind) = remoteProjectLoadResult; if (preferredBuildHostKind != actualBuildHostKind) preferredBuildHostKindThatWeDidNotGet = preferredBuildHostKind; @@ -226,7 +226,7 @@ private async Task ReloadProjectAsync(ProjectToLoad projectToLoad, ToastEr // The out-of-proc build host supports more languages than we may actually have Workspace binaries for, so ensure we can actually process that // language in-process. var projectLanguage = loadedProjectInfos.FirstOrDefault()?.Language; - if (projectLanguage != null && ProjectFactory.Workspace.Services.GetLanguageService(projectLanguage) == null) + if (projectLanguage != null && projectFactory.Workspace.Services.GetLanguageService(projectLanguage) == null) { return false; } @@ -246,7 +246,7 @@ private async Task ReloadProjectAsync(ProjectToLoad projectToLoad, ToastEr var newProjectTargetsBuilder = ArrayBuilder.GetInstance(loadedProjectInfos.Length); foreach (var loadedProjectInfo in loadedProjectInfos) { - var (target, targetAlreadyExists) = await GetOrCreateProjectTargetAsync(previousProjectTargets, loadedProjectInfo); + var (target, targetAlreadyExists) = await GetOrCreateProjectTargetAsync(previousProjectTargets, projectFactory, loadedProjectInfo); newProjectTargetsBuilder.Add(target); var (targetTelemetryInfo, targetNeedsRestore) = await target.UpdateWithNewProjectInfoAsync(loadedProjectInfo, hasAllInformation, _logger); @@ -272,13 +272,13 @@ private async Task ReloadProjectAsync(ProjectToLoad projectToLoad, ToastEr await _projectLoadTelemetryReporter.ReportProjectLoadTelemetryAsync(telemetryInfos, projectToLoad, cancellationToken); } - if (currentLoadState is ProjectLoadState.Primordial(var projectId)) + if (currentLoadState is ProjectLoadState.Primordial(var primordialProjectFactory, var projectId)) { // Remove the primordial project now that the design-time build pass is finished. This ensures that // we have the new project in place before we remove the primordial project; otherwise for // Miscellaneous Files we could have a case where we'd get another request to create a project // for the project we're currently processing. - await ProjectFactory.ApplyChangeToWorkspaceAsync(workspace => workspace.OnProjectRemoved(projectId), cancellationToken); + await primordialProjectFactory.ApplyChangeToWorkspaceAsync(workspace => workspace.OnProjectRemoved(projectId), cancellationToken); } _loadedProjects[projectPath] = new ProjectLoadState.LoadedTargets(newProjectTargets); @@ -306,9 +306,9 @@ private async Task ReloadProjectAsync(ProjectToLoad projectToLoad, ToastEr return false; } - async Task<(LoadedProject, bool alreadyExists)> GetOrCreateProjectTargetAsync(ImmutableArray previousProjectTargets, ProjectFileInfo loadedProjectInfo) + async Task<(LoadedProject, bool alreadyExists)> GetOrCreateProjectTargetAsync(ImmutableArray previousProjectTargets, ProjectSystemProjectFactory projectFactory, ProjectFileInfo loadedProjectInfo) { - var existingProject = previousProjectTargets.FirstOrDefault(p => p.GetTargetFramework() == loadedProjectInfo.TargetFramework); + var existingProject = previousProjectTargets.FirstOrDefault(p => p.GetTargetFramework() == loadedProjectInfo.TargetFramework && p.ProjectFactory == projectFactory); if (existingProject != null) { return (existingProject, alreadyExists: true); @@ -324,13 +324,13 @@ private async Task ReloadProjectAsync(ProjectToLoad projectToLoad, ToastEr CompilationOutputAssemblyFilePath = loadedProjectInfo.IntermediateOutputFilePath, }; - var projectSystemProject = await ProjectFactory.CreateAndAddToWorkspaceAsync( + var projectSystemProject = await projectFactory.CreateAndAddToWorkspaceAsync( projectSystemName, loadedProjectInfo.Language, projectCreationInfo, _projectSystemHostInfo); - var loadedProject = new LoadedProject(projectSystemProject, ProjectFactory.Workspace.Services.SolutionServices, _fileChangeWatcher, _targetFrameworkManager); + var loadedProject = new LoadedProject(projectSystemProject, projectFactory, _fileChangeWatcher, _targetFrameworkManager); loadedProject.NeedsReload += (_, _) => _projectsToReload.AddWork(projectToLoad with { ReportTelemetry = false }); return (loadedProject, alreadyExists: false); } @@ -358,6 +358,14 @@ async Task LogDiagnosticsAsync(ImmutableArray diagnosticLogIt } } + protected async ValueTask IsProjectLoadedAsync(string projectPath, CancellationToken cancellationToken) + { + using (await _gate.DisposableWaitAsync(cancellationToken)) + { + return _loadedProjects.ContainsKey(projectPath); + } + } + /// /// Begins loading a project with an associated primordial project. Must not be called for a project which has already begun loading. /// @@ -365,7 +373,7 @@ async Task LogDiagnosticsAsync(ImmutableArray diagnosticLogIt /// If , initiates a design-time build now, and starts file watchers to repeat the design-time build on relevant changes. /// If , only tracks the primordial project. /// - protected async ValueTask BeginLoadingProjectWithPrimordialAsync(string projectPath, ProjectId primordialProjectId, bool doDesignTimeBuild) + protected async ValueTask BeginLoadingProjectWithPrimordialAsync(string projectPath, ProjectSystemProjectFactory primordialProjectFactory, ProjectId primordialProjectId, bool doDesignTimeBuild) { using (await _gate.DisposableWaitAsync(CancellationToken.None)) { @@ -377,7 +385,7 @@ protected async ValueTask BeginLoadingProjectWithPrimordialAsync(string projectP Contract.Fail($"Cannot begin loading project '{projectPath}' because it has already begun loading."); } - _loadedProjects.Add(projectPath, new ProjectLoadState.Primordial(primordialProjectId)); + _loadedProjects.Add(projectPath, new ProjectLoadState.Primordial(primordialProjectFactory, primordialProjectId)); if (doDesignTimeBuild) { _projectsToReload.AddWork(new ProjectToLoad(projectPath, ProjectGuid: null, ReportTelemetry: true)); @@ -416,9 +424,9 @@ protected async ValueTask UnloadProjectAsync(string projectPath) return; } - if (loadState is ProjectLoadState.Primordial(var projectId)) + if (loadState is ProjectLoadState.Primordial(var projectFactory, var projectId)) { - await ProjectFactory.ApplyChangeToWorkspaceAsync(workspace => workspace.OnProjectRemoved(projectId)); + await projectFactory.ApplyChangeToWorkspaceAsync(workspace => workspace.OnProjectRemoved(projectId)); } else if (loadState is ProjectLoadState.LoadedTargets(var existingProjects)) { diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs index 5ab36238c36fc..fbf1eb09399ed 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs @@ -10,6 +10,7 @@ using Microsoft.CodeAnalysis.Options; using Microsoft.CodeAnalysis.ProjectSystem; using Microsoft.CodeAnalysis.Shared.TestHooks; +using Microsoft.CodeAnalysis.Workspaces.ProjectSystem; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.Composition; using Roslyn.Utilities; @@ -22,6 +23,7 @@ internal sealed class LanguageServerProjectSystem : LanguageServerProjectLoader { private readonly ILogger _logger; private readonly ProjectFileExtensionRegistry _projectFileExtensionRegistry; + private readonly ProjectSystemProjectFactory _hostProjectFactory; [ImportingConstructor] [Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] @@ -35,7 +37,6 @@ public LanguageServerProjectSystem( ServerConfigurationFactory serverConfigurationFactory, IBinLogPathProvider binLogPathProvider) : base( - workspaceFactory.HostProjectFactory, workspaceFactory.TargetFrameworkManager, workspaceFactory.ProjectSystemHostInfo, fileChangeWatcher, @@ -47,14 +48,15 @@ public LanguageServerProjectSystem( binLogPathProvider) { _logger = loggerFactory.CreateLogger(nameof(LanguageServerProjectSystem)); - var workspace = ProjectFactory.Workspace; + _hostProjectFactory = workspaceFactory.HostProjectFactory; + var workspace = workspaceFactory.HostWorkspace; _projectFileExtensionRegistry = new ProjectFileExtensionRegistry(workspace.CurrentSolution.Services, new DiagnosticReporter(workspace)); } public async Task OpenSolutionAsync(string solutionFilePath) { _logger.LogInformation(string.Format(LanguageServerResources.Loading_0, solutionFilePath)); - ProjectFactory.SolutionPath = solutionFilePath; + _hostProjectFactory.SolutionPath = solutionFilePath; var (_, projects) = await SolutionFileReader.ReadSolutionFileAsync(solutionFilePath, DiagnosticReportingMode.Throw, CancellationToken.None); foreach (var (path, guid) in projects) @@ -88,6 +90,6 @@ public async Task OpenProjectsAsync(ImmutableArray projectFilePaths) var (buildHost, actualBuildHostKind) = await buildHostProcessManager.GetBuildHostWithFallbackAsync(preferredBuildHostKind, projectPath, cancellationToken); var loadedFile = await buildHost.LoadProjectFileAsync(projectPath, languageName, cancellationToken); - return new RemoteProjectLoadResult(loadedFile, HasAllInformation: true, preferredBuildHostKind, actualBuildHostKind); + return new RemoteProjectLoadResult(loadedFile, _hostProjectFactory, HasAllInformation: true, preferredBuildHostKind, actualBuildHostKind); } } diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LoadedProject.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LoadedProject.cs index 254822bbf2534..6056e4ee8abb7 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LoadedProject.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LoadedProject.cs @@ -25,6 +25,7 @@ internal sealed class LoadedProject : IDisposable private readonly string _projectDirectory; private readonly ProjectSystemProject _projectSystemProject; + public ProjectSystemProjectFactory ProjectFactory { get; } private readonly ProjectSystemProjectOptionsProcessor _optionsProcessor; private readonly IFileChangeContext _sourceFileChangeContext; private readonly IFileChangeContext _projectFileChangeContext; @@ -42,13 +43,14 @@ internal sealed class LoadedProject : IDisposable private ImmutableArray _mostRecentMetadataReferences = []; private ImmutableArray _mostRecentAnalyzerReferences = []; - public LoadedProject(ProjectSystemProject projectSystemProject, SolutionServices solutionServices, IFileChangeWatcher fileWatcher, ProjectTargetFrameworkManager targetFrameworkManager) + public LoadedProject(ProjectSystemProject projectSystemProject, ProjectSystemProjectFactory projectFactory, IFileChangeWatcher fileWatcher, ProjectTargetFrameworkManager targetFrameworkManager) { Contract.ThrowIfNull(projectSystemProject.FilePath); _projectFilePath = projectSystemProject.FilePath; _projectSystemProject = projectSystemProject; - _optionsProcessor = new ProjectSystemProjectOptionsProcessor(projectSystemProject, solutionServices); + ProjectFactory = projectFactory; + _optionsProcessor = new ProjectSystemProjectOptionsProcessor(projectSystemProject, projectFactory.Workspace.CurrentSolution.Services); _targetFrameworkManager = targetFrameworkManager; // We'll watch the directory for all source file changes diff --git a/src/LanguageServer/Protocol/Workspaces/ILspMiscellaneousFilesWorkspaceProvider.cs b/src/LanguageServer/Protocol/Workspaces/ILspMiscellaneousFilesWorkspaceProvider.cs index 21afe635661a0..e31acc207ddf3 100644 --- a/src/LanguageServer/Protocol/Workspaces/ILspMiscellaneousFilesWorkspaceProvider.cs +++ b/src/LanguageServer/Protocol/Workspaces/ILspMiscellaneousFilesWorkspaceProvider.cs @@ -2,6 +2,7 @@ // 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.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis.Text; using Microsoft.CommonLanguageServerProtocol.Framework; @@ -9,12 +10,19 @@ namespace Microsoft.CodeAnalysis.LanguageServer; +/// +/// Allows the LSP server to create miscellaneous files documents. +/// +/// +/// No methods will be called concurrently, since we are dispatching LSP requests one at a time in the core dispatching loop. +/// This does mean methods should be reasonably fast since they will block the LSP server from processing other requests while they are running. +/// internal interface ILspMiscellaneousFilesWorkspaceProvider : ILspService { /// - /// Returns the actual workspace that the documents are added to or removed from. + /// Returns whether the document is one that came from a previous call to . /// - Workspace Workspace { get; } + ValueTask IsMiscellaneousFilesDocumentAsync(TextDocument document, CancellationToken cancellationToken); /// /// Adds a document to the workspace. Note that the implementation of this method should not depend on anything expensive such as RPC calls. diff --git a/src/LanguageServer/Protocol/Workspaces/LspMiscellaneousFilesWorkspaceProvider.cs b/src/LanguageServer/Protocol/Workspaces/LspMiscellaneousFilesWorkspaceProvider.cs index d8ead3e1210c1..da46a5825a2b6 100644 --- a/src/LanguageServer/Protocol/Workspaces/LspMiscellaneousFilesWorkspaceProvider.cs +++ b/src/LanguageServer/Protocol/Workspaces/LspMiscellaneousFilesWorkspaceProvider.cs @@ -31,8 +31,11 @@ internal sealed class LspMiscellaneousFilesWorkspaceProvider(ILspServices lspSer { public bool SupportsMutation => true; - // For this implementation, it's easiest to just have it inherit from Workspace, so we'll just return this. - public Workspace Workspace => this; + public ValueTask IsMiscellaneousFilesDocumentAsync(TextDocument document, CancellationToken cancellationToken) + { + // In this case, the only documents ever created live in the Miscellaneous Files workspace (which is this object directly), so we can just compare to 'this'. + return ValueTaskFactory.FromResult(document.Project.Solution.Workspace == this); + } /// /// Takes in a file URI and text and creates a misc project and document for the file. diff --git a/src/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs b/src/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs index 22e3c092dbbc9..a23b3e4bb0693 100644 --- a/src/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs +++ b/src/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs @@ -260,21 +260,18 @@ public void UpdateTrackedDocument(DocumentUri uri, SourceText newSourceText) _requestTelemetryLogger.UpdateUsedForkedSolutionCounter(isForked); _logger.LogDebug($"{document.FilePath} found in workspace {workspaceKind}"); - // As we found the document in a non-misc workspace, also attempt to remove it from the misc workspace + // If we found the document in a non-misc workspace, also attempt to remove it from the misc workspace // if it happens to be in there as well. - if (workspace != _lspMiscellaneousFilesWorkspaceProvider?.Workspace) + if (_lspMiscellaneousFilesWorkspaceProvider is not null && !await _lspMiscellaneousFilesWorkspaceProvider.IsMiscellaneousFilesDocumentAsync(document, cancellationToken).ConfigureAwait(false)) { - if (_lspMiscellaneousFilesWorkspaceProvider is not null) + try { - try - { - // Do not attempt to remove the file from the metadata workspace (the document is still open). - await _lspMiscellaneousFilesWorkspaceProvider.TryRemoveMiscellaneousDocumentAsync(uri, removeFromMetadataWorkspace: false).ConfigureAwait(false); - } - catch (Exception ex) when (FatalError.ReportAndCatch(ex)) - { - _logger.LogException(ex); - } + // Do not attempt to remove the file from the metadata workspace (the document is still open). + await _lspMiscellaneousFilesWorkspaceProvider.TryRemoveMiscellaneousDocumentAsync(uri, removeFromMetadataWorkspace: false).ConfigureAwait(false); + } + catch (Exception ex) when (FatalError.ReportAndCatch(ex)) + { + _logger.LogException(ex); } } @@ -569,7 +566,9 @@ public TestAccessor(LspWorkspaceManager manager) public Workspace? GetLspMiscellaneousFilesWorkspace() { - return _manager._lspMiscellaneousFilesWorkspaceProvider?.Workspace; + // For purposes of testing, we test against the implementation that is also a Workspace. + // TODO: once we also test the FileBasedPrograms implementation, we need to do something else here. + return _manager._lspMiscellaneousFilesWorkspaceProvider as Workspace; } public bool IsWorkspaceRegistered(Workspace workspace) From 22c839492afb1319eb279cc902419e028aa8f954 Mon Sep 17 00:00:00 2001 From: Jason Malinowski Date: Fri, 27 Jun 2025 17:41:26 -0700 Subject: [PATCH 2/3] Add an assert to check that things ended up how we expected I'm doing an assert here rather than throwing because although this is an expectation, I don't imagine anything will be terribly wrong for the user if it's violated. But throwing would definitely make things bad. --- .../HostWorkspace/LanguageServerProjectLoader.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs index 8362c6cb6208c..bc083ce0c797d 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs @@ -281,6 +281,10 @@ private async Task ReloadProjectAsync(ProjectToLoad projectToLoad, ToastEr await primordialProjectFactory.ApplyChangeToWorkspaceAsync(workspace => workspace.OnProjectRemoved(projectId), cancellationToken); } + // At this point we expect that all the loaded projects are now in the project factory returned, and any previous ones have been removed. + // this is a Debug.Assert() because if this expectation fails, the user's probably still in a state where things will work just fine; + // throwing here would mean we don't remember the LoadedProjects we created, and the next update will create more and things will get really broken. + Debug.Assert(newProjectTargets.All(target => target.ProjectFactory == projectFactory)); _loadedProjects[projectPath] = new ProjectLoadState.LoadedTargets(newProjectTargets); } From a5ff3f7bcb8cb7116709009e7bbafc73ce2d4c79 Mon Sep 17 00:00:00 2001 From: Jason Malinowski Date: Wed, 2 Jul 2025 15:21:12 -0700 Subject: [PATCH 3/3] Rename FileBasedProgramsProjectFactory to MiscellaneousFilesWorkspaceProjectFactory --- .../FileBasedProgramsProjectSystem.cs | 12 ++++++------ .../HostWorkspace/LanguageServerWorkspaceFactory.cs | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs index 576902dc68961..1d9434538c1c6 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs @@ -66,7 +66,7 @@ public async ValueTask IsMiscellaneousFilesDocumentAsync(TextDocument docu // There are two cases here: if it's a primordial document, it'll be in the MiscellaneousFilesWorkspace and thus we definitely know it's // a miscellaneous file. Otherwise, it might be a file-based program that we loaded in the main workspace; in this case, the project's path // is also the source file path, and that's what we consider the 'project' path that is loaded. - return document.Project.Solution.Workspace == _workspaceFactory.FileBasedProgramsProjectFactory.Workspace || + return document.Project.Solution.Workspace == _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory.Workspace || document.Project.FilePath is not null && await IsProjectLoadedAsync(document.Project.FilePath, cancellationToken); } @@ -88,7 +88,7 @@ public async ValueTask IsMiscellaneousFilesDocumentAsync(TextDocument docu var doDesignTimeBuild = uri.ParsedUri?.IsFile is true && primordialDoc.Project.Language == LanguageNames.CSharp && GlobalOptionService.GetOption(LanguageServerProjectSystemOptionsStorage.EnableFileBasedPrograms); - await BeginLoadingProjectWithPrimordialAsync(primordialDoc.FilePath, _workspaceFactory.FileBasedProgramsProjectFactory, primordialProjectId: primordialDoc.Project.Id, doDesignTimeBuild); + await BeginLoadingProjectWithPrimordialAsync(primordialDoc.FilePath, _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory, primordialProjectId: primordialDoc.Project.Id, doDesignTimeBuild); return primordialDoc; @@ -100,12 +100,12 @@ TextDocument AddPrimordialDocument(DocumentUri uri, SourceText documentText, str Contract.Fail($"Could not find language information for {uri} with absolute path {documentFilePath}"); } - var workspace = _workspaceFactory.FileBasedProgramsProjectFactory.Workspace; + var workspace = _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory.Workspace; var sourceTextLoader = new SourceTextLoader(documentText, documentFilePath); var projectInfo = MiscellaneousFileUtilities.CreateMiscellaneousProjectInfoForDocument( workspace, documentFilePath, sourceTextLoader, languageInformation, documentText.ChecksumAlgorithm, workspace.Services.SolutionServices, []); - _workspaceFactory.FileBasedProgramsProjectFactory.ApplyChangeToWorkspace(workspace => workspace.OnProjectAdded(projectInfo)); + _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory.ApplyChangeToWorkspace(workspace => workspace.OnProjectAdded(projectInfo)); // https://github.com/dotnet/roslyn/pull/78267 // Work around an issue where opening a Razor file in the misc workspace causes a crash. @@ -153,7 +153,7 @@ public async ValueTask TryRemoveMiscellaneousDocumentAsync(DocumentUri uri, bool // This is necessary in order to get msbuild to apply the standard c# props/targets to the project. var virtualProjectPath = VirtualProjectXmlProvider.GetVirtualProjectPath(documentPath); - var loader = _workspaceFactory.FileBasedProgramsProjectFactory.CreateFileTextLoader(documentPath); + var loader = _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory.CreateFileTextLoader(documentPath); var textAndVersion = await loader.LoadTextAsync(new LoadTextOptions(SourceHashAlgorithms.Default), cancellationToken); var isFileBasedProgram = VirtualProjectXmlProvider.IsFileBasedProgram(documentPath, textAndVersion.Text); @@ -165,7 +165,7 @@ public async ValueTask TryRemoveMiscellaneousDocumentAsync(DocumentUri uri, bool loadedFile, // If it's a proper file based program, we'll put it in the main host workspace factory since we want cross-project references to work. // Otherwise, we'll keep it in miscellaneous files. - ProjectFactory: isFileBasedProgram ? _workspaceFactory.HostProjectFactory : _workspaceFactory.FileBasedProgramsProjectFactory, + ProjectFactory: isFileBasedProgram ? _workspaceFactory.HostProjectFactory : _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory, HasAllInformation: isFileBasedProgram, Preferred: buildHostKind, Actual: buildHostKind); diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerWorkspaceFactory.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerWorkspaceFactory.cs index 3fb37ff3277fa..d373b8aedc89b 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerWorkspaceFactory.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerWorkspaceFactory.cs @@ -56,12 +56,12 @@ public LanguageServerWorkspaceFactory( // https://github.com/dotnet/roslyn/issues/78560: Move this workspace creation to 'FileBasedProgramsWorkspaceProviderFactory'. // 'CreateSolutionLevelAnalyzerReferencesForWorkspace' needs to be broken out into its own service for us to be able to move this. - var fileBasedProgramsWorkspace = new LanguageServerWorkspace(hostServicesProvider.HostServices, WorkspaceKind.MiscellaneousFiles); - fileBasedProgramsWorkspace.SetCurrentSolution(s => s.WithAnalyzerReferences(CreateSolutionLevelAnalyzerReferencesForWorkspace(fileBasedProgramsWorkspace)), WorkspaceChangeKind.SolutionChanged); + var miscellaneousFilesWorkspace = new LanguageServerWorkspace(hostServicesProvider.HostServices, WorkspaceKind.MiscellaneousFiles); + miscellaneousFilesWorkspace.SetCurrentSolution(s => s.WithAnalyzerReferences(CreateSolutionLevelAnalyzerReferencesForWorkspace(miscellaneousFilesWorkspace)), WorkspaceChangeKind.SolutionChanged); - FileBasedProgramsProjectFactory = new ProjectSystemProjectFactory( - fileBasedProgramsWorkspace, fileChangeWatcher, static (_, _) => Task.CompletedTask, _ => { }, CancellationToken.None); - fileBasedProgramsWorkspace.ProjectSystemProjectFactory = FileBasedProgramsProjectFactory; + MiscellaneousFilesWorkspaceProjectFactory = new ProjectSystemProjectFactory( + miscellaneousFilesWorkspace, fileChangeWatcher, static (_, _) => Task.CompletedTask, _ => { }, CancellationToken.None); + miscellaneousFilesWorkspace.ProjectSystemProjectFactory = MiscellaneousFilesWorkspaceProjectFactory; ProjectSystemHostInfo = new ProjectSystemHostInfo( DynamicFileInfoProviders: [.. dynamicFileInfoProviders], @@ -73,7 +73,7 @@ public LanguageServerWorkspaceFactory( public Workspace HostWorkspace => HostProjectFactory.Workspace; public ProjectSystemProjectFactory HostProjectFactory { get; } - public ProjectSystemProjectFactory FileBasedProgramsProjectFactory { get; } + public ProjectSystemProjectFactory MiscellaneousFilesWorkspaceProjectFactory { get; } public ProjectSystemHostInfo ProjectSystemHostInfo { get; } public ProjectTargetFrameworkManager TargetFrameworkManager { get; }