From 8d00434d934dee8a4f3b803c1e634795dd881437 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Tue, 17 Sep 2024 16:35:51 -0700 Subject: [PATCH 01/12] Enable support for an LSP client to open source generated files --- .../AbstractLanguageServerProtocolTests.cs | 29 ++++- src/Features/Lsif/Generator/Generator.cs | 21 +--- src/LanguageServer/BannedSymbols.txt | 20 ++-- .../Protocol/Extensions/Extensions.cs | 61 ++++++---- .../Extensions/ProtocolConversions.cs | 34 +----- .../Extensions/SourceGeneratedDocumentUri.cs | 112 ++++++++++++++++++ .../AbstractPullDiagnosticHandler.cs | 13 +- .../Handler/MapCode/MapCodeHandler.cs | 3 +- .../SourceGeneratedDocumentText.cs | 9 ++ .../SourceGeneratedFileGetTextHandler.cs | 48 ++++++++ .../SourceGeneratorGetTextParams.cs | 10 ++ .../AbstractSpellCheckingHandler.cs | 16 +-- .../Protocol/ILanguageInfoProvider.cs | 2 +- .../Protocol/LanguageInfoProvider.cs | 17 ++- .../LspMiscellaneousFilesWorkspace.cs | 2 +- .../Workspaces/LspWorkspaceManager.cs | 68 ++++++++--- .../Definitions/GoToDefinitionTests.cs | 32 +++++ .../Diagnostics/PullDiagnosticTests.cs | 2 +- .../DocumentChanges/DocumentChangesTests.cs | 4 +- .../Formatting/FormatDocumentOnTypeTests.cs | 4 +- .../Formatting/FormatDocumentRangeTests.cs | 4 +- .../Formatting/FormatDocumentTests.cs | 6 +- .../InlineCompletionsTests.cs | 4 +- .../OnAutoInsert/OnAutoInsertTests.cs | 4 +- .../ProtocolConversionsTests.cs | 21 ---- .../Workspaces/LspWorkspaceManagerTests.cs | 18 +++ .../SourceGeneratedDocumentTests.cs | 106 +++++++++++++++++ .../SourceGeneratedDocumentUriTests.cs | 44 +++++++ .../Definitions/GoToDefinitionHandler.cs | 27 +++-- .../AbstractPullDiagnosticHandler.cs | 2 +- .../Portable/Workspace/Solution/Solution.cs | 8 +- ...rceGeneratedDocumentsCompilationTracker.cs | 7 +- .../Workspace/Solution/SolutionState.cs | 5 +- .../Portable/Workspace/Workspace_Editor.cs | 2 +- 34 files changed, 592 insertions(+), 173 deletions(-) create mode 100644 src/LanguageServer/Protocol/Extensions/SourceGeneratedDocumentUri.cs create mode 100644 src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentText.cs create mode 100644 src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedFileGetTextHandler.cs create mode 100644 src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorGetTextParams.cs create mode 100644 src/LanguageServer/ProtocolUnitTests/Workspaces/SourceGeneratedDocumentTests.cs create mode 100644 src/LanguageServer/ProtocolUnitTests/Workspaces/SourceGeneratedDocumentUriTests.cs diff --git a/src/EditorFeatures/TestUtilities/LanguageServer/AbstractLanguageServerProtocolTests.cs b/src/EditorFeatures/TestUtilities/LanguageServer/AbstractLanguageServerProtocolTests.cs index ab040589da27e..abfd1c5fe490d 100644 --- a/src/EditorFeatures/TestUtilities/LanguageServer/AbstractLanguageServerProtocolTests.cs +++ b/src/EditorFeatures/TestUtilities/LanguageServer/AbstractLanguageServerProtocolTests.cs @@ -427,6 +427,20 @@ protected static void AddMappedDocument(Workspace workspace, string markup) workspace.TryApplyChanges(newSolution); } + protected static async Task AddGeneratorAsync(ISourceGenerator generator, EditorTestWorkspace workspace) + { + var analyzerReference = new TestGeneratorReference(generator); + + var solution = workspace.CurrentSolution + .Projects.Single() + .AddAnalyzerReference(analyzerReference) + .Solution; + + await workspace.ChangeSolutionAsync(solution); + await WaitForWorkspaceOperationsAsync(workspace); + + } + internal static async Task>> GetAnnotatedLocationsAsync(EditorTestWorkspace workspace, Solution solution) { var locations = new Dictionary>(); @@ -620,6 +634,19 @@ private static RoslynLanguageServer CreateLanguageServer(Stream inputStream, Str return languageServer; } + public async Task GetDocumentAsync(Uri uri) + { + var document = await GetCurrentSolution().GetDocumentAsync(new LSP.TextDocumentIdentifier { Uri = uri }, CancellationToken.None).ConfigureAwait(false); + Contract.ThrowIfNull(document, $"Unable to find document with {uri} in solution"); + return document; + } + + public async Task GetDocumentTextAsync(Uri uri) + { + var document = await GetDocumentAsync(uri).ConfigureAwait(false); + return await document.GetTextAsync(CancellationToken.None).ConfigureAwait(false); + } + public async Task ExecuteRequestAsync(string methodName, RequestType request, CancellationToken cancellationToken) where RequestType : class { // If creating the LanguageServer threw we might timeout without this. @@ -655,7 +682,7 @@ public async Task OpenDocumentAsync(Uri documentUri, string? text = null, string { // LSP open files don't care about the project context, just the file contents with the URI. // So pick any of the linked documents to get the text from. - var sourceText = await TestWorkspace.CurrentSolution.GetDocuments(documentUri).First().GetTextAsync(CancellationToken.None).ConfigureAwait(false); + var sourceText = await GetDocumentTextAsync(documentUri).ConfigureAwait(false); text = sourceText.ToString(); } diff --git a/src/Features/Lsif/Generator/Generator.cs b/src/Features/Lsif/Generator/Generator.cs index 57dfd14b41197..a4b485bd593e0 100644 --- a/src/Features/Lsif/Generator/Generator.cs +++ b/src/Features/Lsif/Generator/Generator.cs @@ -210,9 +210,9 @@ public async Task GenerateForProjectAsync( // use this document can benefit from that single shared model. var semanticModel = await document.GetRequiredSemanticModelAsync(cancellationToken); - var (uri, contentBase64Encoded) = await GetUriAndContentAsync(document, cancellationToken); + var contentBase64Encoded = await GetBase64EncodedContentAsync(document, cancellationToken); - var documentVertex = new Graph.LsifDocument(uri, GetLanguageKind(semanticModel.Language), contentBase64Encoded, idFactory); + var documentVertex = new Graph.LsifDocument(document.GetURI(), GetLanguageKind(semanticModel.Language), contentBase64Encoded, idFactory); lsifJsonWriter.Write(documentVertex); lsifJsonWriter.Write(new Event(Event.EventKind.Begin, documentVertex.GetId(), idFactory)); @@ -443,32 +443,21 @@ private static (DefinitionRangeTag tag, TextSpan fullRange)? CreateRangeTagAndCo return (new DefinitionRangeTag(syntaxToken.Text, symbolKind, fullRange), fullRangeSpan); } - private static async Task<(Uri uri, string? contentBase64Encoded)> GetUriAndContentAsync( + private static async Task GetBase64EncodedContentAsync( Document document, CancellationToken cancellationToken) { - Contract.ThrowIfNull(document.FilePath); - - string? contentBase64Encoded = null; - Uri uri; - if (document is SourceGeneratedDocument) { var text = await document.GetValueTextAsync(cancellationToken); // We always use UTF-8 encoding when writing out file contents, as that's expected by LSIF implementations. // TODO: when we move to .NET Core, is there a way to reduce allocations here? - contentBase64Encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(text.ToString())); - - // There is a triple slash here, so the "host" portion of the URI is empty, similar to - // how file URIs work. - uri = ProtocolConversions.CreateUriFromSourceGeneratedFilePath(document.FilePath); + return Convert.ToBase64String(Encoding.UTF8.GetBytes(text.ToString())); } else { - uri = ProtocolConversions.CreateAbsoluteUri(document.FilePath); + return null; } - - return (uri, contentBase64Encoded); } private static async Task GenerateSemanticTokensAsync( diff --git a/src/LanguageServer/BannedSymbols.txt b/src/LanguageServer/BannedSymbols.txt index 7cf931bd24c12..1bf0bca833137 100644 --- a/src/LanguageServer/BannedSymbols.txt +++ b/src/LanguageServer/BannedSymbols.txt @@ -11,13 +11,13 @@ T:System.ComponentModel.Composition.PartNotDiscoverableAttribute; Use types from T:System.ComponentModel.Composition.SharedAttribute; Use types from System.Composition instead T:System.ComponentModel.Composition.SharingBoundaryAttribute; Use types from System.Composition instead T:System.ComponentModel.Composition.Convention.AttributedModelProvider; Use types from System.Composition instead -M:System.Uri.#ctor(System.String); Use ProtocolConversions.CreateAbsoluteUri or ProtocolConversions.CreateUriFromSourceGeneratedFilePath -M:System.Uri.#ctor(System.String,System.Boolean); Use ProtocolConversions.CreateAbsoluteUri or ProtocolConversions.CreateUriFromSourceGeneratedFilePath -M:System.Uri.#ctor(System.String,System.UriCreationOptions); Use ProtocolConversions.CreateAbsoluteUri or ProtocolConversions.CreateUriFromSourceGeneratedFilePath -M:System.Uri.#ctor(System.String,System.UriKind); Use ProtocolConversions.CreateAbsoluteUri or ProtocolConversions.CreateUriFromSourceGeneratedFilePath -M:System.Uri.#ctor(System.Uri,System.String); Use ProtocolConversions.CreateAbsoluteUri or ProtocolConversions.CreateUriFromSourceGeneratedFilePath -M:System.Uri.#ctor(System.Uri,System.Uri); Use ProtocolConversions.CreateAbsoluteUri or ProtocolConversions.CreateUriFromSourceGeneratedFilePath -M:System.Uri.#ctor(System.Uri,System.String,System.Boolean); Use ProtocolConversions.CreateAbsoluteUri or ProtocolConversions.CreateUriFromSourceGeneratedFilePath -M:System.Uri.TryCreate(System.String,System.UriKind,System.Uri@); Use ProtocolConversions.CreateAbsoluteUri or ProtocolConversions.CreateUriFromSourceGeneratedFilePath -M:System.Uri.TryCreate(System.Uri,System.String,System.Uri@); Use ProtocolConversions.CreateAbsoluteUri or ProtocolConversions.CreateUriFromSourceGeneratedFilePath -M:System.Uri.TryCreate(System.Uri,System.Uri,System.Uri@); Use ProtocolConversions.CreateAbsoluteUri or ProtocolConversions.CreateUriFromSourceGeneratedFilePath +M:System.Uri.#ctor(System.String); Use Extensions.GetURI() if you have a document, or ProtocolConversions.CreateAbsoluteUri if you are sure the path is a real file path and not a generated file path +M:System.Uri.#ctor(System.String,System.Boolean); Use Extensions.GetURI() if you have a document, or ProtocolConversions.CreateAbsoluteUri if you are sure the path is a real file path and not a generated file path +M:System.Uri.#ctor(System.String,System.UriCreationOptions); Use Extensions.GetURI() if you have a document, or ProtocolConversions.CreateAbsoluteUri if you are sure the path is a real file path and not a generated file path +M:System.Uri.#ctor(System.String,System.UriKind); Use Extensions.GetURI() if you have a document, or ProtocolConversions.CreateAbsoluteUri if you are sure the path is a real file path and not a generated file path +M:System.Uri.#ctor(System.Uri,System.String); Use Extensions.GetURI() if you have a document, or ProtocolConversions.CreateAbsoluteUri if you are sure the path is a real file path and not a generated file path +M:System.Uri.#ctor(System.Uri,System.Uri); Use Extensions.GetURI() if you have a document, or ProtocolConversions.CreateAbsoluteUri if you are sure the path is a real file path and not a generated file path +M:System.Uri.#ctor(System.Uri,System.String,System.Boolean); Use Extensions.GetURI() if you have a document, or ProtocolConversions.CreateAbsoluteUri if you are sure the path is a real file path and not a generated file path +M:System.Uri.TryCreate(System.String,System.UriKind,System.Uri@); Use Extensions.GetURI() if you have a document, or ProtocolConversions.CreateAbsoluteUri if you are sure the path is a real file path and not a generated file path +M:System.Uri.TryCreate(System.Uri,System.String,System.Uri@); Use Extensions.GetURI() if you have a document, or ProtocolConversions.CreateAbsoluteUri if you are sure the path is a real file path and not a generated file path +M:System.Uri.TryCreate(System.Uri,System.Uri,System.Uri@); Use Extensions.GetURI() if you have a document, or ProtocolConversions.CreateAbsoluteUri if you are sure the path is a real file path and not a generated file path diff --git a/src/LanguageServer/Protocol/Extensions/Extensions.cs b/src/LanguageServer/Protocol/Extensions/Extensions.cs index 2523b9c721708..b0aaf02dd22e1 100644 --- a/src/LanguageServer/Protocol/Extensions/Extensions.cs +++ b/src/LanguageServer/Protocol/Extensions/Extensions.cs @@ -24,8 +24,8 @@ internal static class Extensions public static Uri GetURI(this TextDocument document) { Contract.ThrowIfNull(document.FilePath); - return document is SourceGeneratedDocument - ? ProtocolConversions.CreateUriFromSourceGeneratedFilePath(document.FilePath) + return document is SourceGeneratedDocument sourceGeneratedDocument + ? SourceGeneratedDocumentUri.Create(sourceGeneratedDocument.Identity) : ProtocolConversions.CreateAbsoluteUri(document.FilePath); } @@ -56,42 +56,63 @@ public static Uri CreateUriForDocumentWithoutFilePath(this TextDocument document return ProtocolConversions.CreateAbsoluteUri(path); } - public static ImmutableArray GetDocuments(this Solution solution, Uri documentUri) - => GetDocuments(solution, ProtocolConversions.GetDocumentFilePathFromUri(documentUri)); - - public static ImmutableArray GetDocuments(this Solution solution, string documentPath) - { - var documentIds = solution.GetDocumentIdsWithFilePath(documentPath); - - // We don't call GetRequiredDocument here as the id could be referring to an additional document. - var documents = documentIds.Select(solution.GetDocument).WhereNotNull().ToImmutableArray(); - return documents; - } - /// /// Get all regular and additional s for the given . + /// This will not return source generated documents. /// public static ImmutableArray GetTextDocuments(this Solution solution, Uri documentUri) { var documentIds = GetDocumentIds(solution, documentUri); var documents = documentIds - .Select(solution.GetDocument) - .Concat(documentIds.Select(solution.GetAdditionalDocument)) + .Select(solution.GetTextDocument) .WhereNotNull() .ToImmutableArray(); return documents; } public static ImmutableArray GetDocumentIds(this Solution solution, Uri documentUri) - => solution.GetDocumentIdsWithFilePath(ProtocolConversions.GetDocumentFilePathFromUri(documentUri)); + { + // If this is not our special scheme for generated documents, then we can just look for documents with that file path. + if (documentUri.Scheme != SourceGeneratedDocumentUri.Scheme) + return solution.GetDocumentIdsWithFilePath(ProtocolConversions.GetDocumentFilePathFromUri(documentUri)); + + // We can get a null documentId if we were unable to find the project associated with the + // generated document - this can happen if say a project is unloaded. There may be LSP requests + // already in-flight which may ask for a generated document from that project. So we return null + var documentId = SourceGeneratedDocumentUri.DeserializeIdentity(solution, documentUri)?.DocumentId; + + return documentId is not null ? [documentId] : []; + } - public static Document? GetDocument(this Solution solution, TextDocumentIdentifier documentIdentifier) + /// + /// Finds the document for a TextDocumentIdentifier, potentially returning a source-generated file. + /// + public static async ValueTask GetDocumentAsync(this Solution solution, TextDocumentIdentifier documentIdentifier, CancellationToken cancellationToken) + { + var textDocument = await solution.GetTextDocumentAsync(documentIdentifier, cancellationToken).ConfigureAwait(false); + Contract.ThrowIfTrue(textDocument is not null && textDocument is not Document, $"{textDocument!.Id} is not a Document"); + return textDocument as Document; + } + + /// + /// Finds the TextDocument for a TextDocumentIdentifier, potentially returning a source-generated file. + /// + public static async ValueTask GetTextDocumentAsync(this Solution solution, TextDocumentIdentifier documentIdentifier, CancellationToken cancellationToken) { - var documents = solution.GetDocuments(documentIdentifier.Uri); + // If it's the URI scheme for source generated files, delegate to our other helper, otherwise we can handle anything else here. + if (documentIdentifier.Uri.Scheme == SourceGeneratedDocumentUri.Scheme) + { + // In the case of a URI scheme for source generated files, we generate a different URI for each project, thus this URI cannot be linked into multiple projects; + // this means we can safely call .SingleOrDefault() and not worry about calling FindDocumentInProjectContext. + var documentId = solution.GetDocumentIds(documentIdentifier.Uri).SingleOrDefault(); + return await solution.GetDocumentAsync(documentId, includeSourceGenerated: true, cancellationToken).ConfigureAwait(false); + } + + var documents = solution.GetTextDocuments(documentIdentifier.Uri); return documents.Length == 0 ? null - : documents.FindDocumentInProjectContext(documentIdentifier, (sln, id) => sln.GetRequiredDocument(id)); + : documents.FindDocumentInProjectContext(documentIdentifier, (sln, id) => sln.GetRequiredTextDocument(id)); } private static T FindItemInProjectContext( diff --git a/src/LanguageServer/Protocol/Extensions/ProtocolConversions.cs b/src/LanguageServer/Protocol/Extensions/ProtocolConversions.cs index 79b5ab45911ba..a749a057cef2e 100644 --- a/src/LanguageServer/Protocol/Extensions/ProtocolConversions.cs +++ b/src/LanguageServer/Protocol/Extensions/ProtocolConversions.cs @@ -35,14 +35,9 @@ internal static partial class ProtocolConversions { private const string CSharpMarkdownLanguageName = "csharp"; private const string VisualBasicMarkdownLanguageName = "vb"; - private const string SourceGeneratedDocumentBaseUri = "source-generated:///"; private const string BlockCodeFence = "```"; private const string InlineCodeFence = "`"; -#pragma warning disable RS0030 // Do not use banned APIs - private static readonly Uri s_sourceGeneratedDocumentBaseUri = new(SourceGeneratedDocumentBaseUri, UriKind.Absolute); -#pragma warning restore - private static readonly char[] s_dirSeparators = [PathUtilities.DirectorySeparatorChar, PathUtilities.AltDirectorySeparatorChar]; private static readonly Regex s_markdownEscapeRegex = new(@"([\\`\*_\{\}\[\]\(\)#+\-\.!])", RegexOptions.Compiled); @@ -188,7 +183,9 @@ static async Task GetInsertionCharacterAsync(Document document, int positi } public static string GetDocumentFilePathFromUri(Uri uri) - => uri.IsFile ? uri.LocalPath : uri.AbsoluteUri; + { + return uri.IsFile ? uri.LocalPath : uri.AbsoluteUri; + } /// /// Converts an absolute local file path or an absolute URL string to . @@ -262,28 +259,6 @@ static string EscapeUriPart(string stringToEscape) #pragma warning restore } - public static Uri CreateUriFromSourceGeneratedFilePath(string filePath) - { - Debug.Assert(!PathUtilities.IsAbsolute(filePath)); - - // Fast path for common cases: - if (IsAscii(filePath)) - { -#pragma warning disable RS0030 // Do not use banned APIs - return new Uri(s_sourceGeneratedDocumentBaseUri, filePath); -#pragma warning restore - } - - // Workaround for https://github.com/dotnet/runtime/issues/89538: - - var parts = filePath.Split(s_dirSeparators); - var url = SourceGeneratedDocumentBaseUri + string.Join("/", parts.Select(Uri.EscapeDataString)); - -#pragma warning disable RS0030 // Do not use banned APIs - return new Uri(url, UriKind.Absolute); -#pragma warning restore - } - private static bool IsAscii(char c) => (uint)c <= '\x007f'; @@ -512,13 +487,12 @@ public static LSP.Range TextSpanToRange(TextSpan textSpan, SourceText text) Range = MappedSpanResultToRange(mappedSpan) }; - static async Task ConvertTextSpanToLocationAsync( + static async Task ConvertTextSpanToLocationAsync( TextDocument document, TextSpan span, bool isStale, CancellationToken cancellationToken) { - Debug.Assert(document.FilePath != null); var uri = document.GetURI(); var text = await document.GetValueTextAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/LanguageServer/Protocol/Extensions/SourceGeneratedDocumentUri.cs b/src/LanguageServer/Protocol/Extensions/SourceGeneratedDocumentUri.cs new file mode 100644 index 0000000000000..5b9f2743f3c4d --- /dev/null +++ b/src/LanguageServer/Protocol/Extensions/SourceGeneratedDocumentUri.cs @@ -0,0 +1,112 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Linq; +using Roslyn.Utilities; + +namespace Microsoft.CodeAnalysis.LanguageServer +{ + // For source generated documents, we'll produce a URI specifically for LSP that has a scheme the client can register for; the "host" portion will + // just be the project ID of the document, and the path will be the hint text for the document. This recognizes that VS Code shows just the local + // path portion in the UI and thus embedding more into the path will appear and we don't want that. The rest of the stuff to serialize, namely the DocumentId, and any information + // for the SourceGeneratedDocumentIdentity are put in as query string arguments + // + // For example, the URI can look like: + // + // roslyn-source-generated://E7D5BCFA-E345-4029-9D12-3EDCD0FB0F6B/Generated.cs?documentId=8E4C0B71-4044-4247-BDD0-04AF4C9E1677&assembly=Generator... + // + // where the first GUID is the project ID, the second GUID is the document ID. + internal static class SourceGeneratedDocumentUri + { + public const string Scheme = "roslyn-source-generated"; + private const string GuidFormat = "D"; + + public static Uri Create(SourceGeneratedDocumentIdentity identity) + { + // Ensure the hint path is converted to a URI-friendly format + var hintPathParts = identity.HintName.Split('\\'); + var hintPathPortion = string.Join("/", hintPathParts.Select(Uri.EscapeDataString)); + + var uri = Scheme + "://" + + identity.DocumentId.ProjectId.Id.ToString(GuidFormat) + "/" + + hintPathPortion + + "?documentId=" + identity.DocumentId.Id.ToString(GuidFormat) + + "&hintName=" + Uri.EscapeDataString(identity.HintName) + + "&assemblyName=" + Uri.EscapeDataString(identity.Generator.AssemblyName) + + "&assemblyVersion=" + Uri.EscapeDataString(identity.Generator.AssemblyVersion.ToString()) + + "&typeName=" + Uri.EscapeDataString(identity.Generator.TypeName); + + // If we have a path (which is technically optional) also append it + if (identity.Generator.AssemblyPath != null) + uri += "&assemblyPath=" + Uri.EscapeDataString(identity.Generator.AssemblyPath); + + return ProtocolConversions.CreateAbsoluteUri(uri); + } + + public static SourceGeneratedDocumentIdentity? DeserializeIdentity(Solution solution, Uri documentUri) + { + // This is a generated document, so the "host" portion is just the GUID of the project ID; we'll parse that into an ID and then + // look up the project in the Solution. This relies on the fact that technically the only part of the ID that matters for equality + // is the GUID; looking up the project again means we can then recover the ProjectId with the debug name, so anybody looking at a crash + // dump sees a "normal" ID. It also means if the project is gone we can trivially say there are no usable IDs anymore. + var projectIdGuidOnly = ProjectId.CreateFromSerialized(Guid.ParseExact(documentUri.Host, GuidFormat)); + var projectId = solution.GetProject(projectIdGuidOnly)?.Id; + + if (projectId == null) + return null; + + Guid? documentIdGuid = null; + string? hintName = null; + string? assemblyName = null; + string? assemblyPath = null; // this one is actually OK if it's null, since it's optional + Version? assemblyVersion = null; + string? typeName = null; + + // Parse the query string apart and grab everything from it + foreach (var part in documentUri.Query.TrimStart('?').Split('&')) + { + var equals = part.IndexOf('='); + Contract.ThrowIfTrue(equals <= 0); +#if NET + var name = part.AsSpan()[0..equals]; +#else + var name = part.Substring(0, equals); +#endif + var value = Uri.UnescapeDataString(part.Substring(equals + 1)); + + if (name.Equals("documentId", StringComparison.Ordinal)) + documentIdGuid = Guid.ParseExact(value, GuidFormat); + else if (name.Equals("hintName", StringComparison.Ordinal)) + hintName = value.ToString(); + else if (name.Equals("assemblyName", StringComparison.Ordinal)) + assemblyName = value.ToString(); + else if (name.Equals("assemblyPath", StringComparison.Ordinal)) + assemblyPath = value.ToString(); + else if (name.Equals("assemblyVersion", StringComparison.Ordinal)) + assemblyVersion = Version.Parse(value); + else if (name.Equals("typeName", StringComparison.Ordinal)) + typeName = value.ToString(); + } + + Contract.ThrowIfNull(documentIdGuid, "Expected a URI with a documentId parameter."); + Contract.ThrowIfNull(hintName, "Expected a URI with a hintName parameter."); + Contract.ThrowIfNull(assemblyName, "Expected a URI with an assemblyName parameter."); + Contract.ThrowIfNull(assemblyVersion, "Expected a URI with an assemblyVersion parameter."); + Contract.ThrowIfNull(typeName, "Expected a URI with an typeName parameter."); + + var documentId = DocumentId.CreateFromSerialized(projectId, documentIdGuid.Value, isSourceGenerated: true, hintName); + + return new SourceGeneratedDocumentIdentity( + documentId, + hintName, + new SourceGeneratorIdentity( + assemblyName, + assemblyPath, + assemblyVersion, + typeName), + hintName); + } + } +} \ No newline at end of file diff --git a/src/LanguageServer/Protocol/Handler/Diagnostics/AbstractPullDiagnosticHandler.cs b/src/LanguageServer/Protocol/Handler/Diagnostics/AbstractPullDiagnosticHandler.cs index 94b2795b55b67..44c571c84be0f 100644 --- a/src/LanguageServer/Protocol/Handler/Diagnostics/AbstractPullDiagnosticHandler.cs +++ b/src/LanguageServer/Protocol/Handler/Diagnostics/AbstractPullDiagnosticHandler.cs @@ -133,7 +133,7 @@ protected virtual Task WaitForChangesAsync(string? category, RequestContext cont // the updated diagnostics are. using var _1 = PooledDictionary.GetInstance(out var documentIdToPreviousDiagnosticParams); using var _2 = PooledHashSet.GetInstance(out var removedDocuments); - ProcessPreviousResults(context.Solution, previousResults, documentIdToPreviousDiagnosticParams, removedDocuments); + await ProcessPreviousResultsAsync(context.Solution, previousResults, documentIdToPreviousDiagnosticParams, removedDocuments, cancellationToken).ConfigureAwait(false); // First, let the client know if any workspace documents have gone away. That way it can remove those for // the user from squiggles or error-list. @@ -219,17 +219,18 @@ await ComputeAndReportCurrentDiagnosticsAsync( return CreateReturn(progress); - static void ProcessPreviousResults( + static async Task ProcessPreviousResultsAsync( Solution solution, ImmutableArray previousResults, Dictionary idToPreviousDiagnosticParams, - HashSet removedResults) + HashSet removedResults, + CancellationToken cancellationToken) { foreach (var diagnosticParams in previousResults) { if (diagnosticParams.TextDocument != null) { - var id = GetIdForPreviousResult(diagnosticParams.TextDocument, solution); + var id = await GetIdForPreviousResultAsync(diagnosticParams.TextDocument, solution, cancellationToken).ConfigureAwait(false); if (id != null) { idToPreviousDiagnosticParams[id.Value] = diagnosticParams; @@ -244,9 +245,9 @@ static void ProcessPreviousResults( } } - static ProjectOrDocumentId? GetIdForPreviousResult(TextDocumentIdentifier textDocumentIdentifier, Solution solution) + static async Task GetIdForPreviousResultAsync(TextDocumentIdentifier textDocumentIdentifier, Solution solution, CancellationToken cancellationToken) { - var document = solution.GetDocument(textDocumentIdentifier); + var document = await solution.GetDocumentAsync(textDocumentIdentifier, cancellationToken).ConfigureAwait(false); if (document != null) { return new ProjectOrDocumentId(document.Id); diff --git a/src/LanguageServer/Protocol/Handler/MapCode/MapCodeHandler.cs b/src/LanguageServer/Protocol/Handler/MapCode/MapCodeHandler.cs index d2ea413a2eec3..59279fcf4a48a 100644 --- a/src/LanguageServer/Protocol/Handler/MapCode/MapCodeHandler.cs +++ b/src/LanguageServer/Protocol/Handler/MapCode/MapCodeHandler.cs @@ -82,7 +82,8 @@ public MapCodeHandler() var textDocument = codeMapping.TextDocument ?? throw new ArgumentException($"mapCode sub-request failed: MapCodeMapping.TextDocument not expected to be null."); - if (context.Solution.GetDocument(textDocument) is not Document document) + var document = await context.Solution.GetDocumentAsync(textDocument, cancellationToken).ConfigureAwait(false); + if (document is null) throw new ArgumentException($"mapCode sub-request for {textDocument.Uri} failed: can't find this document in the workspace."); var codeMapper = document.GetRequiredLanguageService(); diff --git a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentText.cs b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentText.cs new file mode 100644 index 0000000000000..17c1105a0870e --- /dev/null +++ b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentText.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace Microsoft.CodeAnalysis.LanguageServer.Handler; + +internal sealed record SourceGeneratedDocumentText([property: JsonPropertyName("text")] string? Text); \ No newline at end of file diff --git a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedFileGetTextHandler.cs b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedFileGetTextHandler.cs new file mode 100644 index 0000000000000..abbb529cb05ad --- /dev/null +++ b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedFileGetTextHandler.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Composition; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Host.Mef; +using Roslyn.Utilities; +using LSP = Roslyn.LanguageServer.Protocol; + +namespace Microsoft.CodeAnalysis.LanguageServer.Handler; + +[ExportCSharpVisualBasicStatelessLspService(typeof(SourceGeneratedFileGetTextHandler)), Shared] +[Method(MethodName)] +internal sealed class SourceGeneratedFileGetTextHandler : ILspServiceDocumentRequestHandler +{ + public const string MethodName = "sourceGeneratedFile/_roslyn_getText"; + + [ImportingConstructor] + [Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] + public SourceGeneratedFileGetTextHandler() + { + } + + public bool MutatesSolutionState => false; + public bool RequiresLSPSolution => true; + + public LSP.TextDocumentIdentifier GetTextDocumentIdentifier(SourceGeneratorGetTextParams request) => request.TextDocument; + + public async Task HandleRequestAsync(SourceGeneratorGetTextParams request, RequestContext context, CancellationToken cancellationToken) + { + var document = context.Document; + + // Nothing here strictly prevents this from working on any other document, but we'll assert we got a source-generated file, since + // it wouldn't really make sense for the server to be asked for the contents of a regular file. Since this endpoint is intended for + // source-generated files only, this would indicate that something else has gone wrong. + Contract.ThrowIfFalse(document is SourceGeneratedDocument); + + // If a source file is open we ensure the generated document matches what's currently open in the LSP client so that way everything + // stays in sync and we don't have mismatched ranges. But for this particular case, we want to ignore that. + document = await document.Project.Solution.WithoutFrozenSourceGeneratedDocuments().GetDocumentAsync(document.Id, includeSourceGenerated: true, cancellationToken).ConfigureAwait(false); + + var text = document != null ? await document.GetTextAsync(cancellationToken).ConfigureAwait(false) : null; + return new SourceGeneratedDocumentText(text?.ToString()); + } +} \ No newline at end of file diff --git a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorGetTextParams.cs b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorGetTextParams.cs new file mode 100644 index 0000000000000..f930f1cc438c0 --- /dev/null +++ b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratorGetTextParams.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; +using Roslyn.LanguageServer.Protocol; + +namespace Microsoft.CodeAnalysis.LanguageServer.Handler; + +internal sealed record SourceGeneratorGetTextParams([property: JsonPropertyName("textDocument")] TextDocumentIdentifier TextDocument) : ITextDocumentParams; \ No newline at end of file diff --git a/src/LanguageServer/Protocol/Handler/SpellCheck/AbstractSpellCheckingHandler.cs b/src/LanguageServer/Protocol/Handler/SpellCheck/AbstractSpellCheckingHandler.cs index d7fe19248adff..911eb6ebde87a 100644 --- a/src/LanguageServer/Protocol/Handler/SpellCheck/AbstractSpellCheckingHandler.cs +++ b/src/LanguageServer/Protocol/Handler/SpellCheck/AbstractSpellCheckingHandler.cs @@ -74,12 +74,12 @@ protected AbstractSpellCheckHandler() // First, let the client know if any workspace documents have gone away. That way it can remove those for // the user from squiggles or error-list. - HandleRemovedDocuments(context, previousResults, progress); + await HandleRemovedDocumentsAsync(context, previousResults, progress, cancellationToken).ConfigureAwait(false); // Create a mapping from documents to the previous results the client says it has for them. That way as we // process documents we know if we should tell the client it should stay the same, or we can tell it what // the updated spans are. - var documentToPreviousParams = GetDocumentToPreviousParams(context, previousResults); + var documentToPreviousParams = await GetDocumentToPreviousParamsAsync(context, previousResults, cancellationToken).ConfigureAwait(false); // Next process each file in priority order. Determine if spans are changed or unchanged since the // last time we notified the client. Report back either to the client so they can update accordingly. @@ -129,8 +129,8 @@ protected AbstractSpellCheckHandler() return progress.GetFlattenedValues(); } - private static Dictionary GetDocumentToPreviousParams( - RequestContext context, ImmutableArray previousResults) + private static async Task> GetDocumentToPreviousParamsAsync( + RequestContext context, ImmutableArray previousResults, CancellationToken cancellationToken) { Contract.ThrowIfNull(context.Solution); @@ -139,7 +139,7 @@ private static Dictionary GetDocumentToPreviousPar { if (requestParams.TextDocument != null) { - var document = context.Solution.GetDocument(requestParams.TextDocument); + var document = await context.Solution.GetDocumentAsync(requestParams.TextDocument, cancellationToken).ConfigureAwait(false); if (document != null) result[document] = requestParams; } @@ -199,8 +199,8 @@ private async IAsyncEnumerable ComputeAndReportCurrentSpansAsync( } } - private void HandleRemovedDocuments( - RequestContext context, ImmutableArray previousResults, BufferedProgress progress) + private async Task HandleRemovedDocumentsAsync( + RequestContext context, ImmutableArray previousResults, BufferedProgress progress, CancellationToken cancellationToken) { Contract.ThrowIfNull(context.Solution); @@ -209,7 +209,7 @@ private void HandleRemovedDocuments( var textDocument = previousResult.TextDocument; if (textDocument != null) { - var document = context.Solution.GetDocument(textDocument); + var document = await context.Solution.GetDocumentAsync(textDocument, cancellationToken).ConfigureAwait(false); if (document == null) { context.TraceInformation($"Clearing spans for removed document: {textDocument.Uri}"); diff --git a/src/LanguageServer/Protocol/ILanguageInfoProvider.cs b/src/LanguageServer/Protocol/ILanguageInfoProvider.cs index e6b81cbba6926..9673195278cdd 100644 --- a/src/LanguageServer/Protocol/ILanguageInfoProvider.cs +++ b/src/LanguageServer/Protocol/ILanguageInfoProvider.cs @@ -20,6 +20,6 @@ internal interface ILanguageInfoProvider : ILspService /// In that case, we use the language Id that the LSP client gave us. /// /// Thrown when the language information cannot be determined. - LanguageInformation GetLanguageInformation(string documentPath, string? lspLanguageId); + LanguageInformation GetLanguageInformation(Uri documentUri, string? lspLanguageId); } } diff --git a/src/LanguageServer/Protocol/LanguageInfoProvider.cs b/src/LanguageServer/Protocol/LanguageInfoProvider.cs index 6b5fbe623f777..e9830820a4f79 100644 --- a/src/LanguageServer/Protocol/LanguageInfoProvider.cs +++ b/src/LanguageServer/Protocol/LanguageInfoProvider.cs @@ -43,14 +43,21 @@ internal class LanguageInfoProvider : ILanguageInfoProvider { ".mts", s_typeScriptLanguageInformation }, }; - public LanguageInformation GetLanguageInformation(string documentPath, string? lspLanguageId) + public LanguageInformation GetLanguageInformation(Uri uri, string? lspLanguageId) { - var extension = Path.GetExtension(documentPath); - if (s_extensionToLanguageInformation.TryGetValue(extension, out var languageInformation)) + // First try to get language information from the URI path. + // We can do this for File uris and absolute uris. We use local path to get the value without any query parameters. + if (uri.IsFile || uri.IsAbsoluteUri) { - return languageInformation; + var localPath = uri.LocalPath; + var extension = Path.GetExtension(localPath); + if (s_extensionToLanguageInformation.TryGetValue(extension, out var languageInformation)) + { + return languageInformation; + } } + // If the URI file path mapping failed, use the languageId from the LSP client (if any). return lspLanguageId switch { "csharp" => s_csharpLanguageInformation, @@ -60,7 +67,7 @@ public LanguageInformation GetLanguageInformation(string documentPath, string? l "xaml" => s_xamlLanguageInformation, "typescript" => s_typeScriptLanguageInformation, "javascript" => s_typeScriptLanguageInformation, - _ => throw new InvalidOperationException($"Unsupported extension '{extension}' and LSP language id '{lspLanguageId}'") + _ => throw new InvalidOperationException($"Unable to determine language for '{uri}' with LSP language id '{lspLanguageId}'") }; } } diff --git a/src/LanguageServer/Protocol/Workspaces/LspMiscellaneousFilesWorkspace.cs b/src/LanguageServer/Protocol/Workspaces/LspMiscellaneousFilesWorkspace.cs index 0dbbfe966ee2c..d6d9c2809bb8d 100644 --- a/src/LanguageServer/Protocol/Workspaces/LspMiscellaneousFilesWorkspace.cs +++ b/src/LanguageServer/Protocol/Workspaces/LspMiscellaneousFilesWorkspace.cs @@ -50,7 +50,7 @@ internal sealed class LspMiscellaneousFilesWorkspace(ILspServices lspServices, I } var languageInfoProvider = lspServices.GetRequiredService(); - var languageInformation = languageInfoProvider.GetLanguageInformation(documentFilePath, languageId); + var languageInformation = languageInfoProvider.GetLanguageInformation(uri, languageId); if (languageInformation == null) { // Only log here since throwing here could take down the LSP server. diff --git a/src/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs b/src/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs index d49ab8c07e63e..7685191a53533 100644 --- a/src/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs +++ b/src/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs @@ -210,8 +210,17 @@ async ValueTask TryCloseDocumentsInMutatingWorkspaceAsync(Uri uri) var registeredWorkspaces = _lspWorkspaceRegistrationService.GetAllRegistrations(); foreach (var workspace in registeredWorkspaces) { - await ApplyChangeToMutatingWorkspaceAsync(workspace, uri, (_, documentId) => - workspace.TryOnDocumentClosedAsync(documentId, cancellationToken)).ConfigureAwait(false); + await ApplyChangeToMutatingWorkspaceAsync(workspace, uri, async (_, documentId) => + { + if (documentId.IsSourceGenerated) + { + // Source generated documents cannot go through OnDocumentOpened/Closed. + // There is a separate OnSourceGeneratedDocumentOpened/Closed method, but there is no need + // for us to call it in LSP - it deals with mapping TextBuffers to text containers. + return; + } + await workspace.TryOnDocumentClosedAsync(documentId, cancellationToken).ConfigureAwait(false); + }).ConfigureAwait(false); } } } @@ -274,11 +283,9 @@ public void UpdateTrackedDocument(Uri uri, SourceText newSourceText) // Find the matching document from the LSP solutions. foreach (var (workspace, lspSolution, isForked) in lspSolutions) { - var documents = lspSolution.GetTextDocuments(textDocumentIdentifier.Uri); - if (documents.Any()) + var document = await lspSolution.GetTextDocumentAsync(textDocumentIdentifier, cancellationToken).ConfigureAwait(false); + if (document != null) { - var document = documents.FindDocumentInProjectContext(textDocumentIdentifier, (sln, id) => sln.GetRequiredTextDocument(id)); - // Record metadata on how we got this document. var workspaceKind = document.Project.Solution.WorkspaceKind; _requestTelemetryLogger.UpdateFindDocumentTelemetryData(success: true, workspaceKind); @@ -383,7 +390,16 @@ public void UpdateTrackedDocument(Uri uri, SourceText newSourceText) // Step 3: Check to see if the LSP text matches the workspace text. var documentsInWorkspace = GetDocumentsForUris(_trackedDocuments.Keys.ToImmutableArray(), workspaceCurrentSolution); - if (await DoesAllTextMatchWorkspaceSolutionAsync(documentsInWorkspace, cancellationToken).ConfigureAwait(false)) + var sourceGeneratedDocuments = + _trackedDocuments.Keys.Where(static uri => uri.Scheme == SourceGeneratedDocumentUri.Scheme) + .Select(uri => (identity: SourceGeneratedDocumentUri.DeserializeIdentity(workspaceCurrentSolution, uri), _trackedDocuments[uri].Text)) + .Where(tuple => tuple.identity.HasValue) + .SelectAsArray(tuple => (tuple.identity!.Value, DateTime.Now, tuple.Text)); + + // We don't want to check if the source generated document text matches the workspace text because that + // will trigger generators to run. We don't want that to happen in the queue dispatch as it could be quite slow. + var doesAllTextMatch = await DoesAllTextMatchWorkspaceSolutionAsync(documentsInWorkspace, cancellationToken).ConfigureAwait(false); + if (doesAllTextMatch && !sourceGeneratedDocuments.Any()) { // Remember that the current LSP text matches the text in this workspace solution. _cachedLspSolutions[workspace] = (forkedFromVersion: null, workspaceCurrentSolution); @@ -396,12 +412,30 @@ public void UpdateTrackedDocument(Uri uri, SourceText newSourceText) // Step 5: Fork a new solution from the workspace with the LSP text applied. var lspSolution = workspaceCurrentSolution; - foreach (var (uri, workspaceDocuments) in documentsInWorkspace) - lspSolution = lspSolution.WithDocumentText(workspaceDocuments.Select(d => d.Id), _trackedDocuments[uri].Text); + // If the workspace text matched but we have source generated documents open, we can + // leave the normal documents as-is (the text matched) and just fork with the frozen sg docs. + if (!doesAllTextMatch) + { + foreach (var (uri, workspaceDocuments) in documentsInWorkspace) + lspSolution = lspSolution.WithDocumentText(workspaceDocuments.Select(d => d.Id), _trackedDocuments[uri].Text); + } + + lspSolution = lspSolution.WithFrozenSourceGeneratedDocuments(sourceGeneratedDocuments); - // Remember this forked solution and the workspace version it was forked from. - _cachedLspSolutions[workspace] = (workspaceCurrentSolution.WorkspaceVersion, lspSolution); - return (lspSolution, IsForked: true); + // Did we actually have to fork anything? WithFrozenSourceGeneratedDocuments will return the same instance if we were able to + // immediately determine we already had the same generated contents + if (lspSolution == workspaceCurrentSolution) + { + // Remember that the current LSP text matches the text in this workspace solution. + _cachedLspSolutions[workspace] = (forkedFromVersion: null, workspaceCurrentSolution); + return (workspaceCurrentSolution, IsForked: false); + } + else + { + // Remember this forked solution and the workspace version it was forked from. + _cachedLspSolutions[workspace] = (workspaceCurrentSolution.WorkspaceVersion, lspSolution); + return (lspSolution, IsForked: true); + } } async ValueTask TryOpenAndEditDocumentsInMutatingWorkspaceAsync(Workspace workspace) @@ -410,6 +444,13 @@ async ValueTask TryOpenAndEditDocumentsInMutatingWorkspaceAsync(Workspace worksp { await ApplyChangeToMutatingWorkspaceAsync(workspace, uri, async (mutatingWorkspace, documentId) => { + if (documentId.IsSourceGenerated) + { + // Source generated documents cannot go through OnDocumentOpened/Closed. + // There is a separate OnSourceGeneratedDocumentOpened/Closed method, but there is no need + // for us to call it in LSP - it deals with mapping TextBuffers to text containers. + return; + } // This may be the first time this workspace is hearing that this document is open from LSP's // perspective. Attempt to open it there. // @@ -473,8 +514,7 @@ internal string GetLanguageForUri(Uri uri) languageId = trackedDocument.LanguageId; } - var documentFilePath = ProtocolConversions.GetDocumentFilePathFromUri(uri); - return _languageInfoProvider.GetLanguageInformation(documentFilePath, languageId).LanguageName; + return _languageInfoProvider.GetLanguageInformation(uri, languageId).LanguageName; } /// diff --git a/src/LanguageServer/ProtocolUnitTests/Definitions/GoToDefinitionTests.cs b/src/LanguageServer/ProtocolUnitTests/Definitions/GoToDefinitionTests.cs index 3b6aca89890ba..b1d249cbd3f68 100644 --- a/src/LanguageServer/ProtocolUnitTests/Definitions/GoToDefinitionTests.cs +++ b/src/LanguageServer/ProtocolUnitTests/Definitions/GoToDefinitionTests.cs @@ -9,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using Roslyn.Test.Utilities; +using Roslyn.Test.Utilities.TestGenerators; using Xunit; using Xunit.Abstractions; using LSP = Roslyn.LanguageServer.Protocol; @@ -224,6 +225,37 @@ public async Task TestGotoDefinitionWithValueTuple(string statement) Assert.Single(results); } + [Theory, CombinatorialData] + public async Task TestGotoDefinitionAsync_SourceGeneratedDocument(bool mutatingLspWorkspace) + { + var source = + """ + namespace M + { + class A + { + public {|caret:|}B b; + } + } + """; + var generated = + """ + namespace M + { + class B + { + } + } + """; + + await using var testLspServer = await CreateTestLspServerAsync(source, mutatingLspWorkspace); + await AddGeneratorAsync(new SingleFileTestGenerator(generated), testLspServer.TestWorkspace); + + var results = await RunGotoDefinitionAsync(testLspServer, testLspServer.GetLocations("caret").Single()); + var result = Assert.Single(results); + Assert.Equal(SourceGeneratedDocumentUri.Scheme, result.Uri.Scheme); + } + private static async Task RunGotoDefinitionAsync(TestLspServer testLspServer, LSP.Location caret) { return await testLspServer.ExecuteRequestAsync(LSP.Methods.TextDocumentDefinitionName, diff --git a/src/LanguageServer/ProtocolUnitTests/Diagnostics/PullDiagnosticTests.cs b/src/LanguageServer/ProtocolUnitTests/Diagnostics/PullDiagnosticTests.cs index d0be59f92a78b..4f2aec61262c0 100644 --- a/src/LanguageServer/ProtocolUnitTests/Diagnostics/PullDiagnosticTests.cs +++ b/src/LanguageServer/ProtocolUnitTests/Diagnostics/PullDiagnosticTests.cs @@ -1031,7 +1031,7 @@ public async Task SourceGeneratorFailures_FSA(bool useVSDiagnostics, bool mutati await using var testLspServer = await CreateTestLspServerAsync(["class C {}"], mutatingLspWorkspace, GetInitializationOptions(BackgroundAnalysisScope.FullSolution, CompilerDiagnosticsScope.FullSolution, useVSDiagnostics, enableDiagnosticsInSourceGeneratedFiles: enableDiagnosticsInSourceGeneratedFiles)); - var generator = new TestSourceGenerator() + var generator = new Roslyn.Test.Utilities.TestGenerators.TestSourceGenerator() { ExecuteImpl = context => throw new InvalidOperationException("Source generator failed") }; diff --git a/src/LanguageServer/ProtocolUnitTests/DocumentChanges/DocumentChangesTests.cs b/src/LanguageServer/ProtocolUnitTests/DocumentChanges/DocumentChangesTests.cs index 2c2164f5ad65d..83bb8b0438328 100644 --- a/src/LanguageServer/ProtocolUnitTests/DocumentChanges/DocumentChangesTests.cs +++ b/src/LanguageServer/ProtocolUnitTests/DocumentChanges/DocumentChangesTests.cs @@ -235,7 +235,7 @@ void M() await DidChange(testLspServer, locationTyped.Uri, (4, 8, "// hi there")); - var documentTextFromWorkspace = (await testLspServer.GetCurrentSolution().GetDocuments(locationTyped.Uri).Single().GetTextAsync()).ToString(); + var documentTextFromWorkspace = (await testLspServer.GetDocumentTextAsync(locationTyped.Uri)).ToString(); Assert.NotNull(documentTextFromWorkspace); Assert.Equal(documentText, documentTextFromWorkspace); @@ -452,7 +452,7 @@ void M() { var testLspServer = await CreateTestLspServerAsync(source, mutatingLspWorkspace, CapabilitiesWithVSExtensions); var locationTyped = testLspServer.GetLocations("type").Single(); - var documentText = await testLspServer.GetCurrentSolution().GetDocuments(locationTyped.Uri).Single().GetTextAsync(); + var documentText = await testLspServer.GetDocumentTextAsync(locationTyped.Uri); return (testLspServer, locationTyped, documentText.ToString()); } diff --git a/src/LanguageServer/ProtocolUnitTests/Formatting/FormatDocumentOnTypeTests.cs b/src/LanguageServer/ProtocolUnitTests/Formatting/FormatDocumentOnTypeTests.cs index 4da943682de44..1ee3fbc732ffe 100644 --- a/src/LanguageServer/ProtocolUnitTests/Formatting/FormatDocumentOnTypeTests.cs +++ b/src/LanguageServer/ProtocolUnitTests/Formatting/FormatDocumentOnTypeTests.cs @@ -44,7 +44,7 @@ void M() await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace); var characterTyped = ";"; var locationTyped = testLspServer.GetLocations("type").Single(); - var documentText = await testLspServer.GetCurrentSolution().GetDocuments(locationTyped.Uri).Single().GetTextAsync(); + var documentText = await testLspServer.GetDocumentTextAsync(locationTyped.Uri); var results = await RunFormatDocumentOnTypeAsync(testLspServer, characterTyped, locationTyped); var actualText = ApplyTextEdits(results, documentText); @@ -75,7 +75,7 @@ void M() await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace); var characterTyped = ";"; var locationTyped = testLspServer.GetLocations("type").Single(); - var documentText = await testLspServer.GetCurrentSolution().GetDocuments(locationTyped.Uri).Single().GetTextAsync(); + var documentText = await testLspServer.GetDocumentTextAsync(locationTyped.Uri); var results = await RunFormatDocumentOnTypeAsync(testLspServer, characterTyped, locationTyped, insertSpaces: false, tabSize: 4); var actualText = ApplyTextEdits(results, documentText); diff --git a/src/LanguageServer/ProtocolUnitTests/Formatting/FormatDocumentRangeTests.cs b/src/LanguageServer/ProtocolUnitTests/Formatting/FormatDocumentRangeTests.cs index 70acb3ab8995a..7bc1a67e4d17c 100644 --- a/src/LanguageServer/ProtocolUnitTests/Formatting/FormatDocumentRangeTests.cs +++ b/src/LanguageServer/ProtocolUnitTests/Formatting/FormatDocumentRangeTests.cs @@ -41,7 +41,7 @@ void M() }"; await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace); var rangeToFormat = testLspServer.GetLocations("format").Single(); - var documentText = await testLspServer.GetCurrentSolution().GetDocuments(rangeToFormat.Uri).Single().GetTextAsync(); + var documentText = await testLspServer.GetDocumentTextAsync(rangeToFormat.Uri); var results = await RunFormatDocumentRangeAsync(testLspServer, rangeToFormat); var actualText = ApplyTextEdits(results, documentText); @@ -69,7 +69,7 @@ void M() }"; await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace); var rangeToFormat = testLspServer.GetLocations("format").Single(); - var documentText = await testLspServer.GetCurrentSolution().GetDocuments(rangeToFormat.Uri).Single().GetTextAsync(); + var documentText = await testLspServer.GetDocumentTextAsync(rangeToFormat.Uri); var results = await RunFormatDocumentRangeAsync(testLspServer, rangeToFormat, insertSpaces: false, tabSize: 4); var actualText = ApplyTextEdits(results, documentText); diff --git a/src/LanguageServer/ProtocolUnitTests/Formatting/FormatDocumentTests.cs b/src/LanguageServer/ProtocolUnitTests/Formatting/FormatDocumentTests.cs index bc85cdaf0a894..e4ae2a35b33cc 100644 --- a/src/LanguageServer/ProtocolUnitTests/Formatting/FormatDocumentTests.cs +++ b/src/LanguageServer/ProtocolUnitTests/Formatting/FormatDocumentTests.cs @@ -42,7 +42,7 @@ void M() }"; await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace); var documentURI = testLspServer.GetLocations("caret").Single().Uri; - var documentText = await testLspServer.GetCurrentSolution().GetDocuments(documentURI).Single().GetTextAsync(); + var documentText = await testLspServer.GetDocumentTextAsync(documentURI); var results = await RunFormatDocumentAsync(testLspServer, documentURI); var actualText = ApplyTextEdits(results, documentText); @@ -70,7 +70,7 @@ void M() }"; await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace); var documentURI = testLspServer.GetLocations("caret").Single().Uri; - var documentText = await testLspServer.GetCurrentSolution().GetDocuments(documentURI).Single().GetTextAsync(); + var documentText = await testLspServer.GetDocumentTextAsync(documentURI); var results = await RunFormatDocumentAsync(testLspServer, documentURI, insertSpaces: false, tabSize: 4); var actualText = ApplyTextEdits(results, documentText); @@ -98,7 +98,7 @@ void M() }"; await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace); var documentURI = testLspServer.GetLocations("caret").Single().Uri; - var documentText = await testLspServer.GetCurrentSolution().GetDocuments(documentURI).Single().GetTextAsync(); + var documentText = await testLspServer.GetDocumentTextAsync(documentURI); var results = await RunFormatDocumentAsync(testLspServer, documentURI, insertSpaces: true, tabSize: 2); var actualText = ApplyTextEdits(results, documentText); diff --git a/src/LanguageServer/ProtocolUnitTests/InlineCompletions/InlineCompletionsTests.cs b/src/LanguageServer/ProtocolUnitTests/InlineCompletions/InlineCompletionsTests.cs index cf44a18869abb..b709ade88f520 100644 --- a/src/LanguageServer/ProtocolUnitTests/InlineCompletions/InlineCompletionsTests.cs +++ b/src/LanguageServer/ProtocolUnitTests/InlineCompletions/InlineCompletionsTests.cs @@ -248,8 +248,6 @@ void M() await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace); var locationTyped = testLspServer.GetLocations("tab").Single(); - var document = testLspServer.GetCurrentSolution().GetDocuments(locationTyped.Uri).Single(); - // Verify we haven't parsed snippets until asked. var snippetParser = testLspServer.TestWorkspace.ExportProvider.GetExportedValue(); Assert.Equal(0, snippetParser.GetTestAccessor().GetCachedSnippetsCount()); @@ -273,7 +271,7 @@ private async Task VerifyMarkupAndExpected(string markup, string expected, bool await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace); var locationTyped = testLspServer.GetLocations("tab").Single(); - var document = testLspServer.GetCurrentSolution().GetDocuments(locationTyped.Uri).Single(); + var document = testLspServer.GetDocumentAsync(locationTyped.Uri); var result = await GetInlineCompletionsAsync(testLspServer, locationTyped, options ?? new LSP.FormattingOptions { InsertSpaces = true, TabSize = 4 }); diff --git a/src/LanguageServer/ProtocolUnitTests/OnAutoInsert/OnAutoInsertTests.cs b/src/LanguageServer/ProtocolUnitTests/OnAutoInsert/OnAutoInsertTests.cs index 8d486e0c11af7..c0d60cc1c5588 100644 --- a/src/LanguageServer/ProtocolUnitTests/OnAutoInsert/OnAutoInsertTests.cs +++ b/src/LanguageServer/ProtocolUnitTests/OnAutoInsert/OnAutoInsertTests.cs @@ -404,7 +404,7 @@ private async Task VerifyMarkupAndExpected( await using var testLspServer = await testLspServerTask; var locationTyped = testLspServer.GetLocations("type").Single(); - var document = testLspServer.GetCurrentSolution().GetDocuments(locationTyped.Uri).Single(); + var document = await testLspServer.GetDocumentAsync(locationTyped.Uri); var documentText = await document.GetTextAsync(); var result = await RunOnAutoInsertAsync(testLspServer, characterTyped, locationTyped, insertSpaces, tabSize); @@ -419,7 +419,7 @@ private async Task VerifyNoResult(string characterTyped, string markup, bool mut { await using var testLspServer = await CreateTestLspServerAsync(markup, mutatingLspWorkspace); var locationTyped = testLspServer.GetLocations("type").Single(); - var documentText = await testLspServer.GetCurrentSolution().GetDocuments(locationTyped.Uri).Single().GetTextAsync(); + var documentText = await (await testLspServer.GetDocumentAsync(locationTyped.Uri)).GetTextAsync(); var result = await RunOnAutoInsertAsync(testLspServer, characterTyped, locationTyped, insertSpaces, tabSize); diff --git a/src/LanguageServer/ProtocolUnitTests/ProtocolConversionsTests.cs b/src/LanguageServer/ProtocolUnitTests/ProtocolConversionsTests.cs index a9d20fa0ccee6..d093833d00971 100644 --- a/src/LanguageServer/ProtocolUnitTests/ProtocolConversionsTests.cs +++ b/src/LanguageServer/ProtocolUnitTests/ProtocolConversionsTests.cs @@ -167,27 +167,6 @@ public void CreateAbsoluteUri_Urls(string url) Assert.Equal(url, ProtocolConversions.CreateAbsoluteUri(url).AbsoluteUri); } - [ConditionalTheory(typeof(WindowsOnly))] - [InlineData("a\\b", "source-generated:///a/b")] - [InlineData("a//b", "source-generated:///a//b")] - [InlineData("a/b", "source-generated:///a/b")] - [InlineData("%25\ue25b//\u0089\uC7BD/a", "source-generated:///%2525%EE%89%9B//%C2%89%EC%9E%BD/a")] - [InlineData("%25\ue25b\\\u0089\uC7BD", "source-generated:///%2525%EE%89%9B/%C2%89%EC%9E%BD")] - public void GetUriFromSourceGeneratedFilePath_Windows(string filePath, string expectedAbsoluteUri) - { - var url = ProtocolConversions.CreateUriFromSourceGeneratedFilePath(filePath); - Assert.Equal(expectedAbsoluteUri, url.AbsoluteUri); - } - - [ConditionalTheory(typeof(UnixLikeOnly))] - [InlineData("a/b", "source-generated:///a/b")] - [InlineData("%25\ue25b/\u0089\uC7BD", "source-generated:///%2525%EE%89%9B/%C2%89%EC%9E%BD")] - public void GetUriFromSourceGeneratedFilePath_Unix(string filePath, string expectedAbsoluteUri) - { - var url = ProtocolConversions.CreateUriFromSourceGeneratedFilePath(filePath); - Assert.Equal(expectedAbsoluteUri, url.AbsoluteUri); - } - [Fact] public void CompletionItemKind_DoNotUseMethodAndFunction() { diff --git a/src/LanguageServer/ProtocolUnitTests/Workspaces/LspWorkspaceManagerTests.cs b/src/LanguageServer/ProtocolUnitTests/Workspaces/LspWorkspaceManagerTests.cs index 3998238e041dd..c23799f039eac 100644 --- a/src/LanguageServer/ProtocolUnitTests/Workspaces/LspWorkspaceManagerTests.cs +++ b/src/LanguageServer/ProtocolUnitTests/Workspaces/LspWorkspaceManagerTests.cs @@ -11,8 +11,10 @@ using Microsoft.CodeAnalysis.LanguageService; using Microsoft.CodeAnalysis.Shared.Extensions; using Microsoft.CodeAnalysis.Test.Utilities; +using Microsoft.CodeAnalysis.Testing; using Microsoft.CodeAnalysis.Text; using Roslyn.Test.Utilities; +using Roslyn.Test.Utilities.TestGenerators; using Xunit; using Xunit.Abstractions; @@ -690,6 +692,22 @@ await testLspServer.ReplaceTextAsync(documentUri, Assert.True(oldMethodDeclarations[2].IsIncrementallyIdenticalTo(newMethodDeclarations[2])); } + [Theory, CombinatorialData] + public async Task TestUsesForkForUnchangedGeneratedFileAsync(bool mutatingLspWorkspace) + { + var generatorText = "// Hello World!"; + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace); + await AddGeneratorAsync(new SingleFileTestGenerator(generatorText), testLspServer.TestWorkspace); + + var sourceGeneratedDocuments = await testLspServer.GetCurrentSolution().Projects.Single().GetSourceGeneratedDocumentsAsync(); + var sourceGeneratedDocumentIdentity = sourceGeneratedDocuments.Single().Identity; + var sourceGeneratorDocumentUri = SourceGeneratedDocumentUri.Create(sourceGeneratedDocumentIdentity); + + var sourceGeneratedDocument = await OpenDocumentAndVerifyLspTextAsync(sourceGeneratorDocumentUri, testLspServer, generatorText) as SourceGeneratedDocument; + AssertEx.NotNull(sourceGeneratedDocument); + Assert.NotSame(testLspServer.TestWorkspace.CurrentSolution, sourceGeneratedDocument.Project.Solution); + } + private static async Task OpenDocumentAndVerifyLspTextAsync(Uri documentUri, TestLspServer testLspServer, string openText = "LSP text") { await testLspServer.OpenDocumentAsync(documentUri, openText); diff --git a/src/LanguageServer/ProtocolUnitTests/Workspaces/SourceGeneratedDocumentTests.cs b/src/LanguageServer/ProtocolUnitTests/Workspaces/SourceGeneratedDocumentTests.cs new file mode 100644 index 0000000000000..d90554e61a6ca --- /dev/null +++ b/src/LanguageServer/ProtocolUnitTests/Workspaces/SourceGeneratedDocumentTests.cs @@ -0,0 +1,106 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.LanguageServer.Handler; +using Roslyn.Test.Utilities; +using Roslyn.Test.Utilities.TestGenerators; +using Xunit; +using Xunit.Abstractions; +using LSP = Roslyn.LanguageServer.Protocol; + +namespace Microsoft.CodeAnalysis.LanguageServer.UnitTests.Workspaces; + +public class SourceGeneratedDocumentTests(ITestOutputHelper? testOutputHelper) : AbstractLanguageServerProtocolTests(testOutputHelper) +{ + [Theory, CombinatorialData] + public async Task ReturnsTextForSourceGeneratedDocument(bool mutatingLspWorkspace) + { + await using var testLspServer = await CreateTestLspServerWithGeneratorAsync(mutatingLspWorkspace, "// Hello, World"); + + var sourceGeneratedDocuments = await testLspServer.GetCurrentSolution().Projects.Single().GetSourceGeneratedDocumentsAsync(); + var sourceGeneratedDocumentIdentity = sourceGeneratedDocuments.Single().Identity; + var sourceGeneratorDocumentUri = SourceGeneratedDocumentUri.Create(sourceGeneratedDocumentIdentity); + + var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedFileGetTextHandler.MethodName, + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }), CancellationToken.None); + + AssertEx.NotNull(text); + Assert.Equal("// Hello, World", text.Text); + } + + [Theory, CombinatorialData] + public async Task OpenCloseSourceGeneratedDocument(bool mutatingLspWorkspace) + { + await using var testLspServer = await CreateTestLspServerWithGeneratorAsync(mutatingLspWorkspace, "// Hello, World"); + + var sourceGeneratedDocuments = await testLspServer.GetCurrentSolution().Projects.Single().GetSourceGeneratedDocumentsAsync(); + var sourceGeneratedDocumentIdentity = sourceGeneratedDocuments.Single().Identity; + var sourceGeneratorDocumentUri = SourceGeneratedDocumentUri.Create(sourceGeneratedDocumentIdentity); + + var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedFileGetTextHandler.MethodName, + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }), CancellationToken.None); + + AssertEx.NotNull(text); + Assert.Equal("// Hello, World", text.Text); + + // Verifying opening and closing the document doesn't cause any issues. + await testLspServer.OpenDocumentAsync(sourceGeneratorDocumentUri, text.Text); + await testLspServer.CloseDocumentAsync(sourceGeneratorDocumentUri); + } + + [Theory, CombinatorialData] + public async Task OpenMultipleSourceGeneratedDocument(bool mutatingLspWorkspace) + { + await using var testLspServer = await CreateTestLspServerWithGeneratorAsync(mutatingLspWorkspace, "// Hello, World"); + + await AddGeneratorAsync(new SingleFileTestGenerator2("// Goodbye"), testLspServer.TestWorkspace); + + var sourceGeneratedDocuments = await testLspServer.GetCurrentSolution().Projects.Single().GetSourceGeneratedDocumentsAsync(); + var sourceGeneratorDocumentUris = sourceGeneratedDocuments.Select(s => SourceGeneratedDocumentUri.Create(s.Identity)); + + Assert.Equal(2, sourceGeneratorDocumentUris.Count()); + + foreach (var sourceGeneratorDocumentUri in sourceGeneratorDocumentUris) + { + var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedFileGetTextHandler.MethodName, + new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }), CancellationToken.None); + AssertEx.NotNull(text?.Text); + await testLspServer.OpenDocumentAsync(sourceGeneratorDocumentUri, text.Text); + } + + foreach (var sourceGeneratorDocumentUri in sourceGeneratorDocumentUris) + { + await testLspServer.CloseDocumentAsync(sourceGeneratorDocumentUri); + } + } + + [Theory, CombinatorialData] + public async Task RequestOnSourceGeneratedDocument(bool mutatingLspWorkspace) + { + await using var testLspServer = await CreateTestLspServerWithGeneratorAsync(mutatingLspWorkspace, "class A { }"); + + var sourceGeneratedDocuments = await testLspServer.GetCurrentSolution().Projects.Single().GetSourceGeneratedDocumentsAsync(); + var sourceGeneratedDocumentIdentity = sourceGeneratedDocuments.Single().Identity; + var sourceGeneratorDocumentUri = SourceGeneratedDocumentUri.Create(sourceGeneratedDocumentIdentity); + + var location = new LSP.Location { Uri = sourceGeneratorDocumentUri, Range = new LSP.Range { Start = new LSP.Position(0, 6), End = new LSP.Position(0, 6) } }; + + var hover = await testLspServer.ExecuteRequestAsync(LSP.Methods.TextDocumentHoverName, + CreateTextDocumentPositionParams(location), CancellationToken.None); + + AssertEx.NotNull(hover); + Assert.Contains("class A", hover.Contents.Fourth.Value); + } + + private async Task CreateTestLspServerWithGeneratorAsync(bool mutatingLspWorkspace, string generatedDocumentText) + { + var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace); + await AddGeneratorAsync(new SingleFileTestGenerator(generatedDocumentText), testLspServer.TestWorkspace); + return testLspServer; + } +} diff --git a/src/LanguageServer/ProtocolUnitTests/Workspaces/SourceGeneratedDocumentUriTests.cs b/src/LanguageServer/ProtocolUnitTests/Workspaces/SourceGeneratedDocumentUriTests.cs new file mode 100644 index 0000000000000..e71bedfbdb23e --- /dev/null +++ b/src/LanguageServer/ProtocolUnitTests/Workspaces/SourceGeneratedDocumentUriTests.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Roslyn.Test.Utilities; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.CodeAnalysis.LanguageServer.UnitTests.Workspaces; + +public class SourceGeneratedDocumentUrisTests : AbstractLanguageServerProtocolTests +{ + public SourceGeneratedDocumentUrisTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) + { + } + + [Fact] + public async Task UrisRoundTrip() + { + await using var testLspServer = await CreateTestLspServerAsync("", false); + + // Create up an identity to test with; we'll use the real project ID since we implicitly look for that when deserializing to get the + // project's debug name, but everything else we'll generate here to ensure we don't assume it exists during deserialization. + const string HintName = "HintName.cs"; + + var generatedDocumentId = DocumentId.CreateFromSerialized(testLspServer.TestWorkspace.Projects.Single().Id, Guid.NewGuid(), isSourceGenerated: true, debugName: HintName); + + var identity = new SourceGeneratedDocumentIdentity(generatedDocumentId, HintName, + new SourceGeneratorIdentity("GeneratorAssembly", "Generator.dll", new Version(1, 0), "GeneratorType"), HintName); + + var uri = SourceGeneratedDocumentUri.Create(identity); + Assert.Equal(SourceGeneratedDocumentUri.Scheme, uri.Scheme); + var deserialized = SourceGeneratedDocumentUri.DeserializeIdentity(testLspServer.TestWorkspace.CurrentSolution, uri); + + AssertEx.NotNull(deserialized); + Assert.Equal(identity, deserialized.Value); + + // Debug name is not considered as a the usual part of equality, but we want to ensure we pass this through too + Assert.Equal(generatedDocumentId.DebugName, deserialized.Value.DocumentId.DebugName); + } +} \ No newline at end of file diff --git a/src/VisualStudio/Xaml/Impl/Implementation/LanguageServer/Handler/Definitions/GoToDefinitionHandler.cs b/src/VisualStudio/Xaml/Impl/Implementation/LanguageServer/Handler/Definitions/GoToDefinitionHandler.cs index 32fd9373cc37c..a1c1046dd480a 100644 --- a/src/VisualStudio/Xaml/Impl/Implementation/LanguageServer/Handler/Definitions/GoToDefinitionHandler.cs +++ b/src/VisualStudio/Xaml/Impl/Implementation/LanguageServer/Handler/Definitions/GoToDefinitionHandler.cs @@ -58,6 +58,8 @@ public GoToDefinitionHandler(IMetadataAsSourceFileService metadataAsSourceFileSe return [.. locations]; } + var solution = document.Project.Solution; + var xamlGoToDefinitionService = document.Project.Services.GetService(); if (xamlGoToDefinitionService == null) { @@ -73,7 +75,7 @@ public GoToDefinitionHandler(IMetadataAsSourceFileService metadataAsSourceFileSe { var task = Task.Run(async () => { - foreach (var location in await this.GetLocationsAsync(definition, context, cancellationToken).ConfigureAwait(false)) + foreach (var location in await this.GetLocationsAsync(definition, document, solution, cancellationToken).ConfigureAwait(false)) { locations.Add(location); } @@ -86,17 +88,17 @@ public GoToDefinitionHandler(IMetadataAsSourceFileService metadataAsSourceFileSe return [.. locations]; } - private async Task GetLocationsAsync(XamlDefinition definition, RequestContext context, CancellationToken cancellationToken) + private async Task GetLocationsAsync(XamlDefinition definition, Document document, Solution solution, CancellationToken cancellationToken) { using var _ = ArrayBuilder.GetInstance(out var locations); if (definition is XamlSourceDefinition sourceDefinition) { - locations.AddIfNotNull(await GetSourceDefinitionLocationAsync(sourceDefinition, context, cancellationToken).ConfigureAwait(false)); + locations.AddIfNotNull(await GetSourceDefinitionLocationAsync(sourceDefinition, solution, cancellationToken).ConfigureAwait(false)); } else if (definition is XamlSymbolDefinition symbolDefinition) { - locations.AddRange(await GetSymbolDefinitionLocationsAsync(symbolDefinition, context, _metadataAsSourceFileService, _globalOptions, cancellationToken).ConfigureAwait(false)); + locations.AddRange(await GetSymbolDefinitionLocationsAsync(symbolDefinition, document, solution, _metadataAsSourceFileService, _globalOptions, cancellationToken).ConfigureAwait(false)); } else { @@ -106,14 +108,14 @@ public GoToDefinitionHandler(IMetadataAsSourceFileService metadataAsSourceFileSe return locations.ToArray(); } - private static async Task GetSourceDefinitionLocationAsync(XamlSourceDefinition sourceDefinition, RequestContext context, CancellationToken cancellationToken) + private static async Task GetSourceDefinitionLocationAsync(XamlSourceDefinition sourceDefinition, Solution solution, CancellationToken cancellationToken) { Contract.ThrowIfNull(sourceDefinition.FilePath); if (sourceDefinition.Span != null) { // If the Span is not null, use the span. - var document = context.Solution?.GetDocuments(ProtocolConversions.CreateAbsoluteUri(sourceDefinition.FilePath)).FirstOrDefault(); + var document = await solution.GetDocumentAsync(new TextDocumentIdentifier { Uri = ProtocolConversions.CreateAbsoluteUri(sourceDefinition.FilePath) }, cancellationToken).ConfigureAwait(false); if (document != null) { return await ProtocolConversions.TextSpanToLocationAsync( @@ -148,7 +150,7 @@ public GoToDefinitionHandler(IMetadataAsSourceFileService metadataAsSourceFileSe } } - private static async Task GetSymbolDefinitionLocationsAsync(XamlSymbolDefinition symbolDefinition, RequestContext context, IMetadataAsSourceFileService metadataAsSourceFileService, IGlobalOptionService globalOptions, CancellationToken cancellationToken) + private static async Task GetSymbolDefinitionLocationsAsync(XamlSymbolDefinition symbolDefinition, Document document, Solution solution, IMetadataAsSourceFileService metadataAsSourceFileService, IGlobalOptionService globalOptions, CancellationToken cancellationToken) { Contract.ThrowIfNull(symbolDefinition.Symbol); @@ -156,15 +158,14 @@ public GoToDefinitionHandler(IMetadataAsSourceFileService metadataAsSourceFileSe var symbol = symbolDefinition.Symbol; - var items = NavigableItemFactory.GetItemsFromPreferredSourceLocations(context.Solution, symbol, displayTaggedParts: null, cancellationToken); + var items = NavigableItemFactory.GetItemsFromPreferredSourceLocations(solution, symbol, displayTaggedParts: null, cancellationToken); if (items.Any()) { - RoslynDebug.AssertNotNull(context.Solution); foreach (var item in items) { - var document = await item.Document.GetRequiredDocumentAsync(context.Solution, cancellationToken).ConfigureAwait(false); + var navigableDocument = await item.Document.GetRequiredDocumentAsync(solution, cancellationToken).ConfigureAwait(false); var location = await ProtocolConversions.TextSpanToLocationAsync( - document, item.SourceSpan, item.IsStale, cancellationToken).ConfigureAwait(false); + navigableDocument, item.SourceSpan, item.IsStale, cancellationToken).ConfigureAwait(false); locations.AddIfNotNull(location); } } @@ -172,8 +173,8 @@ public GoToDefinitionHandler(IMetadataAsSourceFileService metadataAsSourceFileSe { if (metadataAsSourceFileService.IsNavigableMetadataSymbol(symbol)) { - var workspace = context.Workspace; - var project = context.Document?.GetCodeProject(); + var workspace = solution.Workspace; + var project = document.GetCodeProject(); if (workspace != null && project != null) { var options = globalOptions.GetMetadataAsSourceOptions(); diff --git a/src/VisualStudio/Xaml/Impl/Implementation/LanguageServer/Handler/Diagnostics/AbstractPullDiagnosticHandler.cs b/src/VisualStudio/Xaml/Impl/Implementation/LanguageServer/Handler/Diagnostics/AbstractPullDiagnosticHandler.cs index 55843ca0bfac0..29d2279d9c4a6 100644 --- a/src/VisualStudio/Xaml/Impl/Implementation/LanguageServer/Handler/Diagnostics/AbstractPullDiagnosticHandler.cs +++ b/src/VisualStudio/Xaml/Impl/Implementation/LanguageServer/Handler/Diagnostics/AbstractPullDiagnosticHandler.cs @@ -76,7 +76,7 @@ protected AbstractPullDiagnosticHandler(IXamlPullDiagnosticService xamlDiagnosti { if (previousResult.TextDocument != null) { - var document = context.Solution.GetDocument(previousResult.TextDocument); + var document = await context.Solution.GetDocumentAsync(previousResult.TextDocument, cancellationToken).ConfigureAwait(false); if (document == null) { // We can no longer get this document, return null for both diagnostics and resultId diff --git a/src/Workspaces/Core/Portable/Workspace/Solution/Solution.cs b/src/Workspaces/Core/Portable/Workspace/Solution/Solution.cs index 796fa8e99cf79..755bd79a8d1e7 100644 --- a/src/Workspaces/Core/Portable/Workspace/Solution/Solution.cs +++ b/src/Workspaces/Core/Portable/Workspace/Solution/Solution.cs @@ -1647,8 +1647,14 @@ public SolutionChanges GetChanges(Solution oldSolution) /// /// Gets the set of s in this with a - /// that matches the given file path. + /// that matches the given file path. This may return IDs for any type of document + /// including s or s. /// + /// + /// It's possible (but unlikely) that the same file may exist as more than one type of document in the same solution. If this + /// were to return more than one , you should not assume that just because one is a regular source file means + /// that all of them would be. + /// public ImmutableArray GetDocumentIdsWithFilePath(string? filePath) => this.SolutionState.GetDocumentIdsWithFilePath(filePath); /// diff --git a/src/Workspaces/Core/Portable/Workspace/Solution/SolutionCompilationState.WithFrozenSourceGeneratedDocumentsCompilationTracker.cs b/src/Workspaces/Core/Portable/Workspace/Solution/SolutionCompilationState.WithFrozenSourceGeneratedDocumentsCompilationTracker.cs index 7db307dade635..9d90c83c69c56 100644 --- a/src/Workspaces/Core/Portable/Workspace/Solution/SolutionCompilationState.WithFrozenSourceGeneratedDocumentsCompilationTracker.cs +++ b/src/Workspaces/Core/Portable/Workspace/Solution/SolutionCompilationState.WithFrozenSourceGeneratedDocumentsCompilationTracker.cs @@ -75,10 +75,9 @@ public bool ContainsAssemblyOrModuleOrDynamic( public ICompilationTracker Fork(ProjectState newProject, TranslationAction? translate) { - // TODO: This only needs to be implemented if a feature that operates from a source generated file then makes - // further mutations to that project, which isn't needed for now. This will be need to be fixed up when we complete - // https://github.com/dotnet/roslyn/issues/49533. - throw new NotImplementedException(); + // We'll apply the translation to the underlying tracker, and then replace the documents again. + var underlyingTracker = this.UnderlyingTracker.Fork(newProject, translate); + return new WithFrozenSourceGeneratedDocumentsCompilationTracker(underlyingTracker, _replacementDocumentStates); } public ICompilationTracker WithCreateCreationPolicy(bool forceRegeneration) diff --git a/src/Workspaces/Core/Portable/Workspace/Solution/SolutionState.cs b/src/Workspaces/Core/Portable/Workspace/Solution/SolutionState.cs index 653edb237c4e6..ae708134f522d 100644 --- a/src/Workspaces/Core/Portable/Workspace/Solution/SolutionState.cs +++ b/src/Workspaces/Core/Portable/Workspace/Solution/SolutionState.cs @@ -1180,10 +1180,7 @@ public StateChange ForkProject( return new(newSolutionState, oldProjectState, newProjectState); } - /// - /// Gets the set of s in this with a - /// that matches the given file path. - /// + /// public ImmutableArray GetDocumentIdsWithFilePath(string? filePath) { if (string.IsNullOrEmpty(filePath)) diff --git a/src/Workspaces/Core/Portable/Workspace/Workspace_Editor.cs b/src/Workspaces/Core/Portable/Workspace/Workspace_Editor.cs index 402abf9713bc4..1b56baae52f2c 100644 --- a/src/Workspaces/Core/Portable/Workspace/Workspace_Editor.cs +++ b/src/Workspaces/Core/Portable/Workspace/Workspace_Editor.cs @@ -365,7 +365,7 @@ internal void OnDocumentOpened(DocumentId documentId, SourceTextContainer textCo { var (@this, documentId, textContainer, _, requireDocumentPresentAndClosed) = data; - var oldDocument = oldSolution.GetRequiredDocument(documentId); + var oldDocument = oldSolution.GetDocument(documentId); if (oldDocument is null) { // Didn't have a document. Throw if required. Bail out gracefully if not. From 85f8f08a8beafea06dddc9a073fbbc5847cfaf68 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Mon, 23 Sep 2024 10:46:09 -0700 Subject: [PATCH 02/12] fix f# language info --- src/LanguageServer/Protocol/LanguageInfoProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/LanguageServer/Protocol/LanguageInfoProvider.cs b/src/LanguageServer/Protocol/LanguageInfoProvider.cs index e9830820a4f79..0758584cad42f 100644 --- a/src/LanguageServer/Protocol/LanguageInfoProvider.cs +++ b/src/LanguageServer/Protocol/LanguageInfoProvider.cs @@ -61,7 +61,7 @@ public LanguageInformation GetLanguageInformation(Uri uri, string? lspLanguageId return lspLanguageId switch { "csharp" => s_csharpLanguageInformation, - "fsharp" => s_csharpLanguageInformation, + "fsharp" => s_fsharpLanguageInformation, "vb" => s_vbLanguageInformation, "razor" => s_razorLanguageInformation, "xaml" => s_xamlLanguageInformation, From b778358602636b309fe3fedd6c1bc716c2d5beff Mon Sep 17 00:00:00 2001 From: David Barbet Date: Mon, 23 Sep 2024 11:59:50 -0700 Subject: [PATCH 03/12] Fix navigable item sg URIs --- src/Features/Core/Portable/Navigation/INavigableItem.cs | 6 +++--- .../Core/Def/NavigateTo/RoslynSearchResultViewFactory.cs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Features/Core/Portable/Navigation/INavigableItem.cs b/src/Features/Core/Portable/Navigation/INavigableItem.cs index c0b1d0eff144a..15ac7bea56479 100644 --- a/src/Features/Core/Portable/Navigation/INavigableItem.cs +++ b/src/Features/Core/Portable/Navigation/INavigableItem.cs @@ -48,7 +48,7 @@ internal interface INavigableItem ImmutableArray ChildItems { get; } - public record NavigableDocument(NavigableProject Project, string Name, string? FilePath, IReadOnlyList Folders, DocumentId Id, bool IsSourceGeneratedDocument, Workspace? Workspace) + public record NavigableDocument(NavigableProject Project, string Name, string? FilePath, IReadOnlyList Folders, DocumentId Id, SourceGeneratedDocumentIdentity? SourceGeneratedDocumentIdentity, Workspace? Workspace) { public static NavigableDocument FromDocument(Document document) => new( @@ -57,7 +57,7 @@ public static NavigableDocument FromDocument(Document document) document.FilePath, document.Folders, document.Id, - IsSourceGeneratedDocument: document is SourceGeneratedDocument, + SourceGeneratedDocumentIdentity: (document as SourceGeneratedDocument)?.Identity, document.Project.Solution.TryGetWorkspace()); /// @@ -66,7 +66,7 @@ public static NavigableDocument FromDocument(Document document) /// navigable item was constructed during a Find Symbols operation on the same solution instance. /// internal ValueTask GetRequiredDocumentAsync(Solution solution, CancellationToken cancellationToken) - => solution.GetRequiredDocumentAsync(Id, includeSourceGenerated: IsSourceGeneratedDocument, cancellationToken); + => solution.GetRequiredDocumentAsync(Id, includeSourceGenerated: SourceGeneratedDocumentIdentity is not null, cancellationToken); /// /// Get the of the within diff --git a/src/VisualStudio/Core/Def/NavigateTo/RoslynSearchResultViewFactory.cs b/src/VisualStudio/Core/Def/NavigateTo/RoslynSearchResultViewFactory.cs index 9b3a2b72a5a21..c189b86b8045d 100644 --- a/src/VisualStudio/Core/Def/NavigateTo/RoslynSearchResultViewFactory.cs +++ b/src/VisualStudio/Core/Def/NavigateTo/RoslynSearchResultViewFactory.cs @@ -64,9 +64,9 @@ public Task> GetPreviewPanelsAsync(S return null; Uri? absoluteUri; - if (document.IsSourceGeneratedDocument) + if (document.SourceGeneratedDocumentIdentity is not null) { - absoluteUri = ProtocolConversions.CreateUriFromSourceGeneratedFilePath(filePath); + absoluteUri = SourceGeneratedDocumentUri.Create(document.SourceGeneratedDocumentIdentity.Value); } else { From fcdc545eb92212efc4a54e56998439aef76feb38 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Tue, 24 Sep 2024 14:16:42 -0700 Subject: [PATCH 04/12] Fix diagnostics to get TextDocument instead of Document --- .../Handler/Diagnostics/AbstractPullDiagnosticHandler.cs | 2 +- .../Protocol/Handler/SpellCheck/AbstractSpellCheckingHandler.cs | 2 +- .../LanguageServer/Handler/Definitions/GoToDefinitionHandler.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/LanguageServer/Protocol/Handler/Diagnostics/AbstractPullDiagnosticHandler.cs b/src/LanguageServer/Protocol/Handler/Diagnostics/AbstractPullDiagnosticHandler.cs index 44c571c84be0f..6336c64247162 100644 --- a/src/LanguageServer/Protocol/Handler/Diagnostics/AbstractPullDiagnosticHandler.cs +++ b/src/LanguageServer/Protocol/Handler/Diagnostics/AbstractPullDiagnosticHandler.cs @@ -247,7 +247,7 @@ static async Task ProcessPreviousResultsAsync( static async Task GetIdForPreviousResultAsync(TextDocumentIdentifier textDocumentIdentifier, Solution solution, CancellationToken cancellationToken) { - var document = await solution.GetDocumentAsync(textDocumentIdentifier, cancellationToken).ConfigureAwait(false); + var document = await solution.GetTextDocumentAsync(textDocumentIdentifier, cancellationToken).ConfigureAwait(false); if (document != null) { return new ProjectOrDocumentId(document.Id); diff --git a/src/LanguageServer/Protocol/Handler/SpellCheck/AbstractSpellCheckingHandler.cs b/src/LanguageServer/Protocol/Handler/SpellCheck/AbstractSpellCheckingHandler.cs index 911eb6ebde87a..927be98a4b8c7 100644 --- a/src/LanguageServer/Protocol/Handler/SpellCheck/AbstractSpellCheckingHandler.cs +++ b/src/LanguageServer/Protocol/Handler/SpellCheck/AbstractSpellCheckingHandler.cs @@ -209,7 +209,7 @@ private async Task HandleRemovedDocumentsAsync( var textDocument = previousResult.TextDocument; if (textDocument != null) { - var document = await context.Solution.GetDocumentAsync(textDocument, cancellationToken).ConfigureAwait(false); + var document = await context.Solution.GetTextDocumentAsync(textDocument, cancellationToken).ConfigureAwait(false); if (document == null) { context.TraceInformation($"Clearing spans for removed document: {textDocument.Uri}"); diff --git a/src/VisualStudio/Xaml/Impl/Implementation/LanguageServer/Handler/Definitions/GoToDefinitionHandler.cs b/src/VisualStudio/Xaml/Impl/Implementation/LanguageServer/Handler/Definitions/GoToDefinitionHandler.cs index a1c1046dd480a..6511946b2de07 100644 --- a/src/VisualStudio/Xaml/Impl/Implementation/LanguageServer/Handler/Definitions/GoToDefinitionHandler.cs +++ b/src/VisualStudio/Xaml/Impl/Implementation/LanguageServer/Handler/Definitions/GoToDefinitionHandler.cs @@ -115,7 +115,7 @@ public GoToDefinitionHandler(IMetadataAsSourceFileService metadataAsSourceFileSe if (sourceDefinition.Span != null) { // If the Span is not null, use the span. - var document = await solution.GetDocumentAsync(new TextDocumentIdentifier { Uri = ProtocolConversions.CreateAbsoluteUri(sourceDefinition.FilePath) }, cancellationToken).ConfigureAwait(false); + var document = await solution.GetTextDocumentAsync(new TextDocumentIdentifier { Uri = ProtocolConversions.CreateAbsoluteUri(sourceDefinition.FilePath) }, cancellationToken).ConfigureAwait(false); if (document != null) { return await ProtocolConversions.TextSpanToLocationAsync( From ff6ec8d86333447b5944b9afe93e76268277555d Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 25 Sep 2024 11:20:10 -0700 Subject: [PATCH 05/12] Udate LSIF source generator tests to expect new URI --- src/Features/Lsif/GeneratorTest/ProjectStructureTests.vb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Features/Lsif/GeneratorTest/ProjectStructureTests.vb b/src/Features/Lsif/GeneratorTest/ProjectStructureTests.vb index d0fa45dd8cfed..3b7f729aaf3f5 100644 --- a/src/Features/Lsif/GeneratorTest/ProjectStructureTests.vb +++ b/src/Features/Lsif/GeneratorTest/ProjectStructureTests.vb @@ -4,8 +4,7 @@ Imports System.IO Imports System.Text -Imports System.Text.Json.Nodes -Imports Microsoft.CodeAnalysis.Editor.UnitTests.Workspaces +Imports Microsoft.CodeAnalysis.LanguageServer Imports Microsoft.CodeAnalysis.LanguageServerIndexFormat.Generator.Writing Imports Microsoft.CodeAnalysis.Test.Utilities Imports Roslyn.Test.Utilities @@ -58,7 +57,7 @@ Namespace Microsoft.CodeAnalysis.LanguageServerIndexFormat.Generator.UnitTests Dim contents = Encoding.UTF8.GetString(Convert.FromBase64String(contentBase64Encoded)) Dim compilation = Await workspace.CurrentSolution.Projects.Single().GetCompilationAsync() - Dim tree = Assert.Single(compilation.SyntaxTrees, Function(t) "source-generated:///" + t.FilePath.Replace("\"c, "/"c) = generatedDocumentVertex.Uri.OriginalString) + Dim tree = Assert.Single(compilation.SyntaxTrees, Function(t) generatedDocumentVertex.Uri.OriginalString.Contains(Path.GetFileName(t.FilePath))) Assert.Equal(tree.GetText().ToString(), contents) Next @@ -77,8 +76,9 @@ Namespace Microsoft.CodeAnalysis.LanguageServerIndexFormat.Generator.UnitTests Await TestLsifOutput.GenerateForWorkspaceAsync(workspace, New LineModeLsifJsonWriter(stringWriter)) Dim generatedDocument = Assert.Single(Await workspace.CurrentSolution.Projects.Single().GetSourceGeneratedDocumentsAsync()) + Dim uri = SourceGeneratedDocumentUri.Create(generatedDocument.Identity) Dim outputText = stringWriter.ToString() - Assert.Contains($"""uri"":""source-generated:///{generatedDocument.FilePath.Replace("\", "/")}""", outputText) + Assert.Contains(uri.AbsoluteUri, outputText) End Function End Class End Namespace From 43d56b6f30da6fc592ac5842d1e53c5dba748fd3 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 25 Sep 2024 11:26:01 -0700 Subject: [PATCH 06/12] Stabilize lsp source generator forking test --- .../ProtocolUnitTests/Workspaces/LspWorkspaceManagerTests.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/LanguageServer/ProtocolUnitTests/Workspaces/LspWorkspaceManagerTests.cs b/src/LanguageServer/ProtocolUnitTests/Workspaces/LspWorkspaceManagerTests.cs index c23799f039eac..82a453af977e6 100644 --- a/src/LanguageServer/ProtocolUnitTests/Workspaces/LspWorkspaceManagerTests.cs +++ b/src/LanguageServer/ProtocolUnitTests/Workspaces/LspWorkspaceManagerTests.cs @@ -703,6 +703,11 @@ public async Task TestUsesForkForUnchangedGeneratedFileAsync(bool mutatingLspWor var sourceGeneratedDocumentIdentity = sourceGeneratedDocuments.Single().Identity; var sourceGeneratorDocumentUri = SourceGeneratedDocumentUri.Create(sourceGeneratedDocumentIdentity); + // The workspace manager calls WithFrozenSourceGeneratedDocuments with the current DateTime. If that date time + // is the same as the generation date time, it does not fork. To ensure that it is not the same in tests, we explicitly + // wait a second to ensure the request datetime does not match the generated datetime. + await Task.Delay(TimeSpan.FromSeconds(1)); + var sourceGeneratedDocument = await OpenDocumentAndVerifyLspTextAsync(sourceGeneratorDocumentUri, testLspServer, generatorText) as SourceGeneratedDocument; AssertEx.NotNull(sourceGeneratedDocument); Assert.NotSame(testLspServer.TestWorkspace.CurrentSolution, sourceGeneratedDocument.Project.Solution); From 58c8106f03c3f65f70149a47bfd3c46439099b82 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Wed, 25 Sep 2024 14:51:53 -0700 Subject: [PATCH 07/12] Do not fork when source generator document is unchanged and add tests --- .../AbstractLanguageServerProtocolTests.cs | 13 +++- .../Workspaces/LspWorkspaceManager.cs | 64 +++++++++++++------ .../Workspaces/LspWorkspaceManagerTests.cs | 43 ++++++++++++- 3 files changed, 99 insertions(+), 21 deletions(-) diff --git a/src/EditorFeatures/TestUtilities/LanguageServer/AbstractLanguageServerProtocolTests.cs b/src/EditorFeatures/TestUtilities/LanguageServer/AbstractLanguageServerProtocolTests.cs index abfd1c5fe490d..32abedcb627ee 100644 --- a/src/EditorFeatures/TestUtilities/LanguageServer/AbstractLanguageServerProtocolTests.cs +++ b/src/EditorFeatures/TestUtilities/LanguageServer/AbstractLanguageServerProtocolTests.cs @@ -427,7 +427,7 @@ protected static void AddMappedDocument(Workspace workspace, string markup) workspace.TryApplyChanges(newSolution); } - protected static async Task AddGeneratorAsync(ISourceGenerator generator, EditorTestWorkspace workspace) + protected static async Task AddGeneratorAsync(ISourceGenerator generator, EditorTestWorkspace workspace) { var analyzerReference = new TestGeneratorReference(generator); @@ -438,7 +438,18 @@ protected static async Task AddGeneratorAsync(ISourceGenerator generator, Editor await workspace.ChangeSolutionAsync(solution); await WaitForWorkspaceOperationsAsync(workspace); + return analyzerReference; + } + + protected static async Task RemoveGeneratorAsync(AnalyzerReference reference, EditorTestWorkspace workspace) + { + var solution = workspace.CurrentSolution + .Projects.Single() + .RemoveAnalyzerReference(reference) + .Solution; + await workspace.ChangeSolutionAsync(solution); + await WaitForWorkspaceOperationsAsync(workspace); } internal static async Task>> GetAnnotatedLocationsAsync(EditorTestWorkspace workspace, Solution solution) diff --git a/src/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs b/src/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs index 7685191a53533..8b02e02eef26c 100644 --- a/src/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs +++ b/src/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs @@ -389,6 +389,7 @@ public void UpdateTrackedDocument(Uri uri, SourceText newSourceText) workspaceCurrentSolution = workspace.CurrentSolution; // Step 3: Check to see if the LSP text matches the workspace text. + var documentsInWorkspace = GetDocumentsForUris(_trackedDocuments.Keys.ToImmutableArray(), workspaceCurrentSolution); var sourceGeneratedDocuments = _trackedDocuments.Keys.Where(static uri => uri.Scheme == SourceGeneratedDocumentUri.Scheme) @@ -396,10 +397,15 @@ public void UpdateTrackedDocument(Uri uri, SourceText newSourceText) .Where(tuple => tuple.identity.HasValue) .SelectAsArray(tuple => (tuple.identity!.Value, DateTime.Now, tuple.Text)); - // We don't want to check if the source generated document text matches the workspace text because that - // will trigger generators to run. We don't want that to happen in the queue dispatch as it could be quite slow. + // First we check if normal document text matches the workspace solution. + // This does not look at source generated documents. var doesAllTextMatch = await DoesAllTextMatchWorkspaceSolutionAsync(documentsInWorkspace, cancellationToken).ConfigureAwait(false); - if (doesAllTextMatch && !sourceGeneratedDocuments.Any()) + + // Then we check if source generated document text matches the workspace solution. + // This is intentionally done differently from normal documents because the normal method will cause + // source generators to run which we do not want to do in queue dispatch. + var doesAllSourceGeneratedTextMatch = DoesAllSourceGeneratedTextMatchWorkspaceSolution(sourceGeneratedDocuments, workspaceCurrentSolution); + if (doesAllTextMatch && doesAllSourceGeneratedTextMatch) { // Remember that the current LSP text matches the text in this workspace solution. _cachedLspSolutions[workspace] = (forkedFromVersion: null, workspaceCurrentSolution); @@ -412,30 +418,22 @@ public void UpdateTrackedDocument(Uri uri, SourceText newSourceText) // Step 5: Fork a new solution from the workspace with the LSP text applied. var lspSolution = workspaceCurrentSolution; - // If the workspace text matched but we have source generated documents open, we can - // leave the normal documents as-is (the text matched) and just fork with the frozen sg docs. + // If the workspace text matched we can leave the normal documents as-is if (!doesAllTextMatch) { foreach (var (uri, workspaceDocuments) in documentsInWorkspace) lspSolution = lspSolution.WithDocumentText(workspaceDocuments.Select(d => d.Id), _trackedDocuments[uri].Text); } - lspSolution = lspSolution.WithFrozenSourceGeneratedDocuments(sourceGeneratedDocuments); - - // Did we actually have to fork anything? WithFrozenSourceGeneratedDocuments will return the same instance if we were able to - // immediately determine we already had the same generated contents - if (lspSolution == workspaceCurrentSolution) + // If the source generated documents matched we can leave the source generated documents as-is + if (!doesAllSourceGeneratedTextMatch) { - // Remember that the current LSP text matches the text in this workspace solution. - _cachedLspSolutions[workspace] = (forkedFromVersion: null, workspaceCurrentSolution); - return (workspaceCurrentSolution, IsForked: false); - } - else - { - // Remember this forked solution and the workspace version it was forked from. - _cachedLspSolutions[workspace] = (workspaceCurrentSolution.WorkspaceVersion, lspSolution); - return (lspSolution, IsForked: true); + lspSolution = lspSolution.WithFrozenSourceGeneratedDocuments(sourceGeneratedDocuments); } + + // Remember this forked solution and the workspace version it was forked from. + _cachedLspSolutions[workspace] = (workspaceCurrentSolution.WorkspaceVersion, lspSolution); + return (lspSolution, IsForked: true); } async ValueTask TryOpenAndEditDocumentsInMutatingWorkspaceAsync(Workspace workspace) @@ -471,6 +469,34 @@ await workspace.TryOnDocumentOpenedAsync( } } + /// + /// Checks if the open source generator document contents matches the contents of the workspace solution. + /// This looks at the source generator state explicitly to avoid actually running source generators + /// + private static bool DoesAllSourceGeneratedTextMatchWorkspaceSolution( + ImmutableArray<(SourceGeneratedDocumentIdentity Identity, DateTime Generated, SourceText Text)> sourceGenereatedDocuments, + Solution workspaceSolution) + { + var compilationState = workspaceSolution.CompilationState; + foreach (var (identity, _, text) in sourceGenereatedDocuments) + { + var existingState = compilationState.TryGetSourceGeneratedDocumentStateForAlreadyGeneratedId(identity.DocumentId); + if (existingState is null) + { + // We don't have existing state for at least one of the documents, so the text does cannot match. + return false; + } + + var newState = existingState.WithText(text); + if (newState != existingState) + { + return false; + } + } + + return true; + } + /// /// Given a set of documents from the workspace current solution, verify that the LSP text is the same as the document contents. /// diff --git a/src/LanguageServer/ProtocolUnitTests/Workspaces/LspWorkspaceManagerTests.cs b/src/LanguageServer/ProtocolUnitTests/Workspaces/LspWorkspaceManagerTests.cs index 82a453af977e6..153694320125d 100644 --- a/src/LanguageServer/ProtocolUnitTests/Workspaces/LspWorkspaceManagerTests.cs +++ b/src/LanguageServer/ProtocolUnitTests/Workspaces/LspWorkspaceManagerTests.cs @@ -693,7 +693,7 @@ await testLspServer.ReplaceTextAsync(documentUri, } [Theory, CombinatorialData] - public async Task TestUsesForkForUnchangedGeneratedFileAsync(bool mutatingLspWorkspace) + public async Task TestDoesNotUseForkForUnchangedGeneratedFileAsync(bool mutatingLspWorkspace) { var generatorText = "// Hello World!"; await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace); @@ -710,6 +710,47 @@ public async Task TestUsesForkForUnchangedGeneratedFileAsync(bool mutatingLspWor var sourceGeneratedDocument = await OpenDocumentAndVerifyLspTextAsync(sourceGeneratorDocumentUri, testLspServer, generatorText) as SourceGeneratedDocument; AssertEx.NotNull(sourceGeneratedDocument); + Assert.Same(testLspServer.TestWorkspace.CurrentSolution, sourceGeneratedDocument.Project.Solution); + } + + [Theory, CombinatorialData] + public async Task TestForksWithDifferentGeneratedContentsAsync(bool mutatingLspWorkspace) + { + var workspaceGeneratedText = "// Hello World!"; + var lspGeneratedText = "// Hello LSP!"; + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace); + await AddGeneratorAsync(new SingleFileTestGenerator(workspaceGeneratedText), testLspServer.TestWorkspace); + + var sourceGeneratedDocuments = await testLspServer.GetCurrentSolution().Projects.Single().GetSourceGeneratedDocumentsAsync(); + var sourceGeneratedDocumentIdentity = sourceGeneratedDocuments.Single().Identity; + var sourceGeneratorDocumentUri = SourceGeneratedDocumentUri.Create(sourceGeneratedDocumentIdentity); + + var sourceGeneratedDocument = await OpenDocumentAndVerifyLspTextAsync(sourceGeneratorDocumentUri, testLspServer, lspGeneratedText) as SourceGeneratedDocument; + AssertEx.NotNull(sourceGeneratedDocument); + Assert.NotSame(testLspServer.TestWorkspace.CurrentSolution, sourceGeneratedDocument.Project.Solution); + } + + [Theory, CombinatorialData] + public async Task TestForksWithRemovedGeneratorAsync(bool mutatingLspWorkspace) + { + var generatorText = "// Hello World!"; + await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace); + var generatorReference = await AddGeneratorAsync(new SingleFileTestGenerator(generatorText), testLspServer.TestWorkspace); + + var sourceGeneratedDocuments = await testLspServer.GetCurrentSolution().Projects.Single().GetSourceGeneratedDocumentsAsync(); + var sourceGeneratedDocumentIdentity = sourceGeneratedDocuments.Single().Identity; + var sourceGeneratorDocumentUri = SourceGeneratedDocumentUri.Create(sourceGeneratedDocumentIdentity); + + var sourceGeneratedDocument = await OpenDocumentAndVerifyLspTextAsync(sourceGeneratorDocumentUri, testLspServer, generatorText) as SourceGeneratedDocument; + AssertEx.NotNull(sourceGeneratedDocument); + Assert.Same(testLspServer.TestWorkspace.CurrentSolution, sourceGeneratedDocument.Project.Solution); + + // Remove the generator and verify the document is forked. + await RemoveGeneratorAsync(generatorReference, testLspServer.TestWorkspace); + + var (_, removedSourceGeneratorDocument) = await GetLspWorkspaceAndDocumentAsync(sourceGeneratorDocumentUri, testLspServer).ConfigureAwait(false); + AssertEx.NotNull(sourceGeneratedDocument as SourceGeneratedDocument); + Assert.Equal(generatorText, (await sourceGeneratedDocument.GetTextAsync(CancellationToken.None)).ToString()); Assert.NotSame(testLspServer.TestWorkspace.CurrentSolution, sourceGeneratedDocument.Project.Solution); } From 4d5055c21fd63c85bc56f0d2d9e352e084bdf3cc Mon Sep 17 00:00:00 2001 From: David Barbet Date: Fri, 27 Sep 2024 10:55:56 -0700 Subject: [PATCH 08/12] file scoped namespace --- .../Extensions/SourceGeneratedDocumentUri.cs | 171 +++++++++--------- 1 file changed, 85 insertions(+), 86 deletions(-) diff --git a/src/LanguageServer/Protocol/Extensions/SourceGeneratedDocumentUri.cs b/src/LanguageServer/Protocol/Extensions/SourceGeneratedDocumentUri.cs index 5b9f2743f3c4d..fc8657b298f8e 100644 --- a/src/LanguageServer/Protocol/Extensions/SourceGeneratedDocumentUri.cs +++ b/src/LanguageServer/Protocol/Extensions/SourceGeneratedDocumentUri.cs @@ -6,107 +6,106 @@ using System.Linq; using Roslyn.Utilities; -namespace Microsoft.CodeAnalysis.LanguageServer +namespace Microsoft.CodeAnalysis.LanguageServer; + +// For source generated documents, we'll produce a URI specifically for LSP that has a scheme the client can register for; the "host" portion will +// just be the project ID of the document, and the path will be the hint text for the document. This recognizes that VS Code shows just the local +// path portion in the UI and thus embedding more into the path will appear and we don't want that. The rest of the stuff to serialize, namely the DocumentId, and any information +// for the SourceGeneratedDocumentIdentity are put in as query string arguments +// +// For example, the URI can look like: +// +// roslyn-source-generated://E7D5BCFA-E345-4029-9D12-3EDCD0FB0F6B/Generated.cs?documentId=8E4C0B71-4044-4247-BDD0-04AF4C9E1677&assembly=Generator... +// +// where the first GUID is the project ID, the second GUID is the document ID. +internal static class SourceGeneratedDocumentUri { - // For source generated documents, we'll produce a URI specifically for LSP that has a scheme the client can register for; the "host" portion will - // just be the project ID of the document, and the path will be the hint text for the document. This recognizes that VS Code shows just the local - // path portion in the UI and thus embedding more into the path will appear and we don't want that. The rest of the stuff to serialize, namely the DocumentId, and any information - // for the SourceGeneratedDocumentIdentity are put in as query string arguments - // - // For example, the URI can look like: - // - // roslyn-source-generated://E7D5BCFA-E345-4029-9D12-3EDCD0FB0F6B/Generated.cs?documentId=8E4C0B71-4044-4247-BDD0-04AF4C9E1677&assembly=Generator... - // - // where the first GUID is the project ID, the second GUID is the document ID. - internal static class SourceGeneratedDocumentUri - { - public const string Scheme = "roslyn-source-generated"; - private const string GuidFormat = "D"; + public const string Scheme = "roslyn-source-generated"; + private const string GuidFormat = "D"; - public static Uri Create(SourceGeneratedDocumentIdentity identity) - { - // Ensure the hint path is converted to a URI-friendly format - var hintPathParts = identity.HintName.Split('\\'); - var hintPathPortion = string.Join("/", hintPathParts.Select(Uri.EscapeDataString)); + public static Uri Create(SourceGeneratedDocumentIdentity identity) + { + // Ensure the hint path is converted to a URI-friendly format + var hintPathParts = identity.HintName.Split('\\'); + var hintPathPortion = string.Join("/", hintPathParts.Select(Uri.EscapeDataString)); - var uri = Scheme + "://" + - identity.DocumentId.ProjectId.Id.ToString(GuidFormat) + "/" + - hintPathPortion + - "?documentId=" + identity.DocumentId.Id.ToString(GuidFormat) + - "&hintName=" + Uri.EscapeDataString(identity.HintName) + - "&assemblyName=" + Uri.EscapeDataString(identity.Generator.AssemblyName) + - "&assemblyVersion=" + Uri.EscapeDataString(identity.Generator.AssemblyVersion.ToString()) + - "&typeName=" + Uri.EscapeDataString(identity.Generator.TypeName); + var uri = Scheme + "://" + + identity.DocumentId.ProjectId.Id.ToString(GuidFormat) + "/" + + hintPathPortion + + "?documentId=" + identity.DocumentId.Id.ToString(GuidFormat) + + "&hintName=" + Uri.EscapeDataString(identity.HintName) + + "&assemblyName=" + Uri.EscapeDataString(identity.Generator.AssemblyName) + + "&assemblyVersion=" + Uri.EscapeDataString(identity.Generator.AssemblyVersion.ToString()) + + "&typeName=" + Uri.EscapeDataString(identity.Generator.TypeName); - // If we have a path (which is technically optional) also append it - if (identity.Generator.AssemblyPath != null) - uri += "&assemblyPath=" + Uri.EscapeDataString(identity.Generator.AssemblyPath); + // If we have a path (which is technically optional) also append it + if (identity.Generator.AssemblyPath != null) + uri += "&assemblyPath=" + Uri.EscapeDataString(identity.Generator.AssemblyPath); - return ProtocolConversions.CreateAbsoluteUri(uri); - } + return ProtocolConversions.CreateAbsoluteUri(uri); + } - public static SourceGeneratedDocumentIdentity? DeserializeIdentity(Solution solution, Uri documentUri) - { - // This is a generated document, so the "host" portion is just the GUID of the project ID; we'll parse that into an ID and then - // look up the project in the Solution. This relies on the fact that technically the only part of the ID that matters for equality - // is the GUID; looking up the project again means we can then recover the ProjectId with the debug name, so anybody looking at a crash - // dump sees a "normal" ID. It also means if the project is gone we can trivially say there are no usable IDs anymore. - var projectIdGuidOnly = ProjectId.CreateFromSerialized(Guid.ParseExact(documentUri.Host, GuidFormat)); - var projectId = solution.GetProject(projectIdGuidOnly)?.Id; + public static SourceGeneratedDocumentIdentity? DeserializeIdentity(Solution solution, Uri documentUri) + { + // This is a generated document, so the "host" portion is just the GUID of the project ID; we'll parse that into an ID and then + // look up the project in the Solution. This relies on the fact that technically the only part of the ID that matters for equality + // is the GUID; looking up the project again means we can then recover the ProjectId with the debug name, so anybody looking at a crash + // dump sees a "normal" ID. It also means if the project is gone we can trivially say there are no usable IDs anymore. + var projectIdGuidOnly = ProjectId.CreateFromSerialized(Guid.ParseExact(documentUri.Host, GuidFormat)); + var projectId = solution.GetProject(projectIdGuidOnly)?.Id; - if (projectId == null) - return null; + if (projectId == null) + return null; - Guid? documentIdGuid = null; - string? hintName = null; - string? assemblyName = null; - string? assemblyPath = null; // this one is actually OK if it's null, since it's optional - Version? assemblyVersion = null; - string? typeName = null; + Guid? documentIdGuid = null; + string? hintName = null; + string? assemblyName = null; + string? assemblyPath = null; // this one is actually OK if it's null, since it's optional + Version? assemblyVersion = null; + string? typeName = null; - // Parse the query string apart and grab everything from it - foreach (var part in documentUri.Query.TrimStart('?').Split('&')) - { - var equals = part.IndexOf('='); - Contract.ThrowIfTrue(equals <= 0); + // Parse the query string apart and grab everything from it + foreach (var part in documentUri.Query.TrimStart('?').Split('&')) + { + var equals = part.IndexOf('='); + Contract.ThrowIfTrue(equals <= 0); #if NET - var name = part.AsSpan()[0..equals]; + var name = part.AsSpan()[0..equals]; #else - var name = part.Substring(0, equals); + var name = part.Substring(0, equals); #endif - var value = Uri.UnescapeDataString(part.Substring(equals + 1)); + var value = Uri.UnescapeDataString(part.Substring(equals + 1)); - if (name.Equals("documentId", StringComparison.Ordinal)) - documentIdGuid = Guid.ParseExact(value, GuidFormat); - else if (name.Equals("hintName", StringComparison.Ordinal)) - hintName = value.ToString(); - else if (name.Equals("assemblyName", StringComparison.Ordinal)) - assemblyName = value.ToString(); - else if (name.Equals("assemblyPath", StringComparison.Ordinal)) - assemblyPath = value.ToString(); - else if (name.Equals("assemblyVersion", StringComparison.Ordinal)) - assemblyVersion = Version.Parse(value); - else if (name.Equals("typeName", StringComparison.Ordinal)) - typeName = value.ToString(); - } + if (name.Equals("documentId", StringComparison.Ordinal)) + documentIdGuid = Guid.ParseExact(value, GuidFormat); + else if (name.Equals("hintName", StringComparison.Ordinal)) + hintName = value.ToString(); + else if (name.Equals("assemblyName", StringComparison.Ordinal)) + assemblyName = value.ToString(); + else if (name.Equals("assemblyPath", StringComparison.Ordinal)) + assemblyPath = value.ToString(); + else if (name.Equals("assemblyVersion", StringComparison.Ordinal)) + assemblyVersion = Version.Parse(value); + else if (name.Equals("typeName", StringComparison.Ordinal)) + typeName = value.ToString(); + } - Contract.ThrowIfNull(documentIdGuid, "Expected a URI with a documentId parameter."); - Contract.ThrowIfNull(hintName, "Expected a URI with a hintName parameter."); - Contract.ThrowIfNull(assemblyName, "Expected a URI with an assemblyName parameter."); - Contract.ThrowIfNull(assemblyVersion, "Expected a URI with an assemblyVersion parameter."); - Contract.ThrowIfNull(typeName, "Expected a URI with an typeName parameter."); + Contract.ThrowIfNull(documentIdGuid, "Expected a URI with a documentId parameter."); + Contract.ThrowIfNull(hintName, "Expected a URI with a hintName parameter."); + Contract.ThrowIfNull(assemblyName, "Expected a URI with an assemblyName parameter."); + Contract.ThrowIfNull(assemblyVersion, "Expected a URI with an assemblyVersion parameter."); + Contract.ThrowIfNull(typeName, "Expected a URI with an typeName parameter."); - var documentId = DocumentId.CreateFromSerialized(projectId, documentIdGuid.Value, isSourceGenerated: true, hintName); + var documentId = DocumentId.CreateFromSerialized(projectId, documentIdGuid.Value, isSourceGenerated: true, hintName); - return new SourceGeneratedDocumentIdentity( - documentId, - hintName, - new SourceGeneratorIdentity( - assemblyName, - assemblyPath, - assemblyVersion, - typeName), - hintName); - } + return new SourceGeneratedDocumentIdentity( + documentId, + hintName, + new SourceGeneratorIdentity( + assemblyName, + assemblyPath, + assemblyVersion, + typeName), + hintName); } } \ No newline at end of file From 6d7524c864ed779842132a015a5d19595806a4da Mon Sep 17 00:00:00 2001 From: David Barbet Date: Fri, 27 Sep 2024 11:37:45 -0700 Subject: [PATCH 09/12] Cleanup sg URI serialization and deserialization --- .../Extensions/SourceGeneratedDocumentUri.cs | 80 ++++++++----------- 1 file changed, 32 insertions(+), 48 deletions(-) diff --git a/src/LanguageServer/Protocol/Extensions/SourceGeneratedDocumentUri.cs b/src/LanguageServer/Protocol/Extensions/SourceGeneratedDocumentUri.cs index fc8657b298f8e..20636fbafcd8e 100644 --- a/src/LanguageServer/Protocol/Extensions/SourceGeneratedDocumentUri.cs +++ b/src/LanguageServer/Protocol/Extensions/SourceGeneratedDocumentUri.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Specialized; using System.Linq; using Roslyn.Utilities; @@ -22,6 +23,12 @@ internal static class SourceGeneratedDocumentUri { public const string Scheme = "roslyn-source-generated"; private const string GuidFormat = "D"; + private const string DocumentIdParam = "documentId"; + private const string HintNameParam = "hintName"; + private const string AssemblyNameParam = "assemblyName"; + private const string AssemblyVersionParam = "assemblyVersion"; + private const string AssemblyPathParam = "assemblyPath"; + private const string TypeNameParam = "typeName"; public static Uri Create(SourceGeneratedDocumentIdentity identity) { @@ -29,18 +36,18 @@ public static Uri Create(SourceGeneratedDocumentIdentity identity) var hintPathParts = identity.HintName.Split('\\'); var hintPathPortion = string.Join("/", hintPathParts.Select(Uri.EscapeDataString)); - var uri = Scheme + "://" + - identity.DocumentId.ProjectId.Id.ToString(GuidFormat) + "/" + - hintPathPortion + - "?documentId=" + identity.DocumentId.Id.ToString(GuidFormat) + - "&hintName=" + Uri.EscapeDataString(identity.HintName) + - "&assemblyName=" + Uri.EscapeDataString(identity.Generator.AssemblyName) + - "&assemblyVersion=" + Uri.EscapeDataString(identity.Generator.AssemblyVersion.ToString()) + - "&typeName=" + Uri.EscapeDataString(identity.Generator.TypeName); + var projectId = identity.DocumentId.ProjectId.Id.ToString(GuidFormat); + var documentId = identity.DocumentId.Id.ToString(GuidFormat); + var hintName = Uri.EscapeDataString(identity.HintName); + var assemblyName = Uri.EscapeDataString(identity.Generator.AssemblyName); + var assemblyVersion = Uri.EscapeDataString(identity.Generator.AssemblyVersion.ToString()); + var typeName = Uri.EscapeDataString(identity.Generator.TypeName); + + var uri = $"{Scheme}://{projectId}/{hintPathPortion}?{DocumentIdParam}={documentId}&{HintNameParam}={hintName}&{AssemblyNameParam}={assemblyName}&{AssemblyVersionParam}={assemblyVersion}&{TypeNameParam}={typeName}"; // If we have a path (which is technically optional) also append it if (identity.Generator.AssemblyPath != null) - uri += "&assemblyPath=" + Uri.EscapeDataString(identity.Generator.AssemblyPath); + uri += $"&{AssemblyPathParam}={Uri.EscapeDataString(identity.Generator.AssemblyPath)}"; return ProtocolConversions.CreateAbsoluteUri(uri); } @@ -57,46 +64,16 @@ public static Uri Create(SourceGeneratedDocumentIdentity identity) if (projectId == null) return null; - Guid? documentIdGuid = null; - string? hintName = null; - string? assemblyName = null; - string? assemblyPath = null; // this one is actually OK if it's null, since it's optional - Version? assemblyVersion = null; - string? typeName = null; - - // Parse the query string apart and grab everything from it - foreach (var part in documentUri.Query.TrimStart('?').Split('&')) - { - var equals = part.IndexOf('='); - Contract.ThrowIfTrue(equals <= 0); -#if NET - var name = part.AsSpan()[0..equals]; -#else - var name = part.Substring(0, equals); -#endif - var value = Uri.UnescapeDataString(part.Substring(equals + 1)); - - if (name.Equals("documentId", StringComparison.Ordinal)) - documentIdGuid = Guid.ParseExact(value, GuidFormat); - else if (name.Equals("hintName", StringComparison.Ordinal)) - hintName = value.ToString(); - else if (name.Equals("assemblyName", StringComparison.Ordinal)) - assemblyName = value.ToString(); - else if (name.Equals("assemblyPath", StringComparison.Ordinal)) - assemblyPath = value.ToString(); - else if (name.Equals("assemblyVersion", StringComparison.Ordinal)) - assemblyVersion = Version.Parse(value); - else if (name.Equals("typeName", StringComparison.Ordinal)) - typeName = value.ToString(); - } + var query = System.Web.HttpUtility.ParseQueryString(documentUri.Query); + var documentIdGuid = Guid.ParseExact(GetRequiredQueryValue(DocumentIdParam, query, documentUri.Query), GuidFormat); + var hintName = GetRequiredQueryValue(HintNameParam, query, documentUri.Query); + var assemblyName = GetRequiredQueryValue(AssemblyNameParam, query, documentUri.Query); + // this one is actually OK if it's null, since it's optional + var assemblyPath = query[AssemblyPathParam]; + var assemblyVersion = Version.Parse(GetRequiredQueryValue(AssemblyVersionParam, query, documentUri.Query)); + var typeName = GetRequiredQueryValue(TypeNameParam, query, documentUri.Query); - Contract.ThrowIfNull(documentIdGuid, "Expected a URI with a documentId parameter."); - Contract.ThrowIfNull(hintName, "Expected a URI with a hintName parameter."); - Contract.ThrowIfNull(assemblyName, "Expected a URI with an assemblyName parameter."); - Contract.ThrowIfNull(assemblyVersion, "Expected a URI with an assemblyVersion parameter."); - Contract.ThrowIfNull(typeName, "Expected a URI with an typeName parameter."); - - var documentId = DocumentId.CreateFromSerialized(projectId, documentIdGuid.Value, isSourceGenerated: true, hintName); + var documentId = DocumentId.CreateFromSerialized(projectId, documentIdGuid, isSourceGenerated: true, hintName); return new SourceGeneratedDocumentIdentity( documentId, @@ -107,5 +84,12 @@ public static Uri Create(SourceGeneratedDocumentIdentity identity) assemblyVersion, typeName), hintName); + + static string GetRequiredQueryValue(string keyName, NameValueCollection nameValueCollection, string query) + { + var value = nameValueCollection[keyName]; + Contract.ThrowIfNull(value, $"Could not get {keyName} from {query}"); + return value; + } } } \ No newline at end of file From 6d10181f45c0c6b4004ed82733b9d5af81035fab Mon Sep 17 00:00:00 2001 From: David Barbet Date: Fri, 27 Sep 2024 11:45:34 -0700 Subject: [PATCH 10/12] Rename handler from file -> document --- ...cs => SourceGeneratedDocumentGetTextHandler.cs} | 14 +++++--------- .../Workspaces/SourceGeneratedDocumentTests.cs | 6 +++--- 2 files changed, 8 insertions(+), 12 deletions(-) rename src/LanguageServer/Protocol/Handler/SourceGenerators/{SourceGeneratedFileGetTextHandler.cs => SourceGeneratedDocumentGetTextHandler.cs} (83%) diff --git a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedFileGetTextHandler.cs b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentGetTextHandler.cs similarity index 83% rename from src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedFileGetTextHandler.cs rename to src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentGetTextHandler.cs index abbb529cb05ad..6d552a72b48dd 100644 --- a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedFileGetTextHandler.cs +++ b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentGetTextHandler.cs @@ -12,17 +12,13 @@ namespace Microsoft.CodeAnalysis.LanguageServer.Handler; -[ExportCSharpVisualBasicStatelessLspService(typeof(SourceGeneratedFileGetTextHandler)), Shared] +[ExportCSharpVisualBasicStatelessLspService(typeof(SourceGeneratedDocumentGetTextHandler)), Shared] [Method(MethodName)] -internal sealed class SourceGeneratedFileGetTextHandler : ILspServiceDocumentRequestHandler +[method: ImportingConstructor] +[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] +internal sealed class SourceGeneratedDocumentGetTextHandler() : ILspServiceDocumentRequestHandler { - public const string MethodName = "sourceGeneratedFile/_roslyn_getText"; - - [ImportingConstructor] - [Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] - public SourceGeneratedFileGetTextHandler() - { - } + public const string MethodName = "sourceGeneratedDocument/_roslyn_getText"; public bool MutatesSolutionState => false; public bool RequiresLSPSolution => true; diff --git a/src/LanguageServer/ProtocolUnitTests/Workspaces/SourceGeneratedDocumentTests.cs b/src/LanguageServer/ProtocolUnitTests/Workspaces/SourceGeneratedDocumentTests.cs index d90554e61a6ca..1889e398f460e 100644 --- a/src/LanguageServer/ProtocolUnitTests/Workspaces/SourceGeneratedDocumentTests.cs +++ b/src/LanguageServer/ProtocolUnitTests/Workspaces/SourceGeneratedDocumentTests.cs @@ -26,7 +26,7 @@ public async Task ReturnsTextForSourceGeneratedDocument(bool mutatingLspWorkspac var sourceGeneratedDocumentIdentity = sourceGeneratedDocuments.Single().Identity; var sourceGeneratorDocumentUri = SourceGeneratedDocumentUri.Create(sourceGeneratedDocumentIdentity); - var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedFileGetTextHandler.MethodName, + var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }), CancellationToken.None); AssertEx.NotNull(text); @@ -42,7 +42,7 @@ public async Task OpenCloseSourceGeneratedDocument(bool mutatingLspWorkspace) var sourceGeneratedDocumentIdentity = sourceGeneratedDocuments.Single().Identity; var sourceGeneratorDocumentUri = SourceGeneratedDocumentUri.Create(sourceGeneratedDocumentIdentity); - var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedFileGetTextHandler.MethodName, + var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }), CancellationToken.None); AssertEx.NotNull(text); @@ -67,7 +67,7 @@ public async Task OpenMultipleSourceGeneratedDocument(bool mutatingLspWorkspace) foreach (var sourceGeneratorDocumentUri in sourceGeneratorDocumentUris) { - var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedFileGetTextHandler.MethodName, + var text = await testLspServer.ExecuteRequestAsync(SourceGeneratedDocumentGetTextHandler.MethodName, new SourceGeneratorGetTextParams(new LSP.TextDocumentIdentifier { Uri = sourceGeneratorDocumentUri }), CancellationToken.None); AssertEx.NotNull(text?.Text); await testLspServer.OpenDocumentAsync(sourceGeneratorDocumentUri, text.Text); From e2f7d0071886f7c43138219ea7e8a72c9f8f9f26 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Mon, 30 Sep 2024 11:47:10 -0700 Subject: [PATCH 11/12] remove normalization of hint path (already handled in compiler) --- .../Protocol/Extensions/SourceGeneratedDocumentUri.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/LanguageServer/Protocol/Extensions/SourceGeneratedDocumentUri.cs b/src/LanguageServer/Protocol/Extensions/SourceGeneratedDocumentUri.cs index 20636fbafcd8e..c42bc1002eae8 100644 --- a/src/LanguageServer/Protocol/Extensions/SourceGeneratedDocumentUri.cs +++ b/src/LanguageServer/Protocol/Extensions/SourceGeneratedDocumentUri.cs @@ -32,10 +32,7 @@ internal static class SourceGeneratedDocumentUri public static Uri Create(SourceGeneratedDocumentIdentity identity) { - // Ensure the hint path is converted to a URI-friendly format - var hintPathParts = identity.HintName.Split('\\'); - var hintPathPortion = string.Join("/", hintPathParts.Select(Uri.EscapeDataString)); - + var hintPath = Uri.EscapeDataString(identity.HintName); var projectId = identity.DocumentId.ProjectId.Id.ToString(GuidFormat); var documentId = identity.DocumentId.Id.ToString(GuidFormat); var hintName = Uri.EscapeDataString(identity.HintName); @@ -43,7 +40,7 @@ public static Uri Create(SourceGeneratedDocumentIdentity identity) var assemblyVersion = Uri.EscapeDataString(identity.Generator.AssemblyVersion.ToString()); var typeName = Uri.EscapeDataString(identity.Generator.TypeName); - var uri = $"{Scheme}://{projectId}/{hintPathPortion}?{DocumentIdParam}={documentId}&{HintNameParam}={hintName}&{AssemblyNameParam}={assemblyName}&{AssemblyVersionParam}={assemblyVersion}&{TypeNameParam}={typeName}"; + var uri = $"{Scheme}://{projectId}/{hintPath}?{DocumentIdParam}={documentId}&{HintNameParam}={hintName}&{AssemblyNameParam}={assemblyName}&{AssemblyVersionParam}={assemblyVersion}&{TypeNameParam}={typeName}"; // If we have a path (which is technically optional) also append it if (identity.Generator.AssemblyPath != null) @@ -92,4 +89,4 @@ static string GetRequiredQueryValue(string keyName, NameValueCollection nameValu return value; } } -} \ No newline at end of file +} From 51c16fb42239bb4a8e3fd7bcc3cf028fc4d90335 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Mon, 30 Sep 2024 11:55:30 -0700 Subject: [PATCH 12/12] improve comment --- .../SourceGeneratedDocumentGetTextHandler.cs | 10 ++++++---- .../Protocol/Workspaces/LspWorkspaceManager.cs | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentGetTextHandler.cs b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentGetTextHandler.cs index 6d552a72b48dd..31402aaa61f25 100644 --- a/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentGetTextHandler.cs +++ b/src/LanguageServer/Protocol/Handler/SourceGenerators/SourceGeneratedDocumentGetTextHandler.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. @@ -34,11 +34,13 @@ public async Task HandleRequestAsync(SourceGenerato // source-generated files only, this would indicate that something else has gone wrong. Contract.ThrowIfFalse(document is SourceGeneratedDocument); - // If a source file is open we ensure the generated document matches what's currently open in the LSP client so that way everything - // stays in sync and we don't have mismatched ranges. But for this particular case, we want to ignore that. + // When a user has a open source-generated file, we ensure that the contents in the LSP snapshot match the contents that we + // get through didOpen/didChanges, like any other file. That way operations in LSP file are in sync with the + // contents the user has. However in this case, we don't want to look at that frozen text, but look at what the + // generator would generate if we ran it again. Otherwise, we'll get "stuck" and never update the file with something new. document = await document.Project.Solution.WithoutFrozenSourceGeneratedDocuments().GetDocumentAsync(document.Id, includeSourceGenerated: true, cancellationToken).ConfigureAwait(false); var text = document != null ? await document.GetTextAsync(cancellationToken).ConfigureAwait(false) : null; return new SourceGeneratedDocumentText(text?.ToString()); } -} \ No newline at end of file +} diff --git a/src/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs b/src/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs index 8b02e02eef26c..41864db53d6dc 100644 --- a/src/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs +++ b/src/LanguageServer/Protocol/Workspaces/LspWorkspaceManager.cs @@ -483,7 +483,7 @@ private static bool DoesAllSourceGeneratedTextMatchWorkspaceSolution( var existingState = compilationState.TryGetSourceGeneratedDocumentStateForAlreadyGeneratedId(identity.DocumentId); if (existingState is null) { - // We don't have existing state for at least one of the documents, so the text does cannot match. + // We don't have existing state for at least one of the documents, so the text cannot match. return false; }