Skip to content

Commit

Permalink
Enable support for an LSP client to open source generated files
Browse files Browse the repository at this point in the history
This allows source-generated documents to be opened by an LSP client.
A custom URI format is defined which uniquely defines a source generated
document by serializing the DocumentId; this URI is returned any time
we need to navigate to a document or reference the document in any way.
A custom request is also defined to fetch the text of a document which
can be used for the client; our VS Code extension will implement a
TextDocumentContentProvider to call that.
  • Loading branch information
jasonmalinowski committed Jun 26, 2023
1 parent f8cada0 commit 09447c1
Show file tree
Hide file tree
Showing 19 changed files with 183 additions and 105 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -642,7 +642,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 TestWorkspace.CurrentSolution.GetDocumentsAsync(documentUri, CancellationToken.None)).First().GetTextAsync(CancellationToken.None).ConfigureAwait(false);
text = sourceText.ToString();
}

Expand Down Expand Up @@ -697,6 +697,8 @@ public async Task ExitTestServerAsync()
}

public IList<LSP.Location> GetLocations(string locationName) => _locations[locationName];
public async Task<SourceText> GetDocumentTextAsync(Uri documentUri)
=> await (await GetCurrentSolution().GetDocumentsAsync(documentUri, CancellationToken.None)).Single().GetTextAsync();

public Solution GetCurrentSolution() => TestWorkspace.CurrentSolution;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ static async Task RunAsync(
));
});


if (launchDebugger)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
Expand Down
56 changes: 44 additions & 12 deletions src/Features/LanguageServer/Protocol/Extensions/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,27 @@ namespace Microsoft.CodeAnalysis.LanguageServer
{
internal static class Extensions
{
private const string SourceGeneratedFileScheme = "source-generated://";
private const string SourceGeneratedGuidFormat = "D";

public static Uri GetURI(this TextDocument document)
{
Contract.ThrowIfNull(document.FilePath);
return document is SourceGeneratedDocument
? ProtocolConversions.GetUriFromPartialFilePath(document.FilePath)
: ProtocolConversions.GetUriFromFilePath(document.FilePath);

// If this is not a source generated document, we'll just use the URI stored in the FilePath
if (document is not SourceGeneratedDocument)
return ProtocolConversions.GetUriFromFilePath(document.FilePath);

// 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, then the path will be the GUID that is the project ID, with the rest of it being the generated file path
// just so any display of the end of the URI (like a file tab) works well.
return new Uri(
SourceGeneratedFileScheme +
document.Id.ProjectId.Id.ToString(SourceGeneratedGuidFormat) +
"/" +
document.Id.Id.ToString(SourceGeneratedGuidFormat) +
"/" +
document.FilePath);
}

/// <summary>
Expand Down Expand Up @@ -59,16 +74,14 @@ public static Uri GetUriFromProjectPath(this TextDocument document)
return ProtocolConversions.GetUriFromFilePath(path);
}

public static Uri? TryGetURI(this TextDocument document, RequestContext? context = null)
=> ProtocolConversions.TryGetUriFromFilePath(document.FilePath, context);

public static ImmutableArray<Document> GetDocuments(this Solution solution, Uri documentUri)
public static async ValueTask<ImmutableArray<Document>> GetDocumentsAsync(this Solution solution, Uri documentUri, CancellationToken cancellationToken)
{
var documentIds = GetDocumentIds(solution, documentUri);

// 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;
// We don't call GetTextDocumentAsync here as the id could be referring to an additional document.
var documents = await documentIds.SelectAsArrayAsync(
static (id, solution, ct) => solution.GetDocumentAsync(id, includeSourceGenerated: true, ct), solution, cancellationToken).ConfigureAwait(false);
return documents.WhereNotNull().ToImmutableArray();
}

public static ImmutableArray<DocumentId> GetDocumentIds(this Solution solution, Uri documentUri)
Expand All @@ -89,16 +102,35 @@ public static ImmutableArray<DocumentId> GetDocumentIds(this Solution solution,
fileDocumentIds = solution.GetDocumentIdsWithFilePath(documentUri.LocalPath);
return fileDocumentIds;
}
else if (documentUri.Scheme == SourceGeneratedFileScheme)
{
// In this case, the "host" portion is just the ProjectId directly.
var projectId = ProjectId.CreateFromSerialized(Guid.ParseExact(documentUri.Host, SourceGeneratedGuidFormat));

// The AbsolutePath will consist of a leading / to ignore, then the GUID that is the DocumentId, and then another slash, then the hint path
var slashAfterId = documentUri.AbsolutePath.IndexOf('/', startIndex: 1);
Contract.ThrowIfFalse(slashAfterId > 0, $"The URI '{documentUri}' is not formatted correctly.");

var documentIdGuidSpan = documentUri.AbsolutePath.AsSpan()[1..slashAfterId];
var documentIdGuid =
#if NET7_0_OR_GREATER // netstandard2.0 doesn't have Parse methods that take Spans
Guid.ParseExact(documentIdGuidSpan, SourceGeneratedGuidFormat);
#else
Guid.ParseExact(documentIdGuidSpan.ToString(), SourceGeneratedGuidFormat);
#endif

return ImmutableArray.Create(DocumentId.CreateFromSerialized(projectId, documentIdGuid, debugName: documentUri.AbsolutePath.Substring(slashAfterId + 1)));
}
else
{
var documentIds = solution.GetDocumentIdsWithFilePath(documentUri.OriginalString);
return documentIds;
}
}

