Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use Roslyn project info in cohost endpoints #9805

Merged
merged 9 commits into from
Jan 12, 2024
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System;
using System.Composition;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost;

namespace Microsoft.AspNetCore.Razor.LanguageServer.Cohost;

// NOTE: This is not a "normal" MEF export (ie, exporting an interface) purely because of a strange desire to keep API in
// RazorCohostRequestContextExtensions looking like the previous code in the non-cohost world.
[ExportRazorStatelessLspService(typeof(CohostDocumentContextFactory))]
[method: ImportingConstructor]
internal class CohostDocumentContextFactory(DocumentSnapshotFactory documentSnapshotFactory, IDocumentVersionCache documentVersionCache) : AbstractRazorLspService
{
private readonly DocumentSnapshotFactory _documentSnapshotFactory = documentSnapshotFactory;
private readonly IDocumentVersionCache _documentVersionCache = documentVersionCache;

public VersionedDocumentContext Create(Uri documentUri, TextDocument textDocument)
{
var documentSnapshot = _documentSnapshotFactory.GetOrCreate(textDocument);

// HACK: For cohosting, we just grab the "current" version, because we know it will have been updated
// since the change handling is synchronous. In future we can just remove the whole concept of
// document versions because TextDocument is inherently versioned.
var version = _documentVersionCache.GetLatestDocumentVersion(documentSnapshot.FilePath.AssumeNotNull());

return new VersionedDocumentContext(documentUri, documentSnapshot, null, version);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Text;

namespace Microsoft.AspNetCore.Razor.LanguageServer.Cohost;

internal class CohostDocumentSnapshot(TextDocument textDocument, IProjectSnapshot projectSnapshot) : IDocumentSnapshot
{
private readonly TextDocument _textDocument = textDocument;
private readonly IProjectSnapshot _projectSnapshot = projectSnapshot;

private RazorCodeDocument? _codeDocument;

public string? FileKind => FileKinds.GetFileKindFromFilePath(FilePath);

public string? FilePath => _textDocument.FilePath;

public string? TargetPath => _textDocument.FilePath;

public IProjectSnapshot Project => _projectSnapshot;

public bool SupportsOutput => true;

public Task<SourceText> GetTextAsync() => _textDocument.GetTextAsync();

public Task<VersionStamp> GetTextVersionAsync() => _textDocument.GetTextVersionAsync();

public bool TryGetText([NotNullWhen(true)] out SourceText? result) => _textDocument.TryGetText(out result);

public bool TryGetTextVersion(out VersionStamp result) => _textDocument.TryGetTextVersion(out result);

public ImmutableArray<IDocumentSnapshot> GetImports()
{
return DocumentState.GetImportsCore(Project, FilePath.AssumeNotNull(), FileKind.AssumeNotNull());
}

public async Task<RazorCodeDocument> GetGeneratedOutputAsync()
{
// TODO: We don't need to worry about locking if we get called from the didOpen/didChange LSP requests, as CLaSP
// takes care of that for us, and blocks requests until those are complete. If that doesn't end up happening,
// then a locking mechanism here would prevent concurrent compilations.
if (_codeDocument is not null)
{
return _codeDocument;
}

// The non-cohosted DocumentSnapshot implementation uses DocumentState to get the generated output, and we could do that too
// but most of that code is optimized around caching pre-computed results when things change that don't affect the compilation.
// We can't do that here because we are using Roslyn's project snapshots, which don't contain the info that Razor needs. We could
// in future provide a side-car mechanism so we can cache things, but still take advantage of snapshots etc. but the working
// assumption for this code is that the source generator will be used, and it will do all of that, so this implementation is naive
// and simply compiles when asked, and if a new document snapshot comes in, we compile again. This is presumably worse for perf
// but since we don't expect users to ever use cohosting without source generators, it's fine for now.

var imports = await DocumentState.ComputedStateTracker.GetImportsAsync(this).ConfigureAwait(false);
_codeDocument = await DocumentState.ComputedStateTracker.GenerateCodeDocumentAsync(Project, this, imports).ConfigureAwait(false);

return _codeDocument;
}

public bool TryGetGeneratedOutput([NotNullWhen(true)] out RazorCodeDocument? result)
{
result = _codeDocument;
return result is not null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Threading;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.AspNetCore.Razor.ProjectEngineHost;
using Microsoft.AspNetCore.Razor.ProjectSystem;
using Microsoft.AspNetCore.Razor.Telemetry;
using Microsoft.AspNetCore.Razor.Utilities;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Razor;
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
using Microsoft.VisualStudio.Threading;

namespace Microsoft.AspNetCore.Razor.LanguageServer.Cohost;

internal class CohostProjectSnapshot : IProjectSnapshot
{
private static readonly EmptyProjectEngineFactory s_fallbackProjectEngineFactory = new();
private static readonly (IProjectEngineFactory Value, ICustomProjectEngineFactoryMetadata)[] s_projectEngineFactories = ProjectEngineFactories.Factories.Select(f => (f.Item1.Value, f.Item2)).ToArray();

private readonly Project _project;
private readonly DocumentSnapshotFactory _documentSnapshotFactory;
private readonly ITelemetryReporter _telemetryReporter;
private readonly ProjectKey _projectKey;
private readonly Lazy<RazorConfiguration> _lazyConfiguration;
private readonly Lazy<RazorProjectEngine> _lazyProjectEngine;
private readonly AsyncLazy<ImmutableArray<TagHelperDescriptor>> _tagHelpersLazy;
private readonly Lazy<ProjectWorkspaceState> _projectWorkspaceStateLazy;
private readonly Lazy<ImmutableDictionary<string, ImmutableArray<string>>> _importsToRelatedDocumentsLazy;

public CohostProjectSnapshot(Project project, DocumentSnapshotFactory documentSnapshotFactory, ITelemetryReporter telemetryReporter, JoinableTaskContext joinableTaskContext)
{
_project = project;
_documentSnapshotFactory = documentSnapshotFactory;
_telemetryReporter = telemetryReporter;
_projectKey = ProjectKey.From(_project).AssumeNotNull();

_lazyConfiguration = new Lazy<RazorConfiguration>(CreateRazorConfiguration);
_lazyProjectEngine = new Lazy<RazorProjectEngine>(() => DefaultProjectEngineFactory.Create(
Configuration,
fileSystem: RazorProjectFileSystem.Create(Path.GetDirectoryName(FilePath)),
configure: builder =>
{
builder.SetRootNamespace(RootNamespace);
builder.SetCSharpLanguageVersion(CSharpLanguageVersion);
builder.SetSupportLocalizedComponentNames();
},
fallback: s_fallbackProjectEngineFactory,
factories: s_projectEngineFactories));

_tagHelpersLazy = new AsyncLazy<ImmutableArray<TagHelperDescriptor>>(() =>
{
var resolver = new CompilationTagHelperResolver(_telemetryReporter);
return resolver.GetTagHelpersAsync(_project, GetProjectEngine(), CancellationToken.None).AsTask();
}, joinableTaskContext.Factory);

_projectWorkspaceStateLazy = new Lazy<ProjectWorkspaceState>(() => ProjectWorkspaceState.Create(TagHelpers, CSharpLanguageVersion));

_importsToRelatedDocumentsLazy = new Lazy<ImmutableDictionary<string, ImmutableArray<string>>>(() =>
{
var importsToRelatedDocuments = ImmutableDictionary.Create<string, ImmutableArray<string>>(FilePathNormalizer.Comparer);
foreach (var document in DocumentFilePaths)
{
var importTargetPaths = ProjectState.GetImportDocumentTargetPaths(document, FileKinds.GetFileKindFromFilePath(document), GetProjectEngine());
importsToRelatedDocuments = ProjectState.AddToImportsToRelatedDocuments(importsToRelatedDocuments, document, importTargetPaths);
}

return importsToRelatedDocuments;
});
}

public ProjectKey Key => _projectKey;

public RazorConfiguration Configuration => _lazyConfiguration.Value;

public IEnumerable<string> DocumentFilePaths
=> _project.AdditionalDocuments
.Where(d => d.FilePath!.EndsWith(".razor", StringComparison.OrdinalIgnoreCase) || d.FilePath.EndsWith(".cshtml", StringComparison.OrdinalIgnoreCase))
.Select(d => d.FilePath.AssumeNotNull());

public string FilePath => _project.FilePath!;

public string IntermediateOutputPath => FilePathNormalizer.GetNormalizedDirectoryName(_project.CompilationOutputInfo.AssemblyPath);

public string? RootNamespace => _project.DefaultNamespace ?? "ASP";

public string DisplayName => _project.Name;

public VersionStamp Version => _project.Version;

public LanguageVersion CSharpLanguageVersion => ((CSharpParseOptions)_project.ParseOptions!).LanguageVersion;

public ImmutableArray<TagHelperDescriptor> TagHelpers => _tagHelpersLazy.GetValue();

public ProjectWorkspaceState ProjectWorkspaceState => _projectWorkspaceStateLazy.Value;

public IDocumentSnapshot? GetDocument(string filePath)
{
var textDocument = _project.AdditionalDocuments.FirstOrDefault(d => d.FilePath == filePath);
if (textDocument is null)
{
return null;
}

return _documentSnapshotFactory.GetOrCreate(textDocument);
}

public RazorProjectEngine GetProjectEngine() => _lazyProjectEngine.Value;

public ImmutableArray<IDocumentSnapshot> GetRelatedDocuments(IDocumentSnapshot document)
{
var targetPath = document.TargetPath.AssumeNotNull();

if (!_importsToRelatedDocumentsLazy.Value.TryGetValue(targetPath, out var relatedDocuments))
{
return ImmutableArray<IDocumentSnapshot>.Empty;
}

using var _ = ArrayBuilderPool<IDocumentSnapshot>.GetPooledObject(out var builder);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using a PooledArrayBuilder if the result will regularly be larger than 4 items.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean if it will be less than 4 items? If it's more than 4, doesn't the PooledArrayBuilder just switch to using the ArrayBuilderPool anyway?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having said that, I just copied this code as is, and it can be improved slightly by specifying a capacity at least

Copy link
Member

@DustinCampbell DustinCampbell Jan 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, sorry - I meant "less than 4 items". PooledArrayBuilder can help avoid starvation of the ArrayBuilderPool and is useful when smaller arrays are expected. I'm not 100% sure what GetRelatedDocuments means. Based on the code and the name of the map, I would guess it means, "the documents that use this import"? If so, ArrayBuilderPool is probably the way to go.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, that's my reading too, and I agree. Thanks for confirming

builder.SetCapacityIfLarger(relatedDocuments.Length);

foreach (var relatedDocumentFilePath in relatedDocuments)
{
if (GetDocument(relatedDocumentFilePath) is { } relatedDocument)
{
builder.Add(relatedDocument);
}
}

return builder.ToImmutableArray();
}

public bool IsImportDocument(IDocumentSnapshot document)
{
return document.TargetPath is { } targetPath && _importsToRelatedDocumentsLazy.Value.ContainsKey(targetPath);
}

private RazorConfiguration CreateRazorConfiguration()
{
// See RazorSourceGenerator.RazorProviders.cs

var globalOptions = _project.AnalyzerOptions.AnalyzerConfigOptionsProvider.GlobalOptions;

globalOptions.TryGetValue("build_property.RazorConfiguration", out var configurationName);

configurationName ??= "MVC-3.0"; // TODO: Source generator uses "default" here??

if (!globalOptions.TryGetValue("build_property.RazorLangVersion", out var razorLanguageVersionString) ||
!RazorLanguageVersion.TryParse(razorLanguageVersionString, out var razorLanguageVersion))
{
razorLanguageVersion = RazorLanguageVersion.Latest;
}

return RazorConfiguration.Create(razorLanguageVersion, configurationName, Enumerable.Empty<RazorExtension>(), useConsolidatedMvcViews: true);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System;
using System.Composition;
using System.Runtime.CompilerServices;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Razor.ProjectSystem;

namespace Microsoft.AspNetCore.Razor.LanguageServer.Cohost;

[Export(typeof(DocumentSnapshotFactory)), Shared]
[method: ImportingConstructor]
internal class DocumentSnapshotFactory(Lazy<ProjectSnapshotFactory> projectSnapshotFactory)
{
private static readonly ConditionalWeakTable<TextDocument, IDocumentSnapshot> _documentSnapshots = new();

private readonly Lazy<ProjectSnapshotFactory> _projectSnapshotFactory = projectSnapshotFactory;

public IDocumentSnapshot GetOrCreate(TextDocument textDocument)
{
if (!_documentSnapshots.TryGetValue(textDocument, out var documentSnapshot))
{
var projectSnapshot = _projectSnapshotFactory.Value.GetOrCreate(textDocument.Project);
documentSnapshot = new CohostDocumentSnapshot(textDocument, projectSnapshot);
_documentSnapshots.Add(textDocument, documentSnapshot);
}

return documentSnapshot;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System.Composition;
using System.Runtime.CompilerServices;
using Microsoft.AspNetCore.Razor.Telemetry;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
using Microsoft.VisualStudio.Threading;

namespace Microsoft.AspNetCore.Razor.LanguageServer.Cohost;

[Export(typeof(ProjectSnapshotFactory)), Shared]
[method: ImportingConstructor]
internal class ProjectSnapshotFactory(DocumentSnapshotFactory documentSnapshotFactory, ITelemetryReporter telemetryReporter, JoinableTaskContext joinableTaskContext)
{
private static readonly ConditionalWeakTable<Project, IProjectSnapshot> _projectSnapshots = new();

private readonly DocumentSnapshotFactory _documentSnapshotFactory = documentSnapshotFactory;
private readonly ITelemetryReporter _telemetryReporter = telemetryReporter;
private readonly JoinableTaskContext _joinableTaskContext = joinableTaskContext;

public IProjectSnapshot GetOrCreate(Project project)
{
if (!_projectSnapshots.TryGetValue(project, out var projectSnapshot))
{
projectSnapshot = new CohostProjectSnapshot(project, _documentSnapshotFactory, _telemetryReporter, _joinableTaskContext);
_projectSnapshots.Add(project, projectSnapshot);
}

return projectSnapshot;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,18 @@ private void TrackDocumentVersion(IDocumentSnapshot documentSnapshot, int versio
}
}

public int GetLatestDocumentVersion(string filePath)
{
using var _ = _lock.EnterReadLock();

if (!DocumentLookup_NeedsLock.TryGetValue(filePath, out var documentEntries))
{
return -1;
}

return documentEntries[^1].Version;
}

public bool TryGetDocumentVersion(IDocumentSnapshot documentSnapshot, [NotNullWhen(true)] out int? version)
{
if (documentSnapshot is null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,7 @@ internal interface IDocumentVersionCache
{
bool TryGetDocumentVersion(IDocumentSnapshot documentSnapshot, [NotNullWhen(true)] out int? version);
void TrackDocumentVersion(IDocumentSnapshot documentSnapshot, int version);

// HACK: This is temporary to allow the cohosting and normal language server to co-exist and share code
int GetLatestDocumentVersion(string filePath);
}
Loading