Skip to content

Commit

Permalink
Cohosting semantic tokens tests (#10619)
Browse files Browse the repository at this point in the history
Part of #9519 and
#10603

This was fairly straight forward too, though adding MVC files to the mix
found a bug in our test data, which is kind of humorous.

I decided not to copy all of the semantic tokens tests we have, but
rather just create something reasonably all-encompassing. The core
engine is shared so both sets of tests exercise it anyway.
  • Loading branch information
davidwengier authored Jul 17, 2024
2 parents 0ded205 + 55fee1e commit c303117
Show file tree
Hide file tree
Showing 15 changed files with 582 additions and 32 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,21 @@ protected override IRemoteClientInitializationService CreateService(in ServiceAr
=> new RemoteClientInitializationService(in args);
}

private readonly RemoteLanguageServerFeatureOptions _remoteLanguageServerFeatureOptions = args.ExportProvider.GetExportedValue<RemoteLanguageServerFeatureOptions>();
private readonly RemoteSemanticTokensLegendService _remoteSemanticTokensLegendService = args.ExportProvider.GetExportedValue<RemoteSemanticTokensLegendService>();

public ValueTask InitializeAsync(RemoteClientInitializationOptions options, CancellationToken cancellationToken)
=> RunServiceAsync(ct =>
{
RemoteLanguageServerFeatureOptions.SetOptions(options);
_remoteLanguageServerFeatureOptions.SetOptions(options);
return default;
},
cancellationToken);

public ValueTask InitializeLSPAsync(RemoteClientLSPInitializationOptions options, CancellationToken cancellationToken)
=> RunServiceAsync(ct =>
{
RemoteSemanticTokensLegendService.SetLegend(options.TokenTypes, options.TokenModifiers);
_remoteSemanticTokensLegendService.SetLegend(options.TokenTypes, options.TokenModifiers);
return default;
},
cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,34 +10,34 @@ namespace Microsoft.CodeAnalysis.Remote.Razor;

[Shared]
[Export(typeof(LanguageServerFeatureOptions))]
[Export(typeof(RemoteLanguageServerFeatureOptions))]
internal class RemoteLanguageServerFeatureOptions : LanguageServerFeatureOptions
{
// It's okay to use default here because we expect the options to be set before the first real OOP call
private static RemoteClientInitializationOptions s_options = default;
private RemoteClientInitializationOptions _options = default;

public static void SetOptions(RemoteClientInitializationOptions options) => s_options = options;
public void SetOptions(RemoteClientInitializationOptions options) => _options = options;

public override bool SupportsFileManipulation => throw new InvalidOperationException("This option has not been synced to OOP.");

public override string CSharpVirtualDocumentSuffix => s_options.CSharpVirtualDocumentSuffix;
public override string CSharpVirtualDocumentSuffix => _options.CSharpVirtualDocumentSuffix;

public override string HtmlVirtualDocumentSuffix => s_options.HtmlVirtualDocumentSuffix;
public override string HtmlVirtualDocumentSuffix => _options.HtmlVirtualDocumentSuffix;

public override bool SingleServerSupport => throw new InvalidOperationException("This option has not been synced to OOP.");

public override bool DelegateToCSharpOnDiagnosticPublish => throw new InvalidOperationException("This option has not been synced to OOP.");

public override bool UsePreciseSemanticTokenRanges => s_options.UsePreciseSemanticTokenRanges;
public override bool UsePreciseSemanticTokenRanges => _options.UsePreciseSemanticTokenRanges;

public override bool ShowAllCSharpCodeActions => throw new InvalidOperationException("This option has not been synced to OOP.");

public override bool UpdateBuffersForClosedDocuments => throw new InvalidOperationException("This option has not been synced to OOP.");

public override bool ReturnCodeActionAndRenamePathsWithPrefixedSlash => throw new InvalidOperationException("This option has not been synced to OOP.");

public override bool IncludeProjectKeyInGeneratedFilePath => s_options.IncludeProjectKeyInGeneratedFilePath;
public override bool IncludeProjectKeyInGeneratedFilePath => _options.IncludeProjectKeyInGeneratedFilePath;

public override bool UseRazorCohostServer => s_options.UseRazorCohostServer;
public override bool UseRazorCohostServer => _options.UseRazorCohostServer;

public override bool DisableRazorLanguageServer => throw new InvalidOperationException("This option has not been synced to OOP.");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,24 @@

namespace Microsoft.CodeAnalysis.Remote.Razor.SemanticTokens;

[Export(typeof(ISemanticTokensLegendService)), Shared]
[Shared]
[Export(typeof(ISemanticTokensLegendService))]
[Export(typeof(RemoteSemanticTokensLegendService))]
internal sealed class RemoteSemanticTokensLegendService : ISemanticTokensLegendService
{
private static SemanticTokenModifiers s_tokenModifiers = null!;
private static SemanticTokenTypes s_tokenTypes = null!;
private SemanticTokenModifiers _tokenModifiers = null!;
private SemanticTokenTypes _tokenTypes = null!;

public SemanticTokenModifiers TokenModifiers => s_tokenModifiers;
public SemanticTokenModifiers TokenModifiers => _tokenModifiers;

public SemanticTokenTypes TokenTypes => s_tokenTypes;
public SemanticTokenTypes TokenTypes => _tokenTypes;

public static void SetLegend(string[] tokenTypes, string[] tokenModifiers)
public void SetLegend(string[] tokenTypes, string[] tokenModifiers)
{
if (s_tokenTypes is null)
if (_tokenTypes is null)
{
s_tokenTypes = new SemanticTokenTypes(tokenTypes);
s_tokenModifiers = new SemanticTokenModifiers(tokenModifiers);
_tokenTypes = new SemanticTokenTypes(tokenTypes);
_tokenModifiers = new SemanticTokenModifiers(tokenModifiers);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
using Microsoft.AspNetCore.Razor;
using Microsoft.AspNetCore.Razor.LanguageServer.Hosting;
using Microsoft.AspNetCore.Razor.Telemetry;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost;
using Microsoft.CodeAnalysis.Razor.Logging;
using Microsoft.CodeAnalysis.Razor.Remote;
using Microsoft.CodeAnalysis.Razor.SemanticTokens;
using Microsoft.CodeAnalysis.Razor.Workspaces;
using Microsoft.CodeAnalysis.Text;
using Microsoft.VisualStudio.LanguageServer.Protocol;
using Microsoft.VisualStudio.Razor.LanguageClient.Extensions;
using Microsoft.VisualStudio.Razor.Settings;
Expand Down Expand Up @@ -68,11 +70,16 @@ internal sealed class CohostSemanticTokensRangeEndpoint(
protected override RazorTextDocumentIdentifier? GetRazorTextDocumentIdentifier(SemanticTokensRangeParams request)
=> request.TextDocument.ToRazorTextDocumentIdentifier();

protected override async Task<SemanticTokens?> HandleRequestAsync(SemanticTokensRangeParams request, RazorCohostRequestContext context, CancellationToken cancellationToken)
protected override Task<SemanticTokens?> HandleRequestAsync(SemanticTokensRangeParams request, RazorCohostRequestContext context, CancellationToken cancellationToken)
{
var razorDocument = context.TextDocument.AssumeNotNull();
var span = request.Range.ToLinePositionSpan();

return HandleRequestAsync(razorDocument, span, cancellationToken);
}

private async Task<SemanticTokens?> HandleRequestAsync(TextDocument razorDocument, LinePositionSpan span, CancellationToken cancellationToken)
{
var colorBackground = _clientSettingsManager.GetClientSettings().AdvancedSettings.ColorBackground;

var correlationId = Guid.NewGuid();
Expand All @@ -93,4 +100,12 @@ internal sealed class CohostSemanticTokensRangeEndpoint(

return null;
}

internal TestAccessor GetTestAccessor() => new(this);

internal readonly struct TestAccessor(CohostSemanticTokensRangeEndpoint instance)
{
public Task<SemanticTokens?> HandleRequestAsync(TextDocument razorDocument, LinePositionSpan span, CancellationToken cancellationToken)
=> instance.HandleRequestAsync(razorDocument, span, cancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public IReadOnlyList<RazorProjectItem> GetImports(RazorProjectItem projectItem)
private void AddHierarchicalImports(RazorProjectItem projectItem, List<RazorProjectItem> imports)
{
// We want items in descending order. FindHierarchicalItems returns items in ascending order.
var importProjectItems = ProjectEngine.FileSystem.FindHierarchicalItems(projectItem.FilePath, "_Imports.cshtml").Reverse();
var importProjectItems = ProjectEngine.FileSystem.FindHierarchicalItems(projectItem.FilePath, "_ViewImports.cshtml").Reverse();
imports.AddRange(importProjectItems);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ static TestProjectData()
SomeProject = new HostProject(Path.Combine(someProjectPath, "SomeProject.csproj"), someProjectObjPath, RazorConfiguration.Default, "SomeProject");
SomeProjectFile1 = new HostDocument(Path.Combine(someProjectPath, "File1.cshtml"), "File1.cshtml", FileKinds.Legacy);
SomeProjectFile2 = new HostDocument(Path.Combine(someProjectPath, "File2.cshtml"), "File2.cshtml", FileKinds.Legacy);
SomeProjectImportFile = new HostDocument(Path.Combine(someProjectPath, "_Imports.cshtml"), "_Imports.cshtml", FileKinds.Legacy);
SomeProjectImportFile = new HostDocument(Path.Combine(someProjectPath, "_ViewImports.cshtml"), "_ViewImports.cshtml", FileKinds.Legacy);
SomeProjectNestedFile3 = new HostDocument(Path.Combine(someProjectPath, "Nested", "File3.cshtml"), "Nested\\File3.cshtml", FileKinds.Legacy);
SomeProjectNestedFile4 = new HostDocument(Path.Combine(someProjectPath, "Nested", "File4.cshtml"), "Nested\\File4.cshtml", FileKinds.Legacy);
SomeProjectNestedImportFile = new HostDocument(Path.Combine(someProjectPath, "Nested", "_Imports.cshtml"), "Nested\\_Imports.cshtml", FileKinds.Legacy);
SomeProjectNestedImportFile = new HostDocument(Path.Combine(someProjectPath, "Nested", "_ViewImports.cshtml"), "Nested\\_ViewImports.cshtml", FileKinds.Legacy);
SomeProjectComponentFile1 = new HostDocument(Path.Combine(someProjectPath, "File1.razor"), "File1.razor", FileKinds.Component);
SomeProjectComponentFile2 = new HostDocument(Path.Combine(someProjectPath, "File2.razor"), "File2.razor", FileKinds.Component);
SomeProjectComponentImportFile1 = new HostDocument(Path.Combine(someProjectPath, "_Imports.razor"), "_Imports.razor", FileKinds.Component);
Expand All @@ -42,10 +42,10 @@ static TestProjectData()
AnotherProject = new HostProject(Path.Combine(anotherProjectPath, "AnotherProject.csproj"), anotherProjectObjPath, RazorConfiguration.Default, "AnotherProject");
AnotherProjectFile1 = new HostDocument(Path.Combine(anotherProjectPath, "File1.cshtml"), "File1.cshtml", FileKinds.Legacy);
AnotherProjectFile2 = new HostDocument(Path.Combine(anotherProjectPath, "File2.cshtml"), "File2.cshtml", FileKinds.Legacy);
AnotherProjectImportFile = new HostDocument(Path.Combine(anotherProjectPath, "_Imports.cshtml"), "_Imports.cshtml", FileKinds.Legacy);
AnotherProjectImportFile = new HostDocument(Path.Combine(anotherProjectPath, "_ViewImports.cshtml"), "_ViewImports.cshtml", FileKinds.Legacy);
AnotherProjectNestedFile3 = new HostDocument(Path.Combine(anotherProjectPath, "Nested", "File3.cshtml"), "Nested\\File1.cshtml", FileKinds.Legacy);
AnotherProjectNestedFile4 = new HostDocument(Path.Combine(anotherProjectPath, "Nested", "File4.cshtml"), "Nested\\File2.cshtml", FileKinds.Legacy);
AnotherProjectNestedImportFile = new HostDocument(Path.Combine(anotherProjectPath, "Nested", "_Imports.cshtml"), "Nested\\_Imports.cshtml", FileKinds.Legacy);
AnotherProjectNestedImportFile = new HostDocument(Path.Combine(anotherProjectPath, "Nested", "_ViewImports.cshtml"), "Nested\\_ViewImports.cshtml", FileKinds.Legacy);
AnotherProjectComponentFile1 = new HostDocument(Path.Combine(anotherProjectPath, "File1.razor"), "File1.razor", FileKinds.Component);
AnotherProjectComponentFile2 = new HostDocument(Path.Combine(anotherProjectPath, "File2.razor"), "File2.razor", FileKinds.Component);
AnotherProjectNestedComponentFile3 = new HostDocument(Path.Combine(anotherProjectPath, "Nested", "File3.razor"), "Nested\\File1.razor", FileKinds.Component);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System.IO;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.LanguageServer.Semantic;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.AspNetCore.Razor.Telemetry;
using Microsoft.CodeAnalysis.Razor.Settings;
using Microsoft.CodeAnalysis.Razor.Workspaces;
using Microsoft.CodeAnalysis.Remote.Razor;
using Microsoft.CodeAnalysis.Remote.Razor.SemanticTokens;
using Microsoft.CodeAnalysis.Text;
using Microsoft.VisualStudio.Razor.Settings;
using Microsoft.VisualStudio.Utilities;
using Roslyn.Test.Utilities;
using Xunit;
using Xunit.Abstractions;

namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost;

public class CohostSemanticTokensRangeEndpointTest(ITestOutputHelper testOutputHelper) : CohostTestBase(testOutputHelper)
{
[Theory]
[CombinatorialData]
public async Task Razor(bool colorBackground, bool precise)
{
var input = """
@page "/"
@using System

<div>This is some HTML</div>

<InputText Value="someValue" />

@* hello there *@
<!-- how are you? -->

@if (true)
{
<text>Html!</text>
}

@code
{
// I am also good, thanks for asking

/*
No problem.
*/

private string someValue;

public void M()
{
RenderFragment x = @<div>This is some HTML in a render fragment</div>;
}
}
""";

await VerifySemanticTokensAsync(input, colorBackground, precise);
}

[Theory]
[CombinatorialData]
public async Task Legacy(bool colorBackground, bool precise)
{
var input = """
@page "/"
@using System

<div>This is some HTML</div>

<component type="typeof(Component)" render-mode="ServerPrerendered" />

@functions
{
public void M()
{
}
}

@section MySection {
<div>Section content</div>
}
""";

await VerifySemanticTokensAsync(input, colorBackground, precise, fileKind: FileKinds.Legacy);
}

private async Task VerifySemanticTokensAsync(string input, bool colorBackground, bool precise, string? fileKind = null, [CallerMemberName] string? testName = null)
{
var document = CreateProjectAndRazorDocument(input, fileKind);
var sourceText = await document.GetTextAsync(DisposalToken);

var legend = TestRazorSemanticTokensLegendService.Instance;

// We need to manually initialize the OOP service so we can get semantic token info later
var legendService = OOPExportProvider.GetExportedValue<RemoteSemanticTokensLegendService>();
legendService.SetLegend(legend.TokenTypes.All, legend.TokenModifiers.All);

// Update the client initialization options to control the precise ranges option
UpdateClientInitializationOptions(c => c with { UsePreciseSemanticTokenRanges = precise });

var clientSettingsManager = new ClientSettingsManager([], null, null);
clientSettingsManager.Update(ClientAdvancedSettings.Default with { ColorBackground = colorBackground });

var endpoint = new CohostSemanticTokensRangeEndpoint(RemoteServiceInvoker, clientSettingsManager, legend, NoOpTelemetryReporter.Instance, LoggerFactory);

var span = new LinePositionSpan(new(0, 0), new(sourceText.Lines.Count, 0));

var result = await endpoint.GetTestAccessor().HandleRequestAsync(document, span, DisposalToken);

var actualFileContents = GetTestOutput(sourceText, result?.Data);

if (colorBackground)
{
testName += "_with_background";
}

var baselineFileName = $@"TestFiles\SemanticTokens\{testName}.txt";
if (GenerateBaselines.ShouldGenerate)
{
WriteBaselineFile(actualFileContents, baselineFileName);
}

var expectedFileContents = GetBaselineFileContents(baselineFileName);
AssertEx.EqualOrDiff(expectedFileContents, actualFileContents);
}

private string GetBaselineFileContents(string baselineFileName)
{
var semanticFile = TestFile.Create(baselineFileName, GetType().Assembly);
if (!semanticFile.Exists())
{
return string.Empty;
}

return semanticFile.ReadAllText();
}

private static void WriteBaselineFile(string fileContents, string baselineFileName)
{
var projectPath = TestProject.GetProjectDirectory(typeof(CohostSemanticTokensRangeEndpointTest), layer: TestProject.Layer.Tooling);
var baselineFileFullPath = Path.Combine(projectPath, baselineFileName);
File.WriteAllText(baselineFileFullPath, fileContents);
}

private static string GetTestOutput(SourceText sourceText, int[]? data)
{
if (data == null)
{
return string.Empty;
}

using var _ = StringBuilderPool.GetPooledObject(out var builder);
builder.AppendLine("Line Δ, Char Δ, Length, Type, Modifier(s), Text");
var tokenTypes = TestRazorSemanticTokensLegendService.Instance.TokenTypes.All;
var prevLength = 0;
var lineIndex = 0;
var lineOffset = 0;
for (var i = 0; i < data.Length; i += 5)
{
var lineDelta = data[i];
var charDelta = data[i + 1];
var length = data[i + 2];

Assert.False(i != 0 && lineDelta == 0 && charDelta == 0, "line delta and character delta are both 0, which is invalid as we shouldn't be producing overlapping tokens");
Assert.False(i != 0 && lineDelta == 0 && charDelta < prevLength, "Previous length is longer than char offset from previous start, meaning tokens will overlap");

if (lineDelta != 0)
{
lineOffset = 0;
}

lineIndex += lineDelta;
lineOffset += charDelta;

var type = tokenTypes[data[i + 3]];
var modifier = GetTokenModifierString(data[i + 4]);
var text = sourceText.GetSubTextString(new TextSpan(sourceText.Lines[lineIndex].Start + lineOffset, length));
builder.AppendLine($"{lineDelta} {charDelta} {length} {type} {modifier} [{text}]");

prevLength = length;
}

return builder.ToString();
}

private static string GetTokenModifierString(int tokenModifiers)
{
var modifiers = TestRazorSemanticTokensLegendService.Instance.TokenModifiers.All;

var modifiersBuilder = ArrayBuilder<string>.GetInstance();
for (var i = 0; i < modifiers.Length; i++)
{
if ((tokenModifiers & (1 << i % 32)) != 0)
{
modifiersBuilder.Add(modifiers[i]);
}
}

return $"[{string.Join(", ", modifiersBuilder.ToArrayAndFree())}]";
}
}
Loading

0 comments on commit c303117

Please sign in to comment.