public static Document? GetDocument(this Solution solution, TextDocumentIdentifier documentIdentifier)
public static async ValueTask<Document?> GetDocumentAsync(this Solution solution, TextDocumentIdentifier documentIdentifier, CancellationToken cancellationToken)
{
var documents = solution.GetDocuments(documentIdentifier.Uri);
var documents = await solution.GetDocumentsAsync(documentIdentifier.Uri, cancellationToken).ConfigureAwait(false);
return documents.Length == 0
? null
: documents.FindDocumentInProjectContext(documentIdentifier, (sln, id) => sln.GetRequiredDocument(id));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ internal static class ProtocolConversions
{
private const string CSharpMarkdownLanguageName = "csharp";
private const string VisualBasicMarkdownLanguageName = "vb";
private static readonly Uri SourceGeneratedDocumentBaseUri = new("gen://");

private static readonly Regex s_markdownEscapeRegex = new(@"([\\`\*_\{\}\[\]\(\)#+\-\.!])", RegexOptions.Compiled);

Expand Down Expand Up @@ -165,14 +164,6 @@ public static Uri GetUriFromFilePath(string filePath)
return new Uri(filePath, UriKind.Absolute);
}

public static Uri GetUriFromPartialFilePath(string? filePath)
{
if (filePath is null)
throw new ArgumentNullException(nameof(filePath));

return new Uri(SourceGeneratedDocumentBaseUri, filePath);
}

public static Uri? TryGetUriFromFilePath(string? filePath, RequestContext? context = null)
{
if (Uri.TryCreate(filePath, UriKind.Absolute, out var uri))
Expand Down Expand Up @@ -360,11 +351,11 @@ public static LSP.Range TextSpanToRange(TextSpan textSpan, SourceText text)
{
var result = await GetMappedSpanResultAsync(document, ImmutableArray.Create(textSpan), cancellationToken).ConfigureAwait(false);
if (result == null)
return await TryConvertTextSpanToLocation(document, textSpan, isStale, context, cancellationToken).ConfigureAwait(false);
return await TryConvertTextSpanToLocation(document, textSpan, isStale, cancellationToken).ConfigureAwait(false);

var mappedSpan = result.Value.Single();
if (mappedSpan.IsDefault)
return await TryConvertTextSpanToLocation(document, textSpan, isStale, context, cancellationToken).ConfigureAwait(false);
return await TryConvertTextSpanToLocation(document, textSpan, isStale, cancellationToken).ConfigureAwait(false);

var uri = TryGetUriFromFilePath(mappedSpan.FilePath, context);
if (uri == null)
Expand All @@ -380,12 +371,9 @@ public static LSP.Range TextSpanToRange(TextSpan textSpan, SourceText text)
Document document,
TextSpan span,
bool isStale,
RequestContext? context,
CancellationToken cancellationToken)
{
var uri = document.TryGetURI(context);
if (uri == null)
return null;
var uri = document.GetURI();

var text = await document.GetValueTextAsync(cancellationToken).ConfigureAwait(false);
if (isStale)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ protected virtual Task WaitForChangesAsync(RequestContext context, CancellationT
// 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 diagnostics are.
var documentToPreviousDiagnosticParams = GetIdToPreviousDiagnosticParams(context, previousResults, out var removedResults);
var (documentToPreviousDiagnosticParams, removedResults) = await GetIdToPreviousDiagnosticParamsAsync(context, previousResults, 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.
Expand Down Expand Up @@ -206,8 +206,8 @@ await ComputeAndReportCurrentDiagnosticsAsync(
return CreateReturn(progress);
}

private static Dictionary<ProjectOrDocumentId, PreviousPullResult> GetIdToPreviousDiagnosticParams(
RequestContext context, ImmutableArray<PreviousPullResult> previousResults, out ImmutableArray<PreviousPullResult> removedDocuments)
private static async ValueTask<(Dictionary<ProjectOrDocumentId, PreviousPullResult>, ImmutableArray<PreviousPullResult> removedDocuments)> GetIdToPreviousDiagnosticParamsAsync(
RequestContext context, ImmutableArray<PreviousPullResult> previousResults, CancellationToken cancellationToken)
{
Contract.ThrowIfNull(context.Solution);

Expand All @@ -217,7 +217,7 @@ private static Dictionary<ProjectOrDocumentId, PreviousPullResult> GetIdToPrevio
{
if (diagnosticParams.TextDocument != null)
{
var id = GetIdForPreviousResult(diagnosticParams.TextDocument, context.Solution);
var id = await GetIdForPreviousResultAsync(diagnosticParams.TextDocument, context.Solution, cancellationToken).ConfigureAwait(false);
if (id != null)
{
result[id.Value] = diagnosticParams;
Expand All @@ -231,12 +231,12 @@ private static Dictionary<ProjectOrDocumentId, PreviousPullResult> GetIdToPrevio
}
}

removedDocuments = removedDocumentsBuilder.ToImmutable();
return result;
var removedDocuments = removedDocumentsBuilder.ToImmutable();
return (result, removedDocuments);

static ProjectOrDocumentId? GetIdForPreviousResult(TextDocumentIdentifier textDocumentIdentifier, Solution solution)
static async ValueTask<ProjectOrDocumentId?> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,17 @@ public GetTextDocumentWithContextHandler()

public TextDocumentIdentifier GetTextDocumentIdentifier(VSGetProjectContextsParams request) => new TextDocumentIdentifier { Uri = request.TextDocument.Uri };

public Task<VSProjectContextList?> HandleRequestAsync(VSGetProjectContextsParams request, RequestContext context, CancellationToken cancellationToken)
public async Task<VSProjectContextList?> HandleRequestAsync(VSGetProjectContextsParams request, RequestContext context, CancellationToken cancellationToken)
{
Contract.ThrowIfNull(context.Workspace);
Contract.ThrowIfNull(context.Solution);

// We specifically don't use context.Document here because we want multiple
var documents = context.Solution.GetDocuments(request.TextDocument.Uri);
var documents = await context.Solution.GetDocumentsAsync(request.TextDocument.Uri, cancellationToken).ConfigureAwait(false);

if (!documents.Any())
{
return SpecializedTasks.Null<VSProjectContextList>();
return null;
}

var contexts = new List<VSProjectContext>();
Expand All @@ -60,11 +60,11 @@ public GetTextDocumentWithContextHandler()
var openDocument = documents.First();
var currentContextDocumentId = context.Workspace.GetDocumentIdInCurrentContext(openDocument.Id);

return Task.FromResult<VSProjectContextList?>(new VSProjectContextList
return new VSProjectContextList
{
ProjectContexts = contexts.ToArray(),
DefaultIndex = documents.IndexOf(d => d.Id == currentContextDocumentId)
});
};
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// 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 = Microsoft.VisualStudio.LanguageServer.Protocol;

namespace Microsoft.CodeAnalysis.LanguageServer.Handler
{
[ExportCSharpVisualBasicStatelessLspService(typeof(GetTextHandler)), Shared]
[Method("sourceGenerator/getText")]
internal class GetTextHandler : ILspServiceDocumentRequestHandler<SourceGeneratorGetTextParams, SourceGeneratedDocumentText>
{
[ImportingConstructor]
[Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
public GetTextHandler()
{
}

public bool MutatesSolutionState => false;
public bool RequiresLSPSolution => true;

public LSP.TextDocumentIdentifier GetTextDocumentIdentifier(SourceGeneratorGetTextParams request) => request.TextDocument;

public async Task<SourceGeneratedDocumentText> HandleRequestAsync(SourceGeneratorGetTextParams request, RequestContext context, CancellationToken cancellationToken)
{
var document = context.Document;

// Although nothing here strictly prevents this from working on any other document, we'll assert this since something
// has gone wrong otherwise.
Contract.ThrowIfFalse(document is SourceGeneratedDocument);
return new SourceGeneratedDocumentText { Text = (await document.GetTextAsync(cancellationToken).ConfigureAwait(false)).ToString() };
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// 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.Runtime.Serialization;

namespace Microsoft.CodeAnalysis.LanguageServer.Handler
{
[DataContract]
internal class SourceGeneratedDocumentText
{
[DataMember(Name = "text")]
public required string Text { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// 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 Microsoft.VisualStudio.LanguageServer.Protocol;
using System.Runtime.Serialization;

namespace Microsoft.CodeAnalysis.LanguageServer.Handler
{
[DataContract]
internal class SourceGeneratorGetTextParams : ITextDocumentParams
{
[DataMember(Name = "textDocument")]
public required TextDocumentIdentifier TextDocument { get; init; }
}
}
Loading

0 comments on commit 09447c1

Please sign in to comment.