diff --git a/src/EditorFeatures/CSharpTest/PdbSourceDocument/AbstractPdbSourceDocumentTests.cs b/src/EditorFeatures/CSharpTest/PdbSourceDocument/AbstractPdbSourceDocumentTests.cs index be856e088e40f..8667fb84e5ae3 100644 --- a/src/EditorFeatures/CSharpTest/PdbSourceDocument/AbstractPdbSourceDocumentTests.cs +++ b/src/EditorFeatures/CSharpTest/PdbSourceDocument/AbstractPdbSourceDocumentTests.cs @@ -156,15 +156,7 @@ protected static async Task GenerateFileAndVerifyAsync( var pdbService = (PdbSourceDocumentMetadataAsSourceFileProvider)workspace.ExportProvider.GetExportedValues().Single(s => s is PdbSourceDocumentMetadataAsSourceFileProvider); - // Add the document to the workspace. We provide an empty static source text as the API requires it to open the document. - // We're not really trying to verify that the source text the editor hands to us is the right encoding - just that the document we added has the right encoding. - var result = pdbService.TryAddDocumentToWorkspace((MetadataAsSourceWorkspace)masWorkspace!, file.FilePath, new StaticSourceTextContainer(SourceText.From(string.Empty)), out _); - Assert.True(result); - - // Immediately close the document so that we get the source text provided by the workspace (instead of the empty one we passed). var info = pdbService.GetTestAccessor().Documents[file.FilePath]; - masWorkspace!.OnDocumentClosed(info.DocumentId, new WorkspaceFileTextLoader(workspace.Services.SolutionServices, file.FilePath, info.Encoding)); - var document = masWorkspace!.CurrentSolution.GetRequiredDocument(info.DocumentId); // Mapping the project from the generated document should map back to the original project diff --git a/src/EditorFeatures/CSharpTest/PdbSourceDocument/NullResultMetadataAsSourceFileProvider.cs b/src/EditorFeatures/CSharpTest/PdbSourceDocument/NullResultMetadataAsSourceFileProvider.cs index 51fbc3eb947b0..92d85bb06302a 100644 --- a/src/EditorFeatures/CSharpTest/PdbSourceDocument/NullResultMetadataAsSourceFileProvider.cs +++ b/src/EditorFeatures/CSharpTest/PdbSourceDocument/NullResultMetadataAsSourceFileProvider.cs @@ -47,17 +47,6 @@ public void CleanupGeneratedFiles(MetadataAsSourceWorkspace workspace) return null; } - public bool TryAddDocumentToWorkspace(MetadataAsSourceWorkspace workspace, string filePath, Text.SourceTextContainer sourceTextContainer, [NotNullWhen(true)] out DocumentId? documentId) - { - documentId = null!; - return true; - } - - public bool TryRemoveDocumentFromWorkspace(MetadataAsSourceWorkspace workspace, string filePath) - { - return true; - } - public bool ShouldCollapseOnOpen(MetadataAsSourceWorkspace workspace, string filePath, BlockStructureOptions options) { return true; diff --git a/src/EditorFeatures/CSharpTest/PdbSourceDocument/PdbSourceDocumentTests.cs b/src/EditorFeatures/CSharpTest/PdbSourceDocument/PdbSourceDocumentTests.cs index c70a3d56f4764..7603ca9faa68b 100644 --- a/src/EditorFeatures/CSharpTest/PdbSourceDocument/PdbSourceDocumentTests.cs +++ b/src/EditorFeatures/CSharpTest/PdbSourceDocument/PdbSourceDocumentTests.cs @@ -1022,7 +1022,7 @@ await RunTestAsync(async path => var file = await service.GetGeneratedFileAsync(project.Solution.Workspace, project, symbol, signaturesOnly: false, options: MetadataAsSourceOptions.Default, cancellationToken: CancellationToken.None); var result = service.TryRemoveDocumentFromWorkspace(file.FilePath); - Assert.False(result); + Assert.True(result); }); } @@ -1057,7 +1057,7 @@ await RunTestAsync(async path => // Opening should still throw (should never be called as we should be able to find the previously // opened document in the MAS workspace). - Assert.Throws(() => service.TryAddDocumentToWorkspace(fileTwo.FilePath, new StaticSourceTextContainer(SourceText.From(string.Empty)), out var documentIdTwo)); + Assert.Throws(() => service.TryAddDocumentToWorkspace(fileTwo.FilePath, new StaticSourceTextContainer(SourceText.From(string.Empty)), out var documentIdTwo)); }); } } diff --git a/src/Features/Core/Portable/MetadataAsSource/DecompilationMetadataAsSourceFileProvider.cs b/src/Features/Core/Portable/MetadataAsSource/DecompilationMetadataAsSourceFileProvider.cs index 2a5a3aac8cb25..3d7047ccaf378 100644 --- a/src/Features/Core/Portable/MetadataAsSource/DecompilationMetadataAsSourceFileProvider.cs +++ b/src/Features/Core/Portable/MetadataAsSource/DecompilationMetadataAsSourceFileProvider.cs @@ -11,6 +11,7 @@ using System.IO; using System.Linq; using System.Runtime.CompilerServices; +using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis.DecompiledSource; @@ -33,11 +34,6 @@ internal sealed class DecompilationMetadataAsSourceFileProvider(IImplementationA { internal const string ProviderName = "Decompilation"; - /// - /// Guards access to and workspace updates when opening / closing documents. - /// - private readonly object _gate = new(); - /// /// Accessed only in and , both of which /// are called under a lock in . So this is safe as a plain @@ -50,15 +46,10 @@ internal sealed class DecompilationMetadataAsSourceFileProvider(IImplementationA /// generally run concurrently. However, to be safe, we make this a concurrent dictionary to be safe to that /// potentially happening. /// - private readonly ConcurrentDictionary _generatedFilenameToInformation = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _generatedFilenameToInformation = new(StringComparer.OrdinalIgnoreCase); private readonly IImplementationAssemblyLookupService _implementationAssemblyLookupService = implementationAssemblyLookupService; - /// - /// Only accessed and mutated from UI thread. - /// - private IBidirectionalMap _openedDocumentIds = BidirectionalMap.Empty; - public async Task GetGeneratedFileAsync( MetadataAsSourceWorkspace metadataWorkspace, Workspace sourceWorkspace, @@ -72,10 +63,8 @@ internal sealed class DecompilationMetadataAsSourceFileProvider(IImplementationA { // Use the current fallback analyzer config options from the source workspace. // Decompilation does not add projects to the MAS workspace, hence the workspace might remain empty and not receive fallback options automatically. - var metadataSolution = metadataWorkspace.CurrentSolution.WithFallbackAnalyzerOptions(sourceWorkspace.CurrentSolution.FallbackAnalyzerOptions); + metadataWorkspace.OnSolutionFallbackAnalyzerOptionsChanged(sourceWorkspace.CurrentSolution.FallbackAnalyzerOptions); - MetadataAsSourceGeneratedFileInfo fileInfo; - Location? navigateLocation = null; var topLevelNamedType = MetadataAsSourceHelpers.GetTopLevelContainingNamedType(symbol); var symbolId = SymbolKey.Create(symbol, cancellationToken); var compilation = await sourceProject.GetRequiredCompilationAsync(cancellationToken).ConfigureAwait(false); @@ -103,112 +92,126 @@ internal sealed class DecompilationMetadataAsSourceFileProvider(IImplementationA } var infoKey = await GetUniqueDocumentKeyAsync(sourceProject, topLevelNamedType, signaturesOnly: !useDecompiler, cancellationToken).ConfigureAwait(false); - fileInfo = _keyToInformation.GetOrAdd(infoKey, - _ => new MetadataAsSourceGeneratedFileInfo(tempPath, sourceWorkspace, sourceProject, topLevelNamedType, signaturesOnly: !useDecompiler)); - _generatedFilenameToInformation[fileInfo.TemporaryFilePath] = fileInfo; + var fileInfo = _keyToInformation.GetOrAdd(infoKey, + _ => new MetadataAsSourceGeneratedFileInfo(tempPath, sourceWorkspace, sourceProject, topLevelNamedType, signaturesOnly: !useDecompiler)); - if (!File.Exists(fileInfo.TemporaryFilePath)) + DocumentId generatedDocumentId; + Location navigateLocation; + if (!_generatedFilenameToInformation.TryGetValue(fileInfo.TemporaryFilePath, out var existingDocumentId)) { - // We need to generate this. First, we'll need a temporary project to do the generation into. We - // avoid loading the actual file from disk since it doesn't exist yet. - - var (temporaryProjectInfo, temporaryDocumentId) = fileInfo.GetProjectInfoAndDocumentId(metadataSolution.Services, loadFileFromDisk: false); - var temporaryDocument = metadataSolution - .AddProject(temporaryProjectInfo) + // We don't have this file in the workspace. We need to create a project to put it in. + var (temporaryProjectInfo, temporaryDocumentId) = GenerateProjectAndDocumentInfo(fileInfo, metadataWorkspace.CurrentSolution.Services, sourceProject, topLevelNamedType); + metadataWorkspace.OnProjectAdded(temporaryProjectInfo); + var temporaryDocument = metadataWorkspace.CurrentSolution .GetRequiredDocument(temporaryDocumentId); - if (useDecompiler) + // Generate the file if it doesn't exist (we may still have it if there was a previous request for it that was then closed). + if (!File.Exists(fileInfo.TemporaryFilePath)) { - try + if (useDecompiler) { - // Fetch the IDecompiledSourceService from the temporary document, not the original one -- it - // may be a different language because we don't have support for decompiling into VB.NET, so we just - // use C#. - var decompiledSourceService = temporaryDocument.GetLanguageService(); - - if (decompiledSourceService != null) + try { - var decompilationDocument = await decompiledSourceService.AddSourceToAsync(temporaryDocument, compilation, symbol, refInfo.metadataReference, refInfo.assemblyLocation, formattingOptions: null, cancellationToken).ConfigureAwait(false); - telemetryMessage?.SetDecompiled(decompilationDocument is not null); - if (decompilationDocument is not null) + // Fetch the IDecompiledSourceService from the temporary document, not the original one -- it + // may be a different language because we don't have support for decompiling into VB.NET, so we just + // use C#. + var decompiledSourceService = temporaryDocument.GetLanguageService(); + + if (decompiledSourceService != null) { - temporaryDocument = decompilationDocument; + var decompilationDocument = await decompiledSourceService.AddSourceToAsync(temporaryDocument, compilation, symbol, refInfo.metadataReference, refInfo.assemblyLocation, formattingOptions: null, cancellationToken).ConfigureAwait(false); + telemetryMessage?.SetDecompiled(decompilationDocument is not null); + if (decompilationDocument is not null) + { + temporaryDocument = decompilationDocument; + } + else + { + useDecompiler = false; + } } else { useDecompiler = false; } } - else + catch (Exception e) when (FatalError.ReportAndCatchUnlessCanceled(e, cancellationToken, ErrorSeverity.General)) { useDecompiler = false; } } - catch (Exception e) when (FatalError.ReportAndCatchUnlessCanceled(e, cancellationToken, ErrorSeverity.General)) - { - useDecompiler = false; - } - } - - if (!useDecompiler) - { - var sourceFromMetadataService = temporaryDocument.Project.Services.GetRequiredService(); - temporaryDocument = await sourceFromMetadataService.AddSourceToAsync(temporaryDocument, compilation, symbol, formattingOptions: null, cancellationToken).ConfigureAwait(false); - } - // We have the content, so write it out to disk - var text = await temporaryDocument.GetValueTextAsync(cancellationToken).ConfigureAwait(false); - - // Create the directory. It's possible a parallel deletion is happening in another process, so we may have - // to retry this a few times. - // - // If we still can't create the folder after 5 seconds, assume we will not be able to create it and - // continue without actually writing the text to disk. - var directoryToCreate = Path.GetDirectoryName(fileInfo.TemporaryFilePath)!; - var stopwatch = SharedStopwatch.StartNew(); - var timeout = TimeSpan.FromSeconds(5); - var firstAttempt = true; - var skipWritingFile = false; - - while (!IOUtilities.PerformIO(() => Directory.Exists(directoryToCreate))) - { - if (stopwatch.Elapsed > timeout) + if (!useDecompiler) { - // If we still can't create the folder after 5 seconds, assume we will not be able to create it. - skipWritingFile = true; - break; + var sourceFromMetadataService = temporaryDocument.Project.Services.GetRequiredService(); + temporaryDocument = await sourceFromMetadataService.AddSourceToAsync(temporaryDocument, compilation, symbol, formattingOptions: null, cancellationToken).ConfigureAwait(false); } - if (firstAttempt) - { - firstAttempt = false; - } - else + // We have the content, so write it out to disk + var text = await temporaryDocument.GetValueTextAsync(cancellationToken).ConfigureAwait(false); + + // Create the directory. It's possible a parallel deletion is happening in another process, so we may have + // to retry this a few times. + // + // If we still can't create the folder after 5 seconds, assume we will not be able to create it and + // continue without actually writing the text to disk. + var directoryToCreate = Path.GetDirectoryName(fileInfo.TemporaryFilePath)!; + var stopwatch = SharedStopwatch.StartNew(); + var timeout = TimeSpan.FromSeconds(5); + var firstAttempt = true; + var skipWritingFile = false; + + while (!IOUtilities.PerformIO(() => Directory.Exists(directoryToCreate))) { - await Task.Delay(DelayTimeSpan.Short, cancellationToken).ConfigureAwait(false); - } + if (stopwatch.Elapsed > timeout) + { + // If we still can't create the folder after 5 seconds, assume we will not be able to create it. + skipWritingFile = true; + break; + } - IOUtilities.PerformIO(() => Directory.CreateDirectory(directoryToCreate)); - } + if (firstAttempt) + { + firstAttempt = false; + } + else + { + await Task.Delay(DelayTimeSpan.Short, cancellationToken).ConfigureAwait(false); + } - if (!skipWritingFile) - { - using (var textWriter = new StreamWriter(fileInfo.TemporaryFilePath, append: false, encoding: MetadataAsSourceGeneratedFileInfo.Encoding)) - { - text.Write(textWriter, cancellationToken); + IOUtilities.PerformIO(() => Directory.CreateDirectory(directoryToCreate)); } - // Mark read-only - new FileInfo(fileInfo.TemporaryFilePath).IsReadOnly = true; + if (!skipWritingFile && !File.Exists(fileInfo.TemporaryFilePath)) + { + using (var textWriter = new StreamWriter(fileInfo.TemporaryFilePath, append: false, encoding: MetadataAsSourceGeneratedFileInfo.Encoding)) + { + text.Write(textWriter, cancellationToken); + } + + // Mark read-only + new FileInfo(fileInfo.TemporaryFilePath).IsReadOnly = true; + } } - // Locate the target in the thing we just created + // Retrieve the navigable location for the symbol using the generated syntax. navigateLocation = await MetadataAsSourceHelpers.GetLocationInGeneratedSourceAsync(symbolId, temporaryDocument, cancellationToken).ConfigureAwait(false); + + // Update the workspace to pull the text from the document. + var newLoader = new WorkspaceFileTextLoader(temporaryDocument.Project.Solution.Services, fileInfo.TemporaryFilePath, MetadataAsSourceGeneratedFileInfo.Encoding); + metadataWorkspace.OnDocumentTextLoaderChanged(temporaryDocumentId, newLoader); + _generatedFilenameToInformation.Add(fileInfo.TemporaryFilePath, (fileInfo, temporaryDocument.Id)); + generatedDocumentId = temporaryDocument.Id; } + else + { + // The file already exists in the workspace, so we can just use that. + generatedDocumentId = existingDocumentId.DocumentId; + var document = metadataWorkspace.CurrentSolution.GetRequiredDocument(generatedDocumentId); + navigateLocation = await MetadataAsSourceHelpers.GetLocationInGeneratedSourceAsync(symbolId, document, cancellationToken).ConfigureAwait(false); - // If we don't have a location yet, then that means we're re-using an existing file. In this case, we'll want to relocate the symbol. - navigateLocation ??= await RelocateSymbol_NoLockAsync(metadataSolution, fileInfo, symbolId, cancellationToken).ConfigureAwait(false); + } var documentName = string.Format( "{0} [{1}]", @@ -249,30 +252,11 @@ internal sealed class DecompilationMetadataAsSourceFileProvider(IImplementationA return (metadataReference, assemblyLocation, isReferenceAssembly); } - private async Task RelocateSymbol_NoLockAsync(Solution solution, MetadataAsSourceGeneratedFileInfo fileInfo, SymbolKey symbolId, CancellationToken cancellationToken) - { - // We need to relocate the symbol in the already existing file. If the file is open, we can just - // reuse that workspace. Otherwise, we have to go spin up a temporary project to do the binding. - if (_openedDocumentIds.TryGetValue(fileInfo, out var openDocumentId)) - { - // Awesome, it's already open. Let's try to grab a document for it - var document = solution.GetRequiredDocument(openDocumentId); - - return await MetadataAsSourceHelpers.GetLocationInGeneratedSourceAsync(symbolId, document, cancellationToken).ConfigureAwait(false); - } - - // Annoying case: the file is still on disk. Only real option here is to spin up a fake project to go and bind in. - var (temporaryProjectInfo, temporaryDocumentId) = fileInfo.GetProjectInfoAndDocumentId(solution.Services, loadFileFromDisk: true); - var temporaryDocument = solution.AddProject(temporaryProjectInfo).GetRequiredDocument(temporaryDocumentId); - - return await MetadataAsSourceHelpers.GetLocationInGeneratedSourceAsync(symbolId, temporaryDocument, cancellationToken).ConfigureAwait(false); - } - public bool ShouldCollapseOnOpen(MetadataAsSourceWorkspace workspace, string filePath, BlockStructureOptions blockStructureOptions) { if (_generatedFilenameToInformation.TryGetValue(filePath, out var info)) { - return info.SignaturesOnly + return info.Metadata.SignaturesOnly ? blockStructureOptions.CollapseEmptyMetadataImplementationsWhenFirstOpened : blockStructureOptions.CollapseMetadataImplementationsWhenFirstOpened; } @@ -280,87 +264,112 @@ public bool ShouldCollapseOnOpen(MetadataAsSourceWorkspace workspace, string fil return false; } - public bool TryAddDocumentToWorkspace(MetadataAsSourceWorkspace workspace, string filePath, SourceTextContainer sourceTextContainer, [NotNullWhen(true)] out DocumentId? documentId) + private bool RemoveDocumentFromWorkspace(MetadataAsSourceWorkspace workspace, MetadataAsSourceGeneratedFileInfo fileInfo) { - lock (_gate) + // Serial access is guaranteed by the caller. + if (_generatedFilenameToInformation.TryRemove(fileInfo.TemporaryFilePath, out var documentIdInfo)) { - if (_generatedFilenameToInformation.TryGetValue(filePath, out var fileInfo)) - { - Contract.ThrowIfTrue(_openedDocumentIds.ContainsKey(fileInfo)); - - // We do own the file, so let's open it up in our workspace - (var projectInfo, documentId) = fileInfo.GetProjectInfoAndDocumentId(workspace.Services.SolutionServices, loadFileFromDisk: true); + workspace.OnDocumentClosed(documentIdInfo.DocumentId, new WorkspaceFileTextLoader(workspace.Services.SolutionServices, fileInfo.TemporaryFilePath, MetadataAsSourceGeneratedFileInfo.Encoding)); + workspace.OnProjectRemoved(documentIdInfo.DocumentId.ProjectId); - workspace.OnProjectAdded(projectInfo); - workspace.OnDocumentOpened(documentId, sourceTextContainer); - - _openedDocumentIds = _openedDocumentIds.Add(fileInfo, documentId); - return true; - } - - documentId = null; - return false; + return true; } - } - - public bool TryRemoveDocumentFromWorkspace(MetadataAsSourceWorkspace workspace, string filePath) - { - lock (_gate) - { - if (_generatedFilenameToInformation.TryGetValue(filePath, out var fileInfo)) - { - if (_openedDocumentIds.ContainsKey(fileInfo)) - return RemoveDocumentFromWorkspace_NoLock(workspace, fileInfo); - } - return false; - } - } - - private bool RemoveDocumentFromWorkspace_NoLock(MetadataAsSourceWorkspace workspace, MetadataAsSourceGeneratedFileInfo fileInfo) - { - // Serial access is guaranteed by the caller. - var documentId = _openedDocumentIds.GetValueOrDefault(fileInfo); - Contract.ThrowIfNull(documentId); - - workspace.OnDocumentClosed(documentId, new WorkspaceFileTextLoader(workspace.Services.SolutionServices, fileInfo.TemporaryFilePath, MetadataAsSourceGeneratedFileInfo.Encoding)); - workspace.OnProjectRemoved(documentId.ProjectId); - - _openedDocumentIds = _openedDocumentIds.RemoveKey(fileInfo); - - return true; + return false; } public Project? MapDocument(Document document) { MetadataAsSourceGeneratedFileInfo? fileInfo; - if (!_openedDocumentIds.TryGetKey(document.Id, out fileInfo)) + if (document.FilePath is not null && _generatedFilenameToInformation.TryGetValue(document.FilePath, out var documentIdInfo)) + { + fileInfo = documentIdInfo.Metadata; + var solution = fileInfo.Workspace.CurrentSolution; + var project = solution.GetProject(fileInfo.SourceProjectId); + return project; + } + else { + // If we don't have the file in our cache, then we can't map it. return null; } - - // WARNING: do not touch any state fields outside the lock. - var solution = fileInfo.Workspace.CurrentSolution; - var project = solution.GetProject(fileInfo.SourceProjectId); - return project; } public void CleanupGeneratedFiles(MetadataAsSourceWorkspace workspace) { - lock (_gate) + // Clone the list so we don't break our own enumeration + foreach (var generatedFileInfo in _generatedFilenameToInformation.Values.ToList()) { - // Clone the list so we don't break our own enumeration - foreach (var generatedFileInfo in _generatedFilenameToInformation.Values.ToList()) - { - if (_openedDocumentIds.ContainsKey(generatedFileInfo)) - RemoveDocumentFromWorkspace_NoLock(workspace, generatedFileInfo); - } + RemoveDocumentFromWorkspace(workspace, generatedFileInfo.Metadata); - _generatedFilenameToInformation.Clear(); - _keyToInformation.Clear(); - Contract.ThrowIfFalse(_openedDocumentIds.IsEmpty); } + + _generatedFilenameToInformation.Clear(); + _keyToInformation.Clear(); + } + + private static (ProjectInfo, DocumentId) GenerateProjectAndDocumentInfo( + MetadataAsSourceGeneratedFileInfo fileInfo, + SolutionServices services, + Project sourceProject, + INamedTypeSymbol topLevelNamedType) + { + var projectId = ProjectId.CreateNewId(); + + var parseOptions = sourceProject.Language == fileInfo.LanguageName + ? sourceProject.ParseOptions + : sourceProject.Solution.Services.GetLanguageServices(fileInfo.LanguageName).GetRequiredService().GetDefaultParseOptionsWithLatestLanguageVersion(); + + var assemblyIdentity = topLevelNamedType.ContainingAssembly.Identity; + + // Just say it's always a DLL since we probably won't have a Main method + var compilationOptions = services.GetRequiredLanguageService(fileInfo.LanguageName).GetDefaultCompilationOptions().WithOutputKind(OutputKind.DynamicallyLinkedLibrary); + + // We need to include the version information of the assembly so InternalsVisibleTo and stuff works + var assemblyInfoDocumentId = DocumentId.CreateNewId(projectId); + var assemblyInfoFileName = "AssemblyInfo" + fileInfo.Extension; + var assemblyInfoString = fileInfo.LanguageName == LanguageNames.CSharp + ? string.Format(@"[assembly: System.Reflection.AssemblyVersion(""{0}"")]", assemblyIdentity.Version) + : string.Format(@"", assemblyIdentity.Version); + + var assemblyInfoSourceText = SourceText.From(assemblyInfoString, MetadataAsSourceGeneratedFileInfo.Encoding, MetadataAsSourceGeneratedFileInfo.ChecksumAlgorithm); + + var assemblyInfoDocument = DocumentInfo.Create( + assemblyInfoDocumentId, + assemblyInfoFileName, + loader: TextLoader.From(assemblyInfoSourceText.Container, VersionStamp.Default), + filePath: null, + isGenerated: true) + .WithDesignTimeOnly(true); + + var emptySourceText = SourceText.From(string.Empty, MetadataAsSourceGeneratedFileInfo.Encoding, MetadataAsSourceGeneratedFileInfo.ChecksumAlgorithm); + var generatedDocumentId = DocumentId.CreateNewId(projectId); + var generatedDocument = DocumentInfo.Create( + generatedDocumentId, + Path.GetFileName(fileInfo.TemporaryFilePath), + // We'll update the loader later when we actually write the file to disk. + loader: TextLoader.From(emptySourceText.Container, VersionStamp.Default), + filePath: fileInfo.TemporaryFilePath, + isGenerated: true) + .WithDesignTimeOnly(true); + + var projectInfo = ProjectInfo.Create( + new ProjectInfo.ProjectAttributes( + id: projectId, + version: VersionStamp.Default, + name: assemblyIdentity.Name, + assemblyName: assemblyIdentity.Name, + language: fileInfo.LanguageName, + compilationOutputInfo: default, + checksumAlgorithm: MetadataAsSourceGeneratedFileInfo.ChecksumAlgorithm), + compilationOptions: compilationOptions, + parseOptions: parseOptions, + documents: [assemblyInfoDocument, generatedDocument], + metadataReferences: [.. sourceProject.MetadataReferences]); + + return (projectInfo, generatedDocumentId); + } private static async Task GetUniqueDocumentKeyAsync(Project project, INamedTypeSymbol topLevelNamedType, bool signaturesOnly, CancellationToken cancellationToken) diff --git a/src/Features/Core/Portable/MetadataAsSource/IMetadataAsSourceFileProvider.cs b/src/Features/Core/Portable/MetadataAsSource/IMetadataAsSourceFileProvider.cs index 693b09389f449..835b091825884 100644 --- a/src/Features/Core/Portable/MetadataAsSource/IMetadataAsSourceFileProvider.cs +++ b/src/Features/Core/Portable/MetadataAsSource/IMetadataAsSourceFileProvider.cs @@ -32,18 +32,6 @@ internal interface IMetadataAsSourceFileProvider /// void CleanupGeneratedFiles(MetadataAsSourceWorkspace workspace); - /// - /// Called when the file returned from needs to be added to the workspace, - /// to be opened. Will be called on the main thread of the workspace host. - /// - bool TryAddDocumentToWorkspace(MetadataAsSourceWorkspace workspace, string filePath, SourceTextContainer sourceTextContainer, [NotNullWhen(true)] out DocumentId? documentId); - - /// - /// Called when the file is being closed, and so needs to be removed from the workspace. Will be called on the - /// main thread of the workspace host. - /// - bool TryRemoveDocumentFromWorkspace(MetadataAsSourceWorkspace workspace, string filePath); - /// /// Called to determine if the file should be collapsed by default when opened for the first time. Will be /// called on the main thread of the workspace host. diff --git a/src/Features/Core/Portable/MetadataAsSource/MetadataAsSourceFileService.cs b/src/Features/Core/Portable/MetadataAsSource/MetadataAsSourceFileService.cs index e21821dd8852a..a97a8cb34e726 100644 --- a/src/Features/Core/Portable/MetadataAsSource/MetadataAsSourceFileService.cs +++ b/src/Features/Core/Portable/MetadataAsSource/MetadataAsSourceFileService.cs @@ -165,47 +165,56 @@ private static void AssertIsMainThread(MetadataAsSourceWorkspace workspace) public bool TryAddDocumentToWorkspace(string filePath, SourceTextContainer sourceTextContainer, [NotNullWhen(true)] out DocumentId? documentId) { - // If we haven't even created a MetadataAsSource workspace yet, then this file definitely cannot be added to - // it. This happens when the MiscWorkspace calls in to just see if it can attach this document to the - // MetadataAsSource instead of itself. var workspace = _workspace; - if (workspace != null) + if (workspace is null) { - foreach (var provider in _providers.Value) - { - if (!provider.IsValueCreated) - continue; + // If we haven't even created a MetadataAsSource workspace yet, then this file definitely cannot be added to + // it. This happens when the MiscWorkspace calls in to just see if it can attach this document to the + // MetadataAsSource instead of itself. + documentId = null; + return false; + } - if (provider.Value.TryAddDocumentToWorkspace(workspace, filePath, sourceTextContainer, out documentId)) - { - return true; - } - } + // There are no linked files in the MetadataAsSource workspace, so we can just use the first document id + documentId = workspace.CurrentSolution.GetDocumentIdsWithFilePath(filePath).SingleOrDefault(); + if (documentId is null) + { + return false; } - documentId = null; - return false; + workspace.OnDocumentOpened(documentId, sourceTextContainer); + return true; } public bool TryRemoveDocumentFromWorkspace(string filePath) { - // If we haven't even created a MetadataAsSource workspace yet, then this file definitely cannot be removed - // from it. This happens when the MiscWorkspace is hearing about a doc closing, and calls into the - // MetadataAsSource system to see if it owns the file and should handle that event. var workspace = _workspace; - if (workspace != null) + if (workspace is null) { - foreach (var provider in _providers.Value) - { - if (!provider.IsValueCreated) - continue; + // If we haven't even created a MetadataAsSource workspace yet, then this file definitely cannot be removed + // from it. This happens when the MiscWorkspace is hearing about a doc closing, and calls into the + // MetadataAsSource system to see if it owns the file and should handle that event. + return false; + } - if (provider.Value.TryRemoveDocumentFromWorkspace(workspace, filePath)) - return true; - } + // There are no linked files in the MetadataAsSource workspace, so we can just use the first document id + var documentId = workspace.CurrentSolution.GetDocumentIdsWithFilePath(filePath).FirstOrDefault(); + if (documentId is null) + { + return false; } - return false; + // In LSP, while calls to TryAddDocumentToWorkspace and TryRemoveDocumentFromWorkspace are handled + // serially, it is possible that TryRemoveDocumentFromWorkspace called without TryAddDocumentToWorkspace first. + // This can happen if the document is immediately closed after opening - only feature requests that force us + // to materialize a solution will trigger TryAddDocumentToWorkspace, if none are made it is never called. + // However TryRemoveDocumentFromWorkspace is always called on close. + if (workspace.GetOpenDocumentIds().Contains(documentId)) + { + workspace.OnDocumentClosed(documentId, new WorkspaceFileTextLoader(workspace.Services.SolutionServices, filePath, defaultEncoding: null)); + } + + return true; } public bool ShouldCollapseOnOpen(string? filePath, BlockStructureOptions blockStructureOptions) diff --git a/src/Features/Core/Portable/MetadataAsSource/MetadataAsSourceGeneratedFileInfo.cs b/src/Features/Core/Portable/MetadataAsSource/MetadataAsSourceGeneratedFileInfo.cs index c21d1e8466bb4..0a7496375513d 100644 --- a/src/Features/Core/Portable/MetadataAsSource/MetadataAsSourceGeneratedFileInfo.cs +++ b/src/Features/Core/Portable/MetadataAsSource/MetadataAsSourceGeneratedFileInfo.cs @@ -3,27 +3,23 @@ // See the LICENSE file in the project root for more information. using System; -using System.Collections.Immutable; using System.IO; using System.Text; -using Microsoft.CodeAnalysis.Host; using Microsoft.CodeAnalysis.Text; namespace Microsoft.CodeAnalysis.MetadataAsSource; internal sealed class MetadataAsSourceGeneratedFileInfo { - public readonly ProjectId SourceProjectId; - public readonly Workspace Workspace; + public string TemporaryFilePath { get; } + public Workspace Workspace { get; } + public ProjectId SourceProjectId { get; } + public bool SignaturesOnly { get; } + public string LanguageName { get; } + public string Extension { get; } - public readonly AssemblyIdentity AssemblyIdentity; - public readonly string LanguageName; - public readonly bool SignaturesOnly; - public readonly ImmutableArray References; - - public readonly string TemporaryFilePath; - - private readonly ParseOptions? _parseOptions; + public static Encoding Encoding => Encoding.UTF8; + public static SourceHashAlgorithm ChecksumAlgorithm => SourceHashAlgorithms.Default; public MetadataAsSourceGeneratedFileInfo(string rootPath, Workspace sourceWorkspace, Project sourceProject, INamedTypeSymbol topLevelNamedType, bool signaturesOnly) { @@ -32,78 +28,9 @@ public MetadataAsSourceGeneratedFileInfo(string rootPath, Workspace sourceWorksp this.LanguageName = signaturesOnly ? sourceProject.Language : LanguageNames.CSharp; this.SignaturesOnly = signaturesOnly; - _parseOptions = sourceProject.Language == LanguageName - ? sourceProject.ParseOptions - : sourceProject.Solution.Services.GetLanguageServices(LanguageName).GetRequiredService().GetDefaultParseOptionsWithLatestLanguageVersion(); - - this.References = [.. sourceProject.MetadataReferences]; - this.AssemblyIdentity = topLevelNamedType.ContainingAssembly.Identity; - - var extension = LanguageName == LanguageNames.CSharp ? ".cs" : ".vb"; + this.Extension = LanguageName == LanguageNames.CSharp ? ".cs" : ".vb"; var directoryName = Guid.NewGuid().ToString("N"); - this.TemporaryFilePath = Path.Combine(rootPath, directoryName, topLevelNamedType.Name + extension); - } - - public static Encoding Encoding => Encoding.UTF8; - public static SourceHashAlgorithm ChecksumAlgorithm => SourceHashAlgorithms.Default; - - /// - /// Creates a ProjectInfo to represent the fake project created for metadata as source documents. - /// - /// Solution services. - /// Whether the source file already exists on disk and should be included. If - /// this is a false, a document is still created, but it's not backed by the file system and thus we won't - /// try to load it. - public (ProjectInfo, DocumentId) GetProjectInfoAndDocumentId(SolutionServices services, bool loadFileFromDisk) - { - var projectId = ProjectId.CreateNewId(); - - // Just say it's always a DLL since we probably won't have a Main method - var compilationOptions = services.GetRequiredLanguageService(LanguageName).GetDefaultCompilationOptions().WithOutputKind(OutputKind.DynamicallyLinkedLibrary); - - var extension = LanguageName == LanguageNames.CSharp ? ".cs" : ".vb"; - - // We need to include the version information of the assembly so InternalsVisibleTo and stuff works - var assemblyInfoDocumentId = DocumentId.CreateNewId(projectId); - var assemblyInfoFileName = "AssemblyInfo" + extension; - var assemblyInfoString = LanguageName == LanguageNames.CSharp - ? string.Format(@"[assembly: System.Reflection.AssemblyVersion(""{0}"")]", AssemblyIdentity.Version) - : string.Format(@"", AssemblyIdentity.Version); - - var assemblyInfoSourceText = SourceText.From(assemblyInfoString, Encoding, ChecksumAlgorithm); - - var assemblyInfoDocument = DocumentInfo.Create( - assemblyInfoDocumentId, - assemblyInfoFileName, - loader: TextLoader.From(assemblyInfoSourceText.Container, VersionStamp.Default), - filePath: null, - isGenerated: true) - .WithDesignTimeOnly(true); - - var generatedDocumentId = DocumentId.CreateNewId(projectId); - var generatedDocument = DocumentInfo.Create( - generatedDocumentId, - Path.GetFileName(TemporaryFilePath), - loader: loadFileFromDisk ? new WorkspaceFileTextLoader(services, TemporaryFilePath, Encoding) : null, - filePath: TemporaryFilePath, - isGenerated: true) - .WithDesignTimeOnly(true); - - var projectInfo = ProjectInfo.Create( - new ProjectInfo.ProjectAttributes( - id: projectId, - version: VersionStamp.Default, - name: AssemblyIdentity.Name, - assemblyName: AssemblyIdentity.Name, - language: LanguageName, - compilationOutputInfo: default, - checksumAlgorithm: ChecksumAlgorithm), - compilationOptions: compilationOptions, - parseOptions: _parseOptions, - documents: [assemblyInfoDocument, generatedDocument], - metadataReferences: References); - - return (projectInfo, generatedDocumentId); + this.TemporaryFilePath = Path.Combine(rootPath, directoryName, topLevelNamedType.Name + Extension); } } diff --git a/src/Features/Core/Portable/PdbSourceDocument/PdbSourceDocumentMetadataAsSourceFileProvider.cs b/src/Features/Core/Portable/PdbSourceDocument/PdbSourceDocumentMetadataAsSourceFileProvider.cs index 002bc0cc4014c..18b7a102e2035 100644 --- a/src/Features/Core/Portable/PdbSourceDocument/PdbSourceDocumentMetadataAsSourceFileProvider.cs +++ b/src/Features/Core/Portable/PdbSourceDocument/PdbSourceDocumentMetadataAsSourceFileProvider.cs @@ -44,11 +44,6 @@ internal sealed class PdbSourceDocumentMetadataAsSourceFileProvider( private readonly IImplementationAssemblyLookupService _implementationAssemblyLookupService = implementationAssemblyLookupService; private readonly IPdbSourceDocumentLogger? _logger = logger; - /// - /// Lock to guard access to workspace updates when opening / closing documents. - /// - private readonly object _gate = new(); - /// /// Accessed only in and , both of which /// are called under a lock in . So this is safe as a plain @@ -70,11 +65,6 @@ internal sealed class PdbSourceDocumentMetadataAsSourceFileProvider( /// private readonly ConcurrentDictionary _fileToDocumentInfoMap = new(StringComparer.OrdinalIgnoreCase); - /// - /// Only accessed and mutated in serial calls either from the UI thread or LSP queue. - /// - private readonly HashSet _openedDocumentIds = []; - public async Task GetGeneratedFileAsync( MetadataAsSourceWorkspace metadataWorkspace, Workspace sourceWorkspace, @@ -262,24 +252,22 @@ internal sealed class PdbSourceDocumentMetadataAsSourceFileProvider( var symbolId = SymbolKey.Create(symbol, cancellationToken); - // Get a view of the solution with the document added, but do not actually update the workspace. - // TryAddDocumentToWorkspace is responsible for actually updating the solution with the new document(s). - // We just need a view with the document added so we can find the right location in the generated source. - var pendingSolution = metadataWorkspace.CurrentSolution; + var navigateProject = metadataWorkspace.CurrentSolution.GetRequiredProject(projectId); var documentInfos = CreateDocumentInfos(sourceFileInfos, encoding, projectId, sourceWorkspace, sourceProject); if (documentInfos.Length > 0) { foreach (var documentInfo in documentInfos) { // The document might have already been added by a previous go to definition call. - if (!pendingSolution.ContainsDocument(documentInfo.Id)) + if (!metadataWorkspace.CurrentSolution.ContainsDocument(documentInfo.Id)) { - pendingSolution = pendingSolution.AddDocument(documentInfo); + metadataWorkspace.OnDocumentAdded(documentInfo); } } - } - var navigateProject = pendingSolution.GetRequiredProject(projectId); + // Get a new view of the project with the documents added. + navigateProject = metadataWorkspace.CurrentSolution.GetRequiredProject(projectId); + } // If MetadataAsSourceHelpers.GetLocationInGeneratedSourceAsync can't find the actual document to navigate to, it will fall back // to the document passed in, which we just use the first document for. @@ -383,50 +371,6 @@ public bool ShouldCollapseOnOpen(MetadataAsSourceWorkspace workspace, string fil return _fileToDocumentInfoMap.TryGetValue(filePath, out _) && blockStructureOptions.CollapseMetadataImplementationsWhenFirstOpened; } - public bool TryAddDocumentToWorkspace(MetadataAsSourceWorkspace workspace, string filePath, SourceTextContainer sourceTextContainer, [NotNullWhen(true)] out DocumentId? documentId) - { - lock (_gate) - { - if (_fileToDocumentInfoMap.TryGetValue(filePath, out var info)) - { - Contract.ThrowIfTrue(_openedDocumentIds.Contains(info.DocumentId)); - - workspace.OnDocumentAdded(info.DocumentInfo); - workspace.OnDocumentOpened(info.DocumentId, sourceTextContainer); - documentId = info.DocumentId; - _openedDocumentIds.Add(documentId); - return true; - } - - documentId = null; - return false; - } - } - - public bool TryRemoveDocumentFromWorkspace(MetadataAsSourceWorkspace workspace, string filePath) - { - lock (_gate) - { - if (_fileToDocumentInfoMap.TryGetValue(filePath, out var info)) - { - // In LSP, while calls to TryAddDocumentToWorkspace and TryRemoveDocumentFromWorkspace are handled - // serially, it is possible that TryRemoveDocumentFromWorkspace called without TryAddDocumentToWorkspace first. - // This can happen if the document is immediately closed after opening - only feature requests that force us - // to materialize a solution will trigger TryAddDocumentToWorkspace, if none are made it is never called. - // However TryRemoveDocumentFromWorkspace is always called on close. - if (_openedDocumentIds.Contains(info.DocumentId)) - { - workspace.OnDocumentClosed(info.DocumentId, new WorkspaceFileTextLoader(workspace.Services.SolutionServices, filePath, info.Encoding)); - workspace.OnDocumentRemoved(info.DocumentId); - _openedDocumentIds.Remove(info.DocumentId); - return true; - } - } - - return false; - } - } - public Project? MapDocument(Document document) { if (document.FilePath is not null && @@ -462,7 +406,6 @@ public void CleanupGeneratedFiles(MetadataAsSourceWorkspace workspace) // The MetadataAsSourceFileService will clean up the entire temp folder so no need to do anything here _fileToDocumentInfoMap.Clear(); - _openedDocumentIds.Clear(); _sourceLinkEnabledProjects.Clear(); _implementationAssemblyLookupService.Clear(); } diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs index 1d284207fb741..4d7d109660228 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs @@ -2,26 +2,18 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System.Collections.Immutable; -using System.Security; -using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Features.Workspaces; -using Microsoft.CodeAnalysis.Host; using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace; using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.ProjectTelemetry; -using Microsoft.CodeAnalysis.MetadataAsSource; using Microsoft.CodeAnalysis.MSBuild; using Microsoft.CodeAnalysis.Options; using Microsoft.CodeAnalysis.ProjectSystem; using Microsoft.CodeAnalysis.Shared.Extensions; using Microsoft.CodeAnalysis.Shared.TestHooks; using Microsoft.CodeAnalysis.Text; -using Microsoft.CodeAnalysis.Workspaces.ProjectSystem; using Microsoft.CommonLanguageServerProtocol.Framework; using Microsoft.Extensions.Logging; -using Microsoft.VisualStudio.Composition; using Roslyn.LanguageServer.Protocol; -using Roslyn.Utilities; using static Microsoft.CodeAnalysis.MSBuild.BuildHostProcessManager; namespace Microsoft.CodeAnalysis.LanguageServer.FileBasedPrograms; @@ -31,12 +23,10 @@ internal sealed class FileBasedProgramsProjectSystem : LanguageServerProjectLoad { private readonly ILspServices _lspServices; private readonly ILogger _logger; - private readonly IMetadataAsSourceFileService _metadataAsSourceFileService; private readonly VirtualProjectXmlProvider _projectXmlProvider; public FileBasedProgramsProjectSystem( ILspServices lspServices, - IMetadataAsSourceFileService metadataAsSourceFileService, VirtualProjectXmlProvider projectXmlProvider, LanguageServerWorkspaceFactory workspaceFactory, IFileChangeWatcher fileChangeWatcher, @@ -60,7 +50,6 @@ public FileBasedProgramsProjectSystem( { _lspServices = lspServices; _logger = loggerFactory.CreateLogger(); - _metadataAsSourceFileService = metadataAsSourceFileService; _projectXmlProvider = projectXmlProvider; } @@ -72,14 +61,6 @@ public FileBasedProgramsProjectSystem( { var documentFilePath = GetDocumentFilePath(uri); - // https://github.com/dotnet/roslyn/issues/78421: MetadataAsSource should be its own workspace - if (_metadataAsSourceFileService.TryAddDocumentToWorkspace(documentFilePath, documentText.Container, out var documentId)) - { - var metadataWorkspace = _metadataAsSourceFileService.TryGetWorkspace(); - Contract.ThrowIfNull(metadataWorkspace); - return metadataWorkspace.CurrentSolution.GetRequiredDocument(documentId); - } - var primordialDoc = AddPrimordialDocument(uri, documentText, languageId); Contract.ThrowIfNull(primordialDoc.FilePath); @@ -118,14 +99,9 @@ TextDocument AddPrimordialDocument(DocumentUri uri, SourceText documentText, str } } - public async ValueTask TryRemoveMiscellaneousDocumentAsync(DocumentUri uri, bool removeFromMetadataWorkspace) + public async ValueTask TryRemoveMiscellaneousDocumentAsync(DocumentUri uri) { var documentPath = GetDocumentFilePath(uri); - if (removeFromMetadataWorkspace && _metadataAsSourceFileService.TryRemoveDocumentFromWorkspace(documentPath)) - { - return; - } - await UnloadProjectAsync(documentPath); } diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsWorkspaceProviderFactory.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsWorkspaceProviderFactory.cs index 3754d38086a01..56dc2a9a3b657 100644 --- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsWorkspaceProviderFactory.cs +++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsWorkspaceProviderFactory.cs @@ -8,7 +8,6 @@ using Microsoft.CodeAnalysis.LanguageServer.Handler; using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace; using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.ProjectTelemetry; -using Microsoft.CodeAnalysis.MetadataAsSource; using Microsoft.CodeAnalysis.MSBuild; using Microsoft.CodeAnalysis.Options; using Microsoft.CodeAnalysis.ProjectSystem; @@ -19,7 +18,7 @@ namespace Microsoft.CodeAnalysis.LanguageServer.FileBasedPrograms; /// -/// Service to create instances. +/// Service to create instances. /// This is not exported as a as it requires /// special base language server dependencies such as the /// @@ -27,7 +26,6 @@ namespace Microsoft.CodeAnalysis.LanguageServer.FileBasedPrograms; [method: ImportingConstructor] [method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] internal sealed class FileBasedProgramsWorkspaceProviderFactory( - IMetadataAsSourceFileService metadataAsSourceFileService, VirtualProjectXmlProvider projectXmlProvider, LanguageServerWorkspaceFactory workspaceFactory, IFileChangeWatcher fileChangeWatcher, @@ -42,7 +40,6 @@ public ILspMiscellaneousFilesWorkspaceProvider CreateLspMiscellaneousFilesWorksp { return new FileBasedProgramsProjectSystem( lspServices, - metadataAsSourceFileService, projectXmlProvider, workspaceFactory, fileChangeWatcher, diff --git a/src/LanguageServer/Protocol.TestUtilities/LanguageServer/AbstractLanguageServerProtocolTests.cs b/src/LanguageServer/Protocol.TestUtilities/LanguageServer/AbstractLanguageServerProtocolTests.cs index 79aa22b3d3479..456cefe719da0 100644 --- a/src/LanguageServer/Protocol.TestUtilities/LanguageServer/AbstractLanguageServerProtocolTests.cs +++ b/src/LanguageServer/Protocol.TestUtilities/LanguageServer/AbstractLanguageServerProtocolTests.cs @@ -22,6 +22,7 @@ using Microsoft.CodeAnalysis.LanguageServer.Handler; using Microsoft.CodeAnalysis.LanguageServer.Handler.CodeActions; using Microsoft.CodeAnalysis.LanguageServer.Handler.Completion; +using Microsoft.CodeAnalysis.MetadataAsSource; using Microsoft.CodeAnalysis.Options; using Microsoft.CodeAnalysis.Shared.Extensions; using Microsoft.CodeAnalysis.Shared.TestHooks; @@ -327,7 +328,7 @@ private protected Task CreateTestLspServerAsync( return CreateTestLspServerAsync(workspace, lspOptions, languageName); } - private async Task CreateTestLspServerAsync(LspTestWorkspace workspace, InitializationOptions initializationOptions, string languageName) + private protected async Task CreateTestLspServerAsync(LspTestWorkspace workspace, InitializationOptions initializationOptions, string languageName) { var solution = workspace.CurrentSolution; @@ -364,7 +365,11 @@ internal LspTestWorkspace CreateWorkspace( composition ?? Composition, workspaceKind, configurationOptions: new WorkspaceConfigurationOptions(ValidateCompilationTrackerStates: true), supportsLspMutation: mutatingLspWorkspace); options?.OptionUpdater?.Invoke(workspace.GetService()); - workspace.GetService().Register(workspace); + // By default, workspace event listeners are disabled in tests. Explicitly enable the LSP workspace registration event listener + // to ensure that the lsp workspace registration service sees all workspaces. + var lspWorkspaceRegistrationListener = (LspWorkspaceRegistrationEventListener)workspace.ExportProvider.GetExports().Single(e => e.Value is LspWorkspaceRegistrationEventListener).Value; + var listenerProvider = workspace.GetService(); + listenerProvider.EventListeners = [lspWorkspaceRegistrationListener]; return workspace; } @@ -607,10 +612,6 @@ internal async Task InitializeAsync() // Initialize the language server _ = _languageServer.Value; - // Workspace listener events do not run in tests, so we manually register the lsp misc workspace. - // This must be done after the language server is created in order to access the misc workspace off of the LSP workspace manager. - TestWorkspace.GetService().Register(GetManagerAccessor().GetLspMiscellaneousFilesWorkspace()); - if (_initializationOptions.CallInitialize) { _initializeResult = await this.ExecuteRequestAsync(LSP.Methods.InitializeName, new LSP.InitializeParams @@ -840,9 +841,6 @@ internal async ValueTask RunCodeAnalysisAsync(ProjectId? projectId) public async ValueTask DisposeAsync() { - TestWorkspace.GetService().Deregister(TestWorkspace); - TestWorkspace.GetService().Deregister(GetManagerAccessor().GetLspMiscellaneousFilesWorkspace()); - // Some tests will manually call shutdown and exit, so attempting to call this during dispose // will fail as the server's jsonrpc instance will be disposed of. if (!_languageServer.Value.GetTestAccessor().HasShutdownStarted()) diff --git a/src/LanguageServer/Protocol/Workspaces/ILspMiscellaneousFilesWorkspaceProvider.cs b/src/LanguageServer/Protocol/Workspaces/ILspMiscellaneousFilesWorkspaceProvider.cs index 6635fd32f127c..d35c416e7ba33 100644 --- a/src/LanguageServer/Protocol/Workspaces/ILspMiscellaneousFilesWorkspaceProvider.cs +++ b/src/LanguageServer/Protocol/Workspaces/ILspMiscellaneousFilesWorkspaceProvider.cs @@ -29,5 +29,5 @@ internal interface ILspMiscellaneousFilesWorkspaceProvider : ILspService /// Note that the implementation of this method should not depend on anything expensive such as RPC calls. /// async is used here to allow taking locks asynchronously and "relatively fast" stuff like that. /// - ValueTask TryRemoveMiscellaneousDocumentAsync(DocumentUri uri, bool removeFromMetadataWorkspace); + ValueTask TryRemoveMiscellaneousDocumentAsync(DocumentUri uri); } diff --git a/src/LanguageServer/Protocol/Workspaces/LspMiscellaneousFilesWorkspaceProvider.cs b/src/LanguageServer/Protocol/Workspaces/LspMiscellaneousFilesWorkspaceProvider.cs index d8ead3e1210c1..29084956d6805 100644 --- a/src/LanguageServer/Protocol/Workspaces/LspMiscellaneousFilesWorkspaceProvider.cs +++ b/src/LanguageServer/Protocol/Workspaces/LspMiscellaneousFilesWorkspaceProvider.cs @@ -8,7 +8,6 @@ using System.Threading.Tasks; using Microsoft.CodeAnalysis.Features.Workspaces; using Microsoft.CodeAnalysis.Host; -using Microsoft.CodeAnalysis.MetadataAsSource; using Microsoft.CodeAnalysis.Shared.Extensions; using Microsoft.CodeAnalysis.Text; using Microsoft.CommonLanguageServerProtocol.Framework; @@ -26,7 +25,7 @@ namespace Microsoft.CodeAnalysis.LanguageServer; /// Future work for this workspace includes supporting basic metadata references (mscorlib, System dlls, etc), /// but that is dependent on having a x-plat mechanism for retrieving those references from the framework / sdk. /// -internal sealed class LspMiscellaneousFilesWorkspaceProvider(ILspServices lspServices, IMetadataAsSourceFileService metadataAsSourceFileService, HostServices hostServices) +internal sealed class LspMiscellaneousFilesWorkspaceProvider(ILspServices lspServices, HostServices hostServices) : Workspace(hostServices, WorkspaceKind.MiscellaneousFiles), ILspMiscellaneousFilesWorkspaceProvider, ILspWorkspace { public bool SupportsMutation => true; @@ -37,7 +36,7 @@ internal sealed class LspMiscellaneousFilesWorkspaceProvider(ILspServices lspSer /// /// Takes in a file URI and text and creates a misc project and document for the file. /// - /// Calls to this method and are made + /// Calls to this method and are made /// from LSP text sync request handling which do not run concurrently. /// public ValueTask AddMiscellaneousDocumentAsync(DocumentUri uri, SourceText documentText, string languageId, ILspLogger logger) @@ -51,15 +50,6 @@ internal sealed class LspMiscellaneousFilesWorkspaceProvider(ILspServices lspSer documentFilePath = ProtocolConversions.GetDocumentFilePathFromUri(uri.ParsedUri); } - var container = new StaticSourceTextContainer(documentText); - if (metadataAsSourceFileService.TryAddDocumentToWorkspace(documentFilePath, container, out var documentId)) - { - var metadataWorkspace = metadataAsSourceFileService.TryGetWorkspace(); - Contract.ThrowIfNull(metadataWorkspace); - var document = metadataWorkspace.CurrentSolution.GetRequiredDocument(documentId); - return document; - } - var languageInfoProvider = lspServices.GetRequiredService(); if (!languageInfoProvider.TryGetLanguageInformation(uri, languageId, out var languageInformation)) { @@ -90,13 +80,8 @@ internal sealed class LspMiscellaneousFilesWorkspaceProvider(ILspServices lspSer /// Calls to this method and are made /// from LSP text sync request handling which do not run concurrently. /// - public ValueTask TryRemoveMiscellaneousDocumentAsync(DocumentUri uri, bool removeFromMetadataWorkspace) + public ValueTask TryRemoveMiscellaneousDocumentAsync(DocumentUri uri) { - if (removeFromMetadataWorkspace && uri.ParsedUri is not null && metadataAsSourceFileService.TryRemoveDocumentFromWorkspace(ProtocolConversions.GetDocumentFilePathFromUri(uri.ParsedUri))) - { - return ValueTaskFactory.CompletedTask; - } - // We'll only ever have a single document matching this URI in the misc solution. var matchingDocument = CurrentSolution.GetDocumentIds(uri).SingleOrDefault(); if (matchingDocument != null) diff --git a/src/LanguageServer/Protocol/Workspaces/LspMiscellaneousFilesWorkspaceProviderFactory.cs b/src/LanguageServer/Protocol/Workspaces/LspMiscellaneousFilesWorkspaceProviderFactory.cs index 568903cd80a81..7a5c6fe44966d 100644 --- a/src/LanguageServer/Protocol/Workspaces/LspMiscellaneousFilesWorkspaceProviderFactory.cs +++ b/src/LanguageServer/Protocol/Workspaces/LspMiscellaneousFilesWorkspaceProviderFactory.cs @@ -20,10 +20,10 @@ namespace Microsoft.CodeAnalysis.LanguageServer; [ExportCSharpVisualBasicStatelessLspService(typeof(ILspMiscellaneousFilesWorkspaceProviderFactory)), Shared] [method: ImportingConstructor] [method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] -internal sealed class LspMiscellaneousFilesWorkspaceProviderFactory(IMetadataAsSourceFileService metadataAsSourceFileService) : ILspMiscellaneousFilesWorkspaceProviderFactory +internal sealed class LspMiscellaneousFilesWorkspaceProviderFactory() : ILspMiscellaneousFilesWorkspaceProviderFactory { public ILspMiscellaneousFilesWorkspaceProvider CreateLspMiscellaneousFilesWorkspaceProvider(ILspServices lspServices, HostServices hostServices) { - return new LspMiscellaneousFilesWorkspaceProvider(lspServices, metadataAsSourceFileService, hostServices); + return new LspMiscellaneousFilesWorkspaceProvider(lspServices, hostServices); } } diff --git a/src/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs b/src/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs index 22e3c092dbbc9..34a0a73a908a3 100644 --- a/src/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs +++ b/src/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs @@ -153,12 +153,12 @@ public async ValueTask StopTrackingAsync(DocumentUri uri, CancellationToken canc // If LSP changed, we need to compare against the workspace again to get the updated solution. _cachedLspSolutions.Clear(); - // Also remove it from our loose files or metadata workspace if it is still there. + // Also remove it from our loose files if it is still there. if (_lspMiscellaneousFilesWorkspaceProvider is not null) { try { - await _lspMiscellaneousFilesWorkspaceProvider.TryRemoveMiscellaneousDocumentAsync(uri, removeFromMetadataWorkspace: true).ConfigureAwait(false); + await _lspMiscellaneousFilesWorkspaceProvider.TryRemoveMiscellaneousDocumentAsync(uri).ConfigureAwait(false); } catch (Exception ex) when (FatalError.ReportAndCatch(ex)) { @@ -268,8 +268,7 @@ public void UpdateTrackedDocument(DocumentUri uri, SourceText newSourceText) { 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); + await _lspMiscellaneousFilesWorkspaceProvider.TryRemoveMiscellaneousDocumentAsync(uri).ConfigureAwait(false); } catch (Exception ex) when (FatalError.ReportAndCatch(ex)) { diff --git a/src/LanguageServer/ProtocolUnitTests/Initialize/LocaleTests.cs b/src/LanguageServer/ProtocolUnitTests/Initialize/LocaleTests.cs index 681db0ed4b8e8..c96b532168aa4 100644 --- a/src/LanguageServer/ProtocolUnitTests/Initialize/LocaleTests.cs +++ b/src/LanguageServer/ProtocolUnitTests/Initialize/LocaleTests.cs @@ -41,10 +41,10 @@ public async Task TestUsesLspLocalePerServer(bool mutatingLspWorkspace) Locale = "ja" }); - await using var testLspServerTwo = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace, new InitializationOptions + await using var testLspServerTwo = await CreateTestLspServerAsync(testLspServerOne.TestWorkspace, new InitializationOptions { Locale = "zh" - }); + }, LanguageNames.CSharp); var resultOne = await testLspServerOne.ExecuteRequestAsync(LocaleTestHandler.MethodName, new Request(), CancellationToken.None); var resultTwo = await testLspServerTwo.ExecuteRequestAsync(LocaleTestHandler.MethodName, new Request(), CancellationToken.None); diff --git a/src/LanguageServer/ProtocolUnitTests/LanguageServerTargetTests.cs b/src/LanguageServer/ProtocolUnitTests/LanguageServerTargetTests.cs index d6bcb287fa3be..9f12a828f806a 100644 --- a/src/LanguageServer/ProtocolUnitTests/LanguageServerTargetTests.cs +++ b/src/LanguageServer/ProtocolUnitTests/LanguageServerTargetTests.cs @@ -66,7 +66,7 @@ public async Task LanguageServerCleansUpOnUnexpectedJsonRpcDisconnectAsync(bool public async Task LanguageServerHasSeparateServiceInstances(bool mutatingLspWorkspace) { await using var serverOne = await CreateTestLspServerAsync("", mutatingLspWorkspace); - await using var serverTwo = await CreateTestLspServerAsync("", mutatingLspWorkspace); + await using var serverTwo = await CreateTestLspServerAsync(serverOne.TestWorkspace, initializationOptions: default, LanguageNames.CSharp); // Get an LSP service and verify each server has its own instance per server. Assert.NotSame(serverOne.GetRequiredLspService(), serverTwo.GetRequiredLspService()); diff --git a/src/LanguageServer/ProtocolUnitTests/LspServicesTests.cs b/src/LanguageServer/ProtocolUnitTests/LspServicesTests.cs index e3d31c12adab9..ec70cac816d6e 100644 --- a/src/LanguageServer/ProtocolUnitTests/LspServicesTests.cs +++ b/src/LanguageServer/ProtocolUnitTests/LspServicesTests.cs @@ -78,7 +78,7 @@ public async Task ReturnsLspServiceForMatchingServer(bool mutatingLspWorkspace) var lspService = server.GetRequiredLspService(); Assert.True(lspService is CSharpLspService); - await using var server2 = await CreateTestLspServerAsync("", mutatingLspWorkspace, initializationOptions: new() { ServerKind = WellKnownLspServerKinds.AlwaysActiveVSLspServer }, composition); + await using var server2 = await CreateTestLspServerAsync(server.TestWorkspace, initializationOptions: new() { ServerKind = WellKnownLspServerKinds.AlwaysActiveVSLspServer }, LanguageNames.CSharp); var lspService2 = server2.GetRequiredLspService(); Assert.True(lspService2 is AlwaysActiveCSharpLspService); diff --git a/src/LanguageServer/ProtocolUnitTests/Metadata/LspMetadataAsSourceWorkspaceTests.cs b/src/LanguageServer/ProtocolUnitTests/Metadata/LspMetadataAsSourceWorkspaceTests.cs index c7df1d9fa6554..945a51e3c3504 100644 --- a/src/LanguageServer/ProtocolUnitTests/Metadata/LspMetadataAsSourceWorkspaceTests.cs +++ b/src/LanguageServer/ProtocolUnitTests/Metadata/LspMetadataAsSourceWorkspaceTests.cs @@ -51,10 +51,11 @@ void M() Assert.Equal(WorkspaceKind.MetadataAsSource, (await GetWorkspaceForDocument(testLspServer, definition.Single().DocumentUri)).Kind); AssertMiscFileWorkspaceEmpty(testLspServer); - // Close the metadata file and verify it gets removed from the metadata workspace. + // Close the metadata file - the file will still be present in MAS. await testLspServer.CloseDocumentAsync(definition.Single().DocumentUri).ConfigureAwait(false); - AssertMetadataFileWorkspaceEmpty(testLspServer); + Assert.Equal(WorkspaceKind.MetadataAsSource, (await GetWorkspaceForDocument(testLspServer, definition.Single().DocumentUri)).Kind); + AssertMiscFileWorkspaceEmpty(testLspServer); } [Theory, CombinatorialData] @@ -132,11 +133,4 @@ private static void AssertMiscFileWorkspaceEmpty(TestLspServer testLspServer) var doc = testLspServer.GetManagerAccessor().GetLspMiscellaneousFilesWorkspace()!.CurrentSolution.Projects.SingleOrDefault()?.Documents.SingleOrDefault(); Assert.Null(doc); } - - private static void AssertMetadataFileWorkspaceEmpty(TestLspServer testLspServer) - { - var provider = testLspServer.TestWorkspace.ExportProvider.GetExportedValue(); - var metadataDocument = provider.TryGetWorkspace()?.CurrentSolution.Projects.SingleOrDefault()?.Documents.SingleOrDefault(); - Assert.Null(metadataDocument); - } }