services)
+ {
+ services.Add(new TestTagHelperResolver());
+ }
+
+ [Fact]
+ public async Task TryExcerptAsync_SingleLine_CanClassifyCSharp()
+ {
+ // Arrange
+ var (sourceText, primarySpan) = CreateText(
+@"
+
+@{
+ var |foo| = ""Hello, World!"";
+}
+ @foo
+ @(3 + 4)
@(foo + foo)
+
+");
+
+ var (primary, secondary) = Initialize(sourceText);
+ var service = CreateExcerptService(primary);
+
+ var secondarySpan = await GetSecondarySpanAsync(primary, primarySpan, secondary);
+
+ // Act
+ var result = await service.TryExcerptAsync(secondary, secondarySpan, ExcerptMode.SingleLine, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(secondarySpan, result.Value.Span);
+ Assert.Same(secondary, result.Value.Document);
+
+ Assert.Equal(@" var foo = ""Hello, World!"";", result.Value.Content.ToString(), ignoreLineEndingDifferences: true);
+ Assert.Collection(
+ result.Value.ClassifiedSpans,
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.Keyword, c.ClassificationType);
+ Assert.Equal("var", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.LocalName, c.ClassificationType);
+ Assert.Equal("foo", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.Operator, c.ClassificationType);
+ Assert.Equal("=", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.StringLiteral, c.ClassificationType);
+ Assert.Equal("\"Hello, World!\"", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.Punctuation, c.ClassificationType);
+ Assert.Equal(";", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ });
+ }
+
+ [Fact]
+ public async Task TryExcerptAsync_SingleLine_CanClassifyCSharp_ImplicitExpression()
+ {
+ // Arrange
+ var (sourceText, primarySpan) = CreateText(
+@"
+
+@{
+ var foo = ""Hello, World!"";
+}
+ @|foo|
+ @(3 + 4)
@(foo + foo)
+
+");
+
+ var (primary, secondary) = Initialize(sourceText);
+ var service = CreateExcerptService(primary);
+
+ var secondarySpan = await GetSecondarySpanAsync(primary, primarySpan, secondary);
+
+ // Act
+ var result = await service.TryExcerptAsync(secondary, secondarySpan, ExcerptMode.SingleLine, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(secondarySpan, result.Value.Span);
+ Assert.Same(secondary, result.Value.Document);
+
+ Assert.Equal(@" @foo", result.Value.Content.ToString(), ignoreLineEndingDifferences: true);
+ Assert.Collection(
+ result.Value.ClassifiedSpans,
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.Text, c.ClassificationType);
+ Assert.Equal(" @", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.LocalName, c.ClassificationType);
+ Assert.Equal("foo", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.Text, c.ClassificationType);
+ Assert.Equal("", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ });
+ }
+
+ [Fact]
+ public async Task TryExcerptAsync_SingleLine_CanClassifyCSharp_ComplexLine()
+ {
+ // Arrange
+ var (sourceText, primarySpan) = CreateText(
+@"
+
+@{
+ var foo = ""Hello, World!"";
+}
+ @foo
+ @(3 + 4)
@(foo + |foo|)
+
+");
+
+ var (primary, secondary) = Initialize(sourceText);
+ var service = CreateExcerptService(primary);
+
+ var secondarySpan = await GetSecondarySpanAsync(primary, primarySpan, secondary);
+
+ // Act
+ var result = await service.TryExcerptAsync(secondary, secondarySpan, ExcerptMode.SingleLine, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(secondarySpan, result.Value.Span);
+ Assert.Same(secondary, result.Value.Document);
+
+ Assert.Equal(@" @(3 + 4)
@(foo + foo)
", result.Value.Content.ToString(), ignoreLineEndingDifferences: true);
+ Assert.Collection(
+ result.Value.ClassifiedSpans,
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.Text, c.ClassificationType);
+ Assert.Equal(" @(", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.NumericLiteral, c.ClassificationType);
+ Assert.Equal("3", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.Operator, c.ClassificationType);
+ Assert.Equal("+", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.NumericLiteral, c.ClassificationType);
+ Assert.Equal("4", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.Text, c.ClassificationType);
+ Assert.Equal(")
@(", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.LocalName, c.ClassificationType);
+ Assert.Equal("foo", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.Operator, c.ClassificationType);
+ Assert.Equal("+", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.LocalName, c.ClassificationType);
+ Assert.Equal("foo", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.Text, c.ClassificationType);
+ Assert.Equal(")
", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ });
+ }
+
+ [Fact]
+ public async Task TryExcerptAsync_MultiLine_CanClassifyCSharp()
+ {
+ // Arrange
+ var (sourceText, primarySpan) = CreateText(
+@"
+
+@{
+ var |foo| = ""Hello, World!"";
+}
+ @foo
+ @(3 + 4)
@(foo + foo)
+
+");
+
+ var (primary, secondary) = Initialize(sourceText);
+ var service = CreateExcerptService(primary);
+
+ var secondarySpan = await GetSecondarySpanAsync(primary, primarySpan, secondary);
+
+ // Act
+ var result = await service.TryExcerptAsync(secondary, secondarySpan, ExcerptMode.Tooltip, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(secondarySpan, result.Value.Span);
+ Assert.Same(secondary, result.Value.Document);
+
+ Assert.Equal(
+@"@{
+ var foo = ""Hello, World!"";
+}",
+ result.Value.Content.ToString(), ignoreLineEndingDifferences: true);
+
+ Assert.Collection(
+ result.Value.ClassifiedSpans,
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.Text, c.ClassificationType);
+ Assert.Equal("@{", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.Keyword, c.ClassificationType);
+ Assert.Equal("var", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.LocalName, c.ClassificationType);
+ Assert.Equal("foo", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.Operator, c.ClassificationType);
+ Assert.Equal("=", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.StringLiteral, c.ClassificationType);
+ Assert.Equal("\"Hello, World!\"", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.Punctuation, c.ClassificationType);
+ Assert.Equal(";", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.Text, c.ClassificationType);
+ Assert.Equal("}", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ });
+ }
+
+ [Fact]
+ public async Task TryExcerptAsync_MultiLine_Boundaries_CanClassifyCSharp()
+ {
+ // Arrange
+ var (sourceText, primarySpan) = CreateText(@"@{ var |foo| = ""Hello, World!""; }");
+
+ var (primary, secondary) = Initialize(sourceText);
+ var service = CreateExcerptService(primary);
+
+ var secondarySpan = await GetSecondarySpanAsync(primary, primarySpan, secondary);
+
+ // Act
+ var result = await service.TryExcerptAsync(secondary, secondarySpan, ExcerptMode.Tooltip, CancellationToken.None);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(secondarySpan, result.Value.Span);
+ Assert.Same(secondary, result.Value.Document);
+
+ Assert.Equal(
+@"@{ var foo = ""Hello, World!""; }",
+ result.Value.Content.ToString(), ignoreLineEndingDifferences: true);
+
+ Assert.Collection(
+ result.Value.ClassifiedSpans,
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.Text, c.ClassificationType);
+ Assert.Equal("@{", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.Keyword, c.ClassificationType);
+ Assert.Equal("var", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.LocalName, c.ClassificationType);
+ Assert.Equal("foo", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.Operator, c.ClassificationType);
+ Assert.Equal("=", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.StringLiteral, c.ClassificationType);
+ Assert.Equal("\"Hello, World!\"", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.Punctuation, c.ClassificationType);
+ Assert.Equal(";", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ },
+ c =>
+ {
+ Assert.Equal(ClassificationTypeNames.Text, c.ClassificationType);
+ Assert.Equal("}", result.Value.Content.GetSubText(c.TextSpan).ToString());
+ });
+ }
+
+ public (SourceText sourceText, TextSpan span) CreateText(string text)
+ {
+ // Since we're using positions, normalize to Windows style
+ text = text.Replace("\r", "").Replace("\n", "\r\n");
+
+ var start = text.IndexOf('|');
+ var length = text.IndexOf('|', start + 1) - start - 1;
+ text = text.Replace("|", "");
+
+ if (start < 0 || length < 0)
+ {
+ throw new InvalidOperationException("Could not find delimited text.");
+ }
+
+ return (SourceText.From(text), new TextSpan(start, length));
+ }
+
+ // Adds the text to a ProjectSnapshot, generates code, and updates the workspace.
+ private (DocumentSnapshot primary, Document secondary) Initialize(SourceText sourceText)
+ {
+ var project = new DefaultProjectSnapshot(
+ ProjectState.Create(Workspace.Services, HostProject)
+ .WithAddedHostDocument(HostDocument, () =>
+ {
+ return Task.FromResult(TextAndVersion.Create(sourceText, VersionStamp.Create()));
+ }));
+
+ var primary = project.GetDocument(HostDocument.FilePath);
+
+ var solution = Workspace.CurrentSolution.AddProject(ProjectInfo.Create(
+ ProjectId.CreateNewId(Path.GetFileNameWithoutExtension(HostDocument.FilePath)),
+ VersionStamp.Create(),
+ Path.GetFileNameWithoutExtension(HostDocument.FilePath),
+ Path.GetFileNameWithoutExtension(HostDocument.FilePath),
+ LanguageNames.CSharp,
+ HostDocument.FilePath));
+
+ solution = solution.AddDocument(
+ DocumentId.CreateNewId(solution.ProjectIds.Single(), HostDocument.FilePath),
+ HostDocument.FilePath,
+ new GeneratedOutputTextLoader(primary, HostDocument.FilePath));
+
+ var secondary = solution.Projects.Single().Documents.Single();
+ return (primary, secondary);
+ }
+
+ // Maps a span in the primary buffer to the secondary buffer. This is only valid for C# code
+ // that appears in the primary buffer.
+ private async Task GetSecondarySpanAsync(DocumentSnapshot primary, TextSpan primarySpan, Document secondary)
+ {
+ var output = await primary.GetGeneratedOutputAsync();
+
+ var mappings = output.GetCSharpDocument().SourceMappings;
+ for (var i = 0; i < mappings.Count; i++)
+ {
+ var mapping = mappings[i];
+ if (mapping.OriginalSpan.AsTextSpan().Contains(primarySpan))
+ {
+ var offset = mapping.GeneratedSpan.AbsoluteIndex - mapping.OriginalSpan.AbsoluteIndex;
+ var secondarySpan = new TextSpan(primarySpan.Start + offset, primarySpan.Length);
+ Assert.Equal(
+ (await primary.GetTextAsync()).GetSubText(primarySpan).ToString(),
+ (await secondary.GetTextAsync()).GetSubText(secondarySpan).ToString());
+ return secondarySpan;
+ }
+ }
+
+ throw new InvalidOperationException("Could not map the primary span to the generated code.");
+ }
+
+ private RazorDocumentExcerptService CreateExcerptService(DocumentSnapshot document)
+ {
+ return new RazorDocumentExcerptService(document, new RazorSpanMappingService(document));
+ }
+ }
+}
diff --git a/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/RazorSpanMappingServiceTest.cs b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/RazorSpanMappingServiceTest.cs
new file mode 100644
index 0000000000..f2153325f4
--- /dev/null
+++ b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/RazorSpanMappingServiceTest.cs
@@ -0,0 +1,164 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Razor.Language;
+using Microsoft.CodeAnalysis.Host;
+using Microsoft.CodeAnalysis.Razor.ProjectSystem;
+using Microsoft.CodeAnalysis.Text;
+using Xunit;
+
+namespace Microsoft.CodeAnalysis.Razor
+{
+ public class RazorSpanMappingServiceTest : WorkspaceTestBase
+ {
+ public RazorSpanMappingServiceTest()
+ {
+ HostProject = TestProjectData.SomeProject;
+ HostDocument = TestProjectData.SomeProjectFile1;
+ }
+
+ private HostProject HostProject { get; }
+ private HostDocument HostDocument { get; }
+
+ protected override void ConfigureLanguageServices(List services)
+ {
+ services.Add(new TestTagHelperResolver());
+ }
+
+ [Fact]
+ public async Task TryGetLinePositionSpan_SpanMatchesSourceMapping_ReturnsTrue()
+ {
+ // Arrange
+ var sourceText = SourceText.From(@"
+@SomeProperty
+");
+
+ var project = new DefaultProjectSnapshot(
+ ProjectState.Create(Workspace.Services, HostProject)
+ .WithAddedHostDocument(HostDocument, () =>
+ {
+ return Task.FromResult(TextAndVersion.Create(sourceText, VersionStamp.Create()));
+ }));
+
+ var document = project.GetDocument(HostDocument.FilePath);
+ var service = new RazorSpanMappingService(document);
+
+ var output = await document.GetGeneratedOutputAsync();
+ var generated = output.GetCSharpDocument();
+
+ var symbol = "SomeProperty";
+ var span = new TextSpan(generated.GeneratedCode.IndexOf(symbol), symbol.Length);
+
+ // Act
+ var result = RazorSpanMappingService.TryGetLinePositionSpan(span, await document.GetTextAsync(), generated, out var mapped);
+
+ // Assert
+ Assert.True(result);
+ Assert.Equal(new LinePositionSpan(new LinePosition(1, 1), new LinePosition(1, 13)), mapped);
+ }
+
+ [Fact]
+ public async Task TryGetLinePositionSpan_SpanMatchesSourceMappingAndPosition_ReturnsTrue()
+ {
+ // Arrange
+ var sourceText = SourceText.From(@"
+@SomeProperty
+@SomeProperty
+@SomeProperty
+");
+
+ var project = new DefaultProjectSnapshot(
+ ProjectState.Create(Workspace.Services, HostProject)
+ .WithAddedHostDocument(HostDocument, () =>
+ {
+ return Task.FromResult(TextAndVersion.Create(sourceText, VersionStamp.Create()));
+ }));
+
+ var document = project.GetDocument(HostDocument.FilePath);
+ var service = new RazorSpanMappingService(document);
+
+ var output = await document.GetGeneratedOutputAsync();
+ var generated = output.GetCSharpDocument();
+
+ var symbol = "SomeProperty";
+ // Second occurrence
+ var span = new TextSpan(generated.GeneratedCode.IndexOf(symbol, generated.GeneratedCode.IndexOf(symbol) + symbol.Length), symbol.Length);
+
+ // Act
+ var result = RazorSpanMappingService.TryGetLinePositionSpan(span, await document.GetTextAsync(), generated, out var mapped);
+
+ // Assert
+ Assert.True(result);
+ Assert.Equal(new LinePositionSpan(new LinePosition(2, 1), new LinePosition(2, 13)), mapped);
+ }
+
+ [Fact]
+ public async Task TryGetLinePositionSpan_SpanWithinSourceMapping_ReturnsTrue()
+ {
+ // Arrange
+ var sourceText = SourceText.From(@"
+@{
+ var x = SomeClass.SomeProperty;
+}
+");
+
+ var project = new DefaultProjectSnapshot(
+ ProjectState.Create(Workspace.Services, HostProject)
+ .WithAddedHostDocument(HostDocument, () =>
+ {
+ return Task.FromResult(TextAndVersion.Create(sourceText, VersionStamp.Create()));
+ }));
+
+ var document = project.GetDocument(HostDocument.FilePath);
+ var service = new RazorSpanMappingService(document);
+
+ var output = await document.GetGeneratedOutputAsync();
+ var generated = output.GetCSharpDocument();
+
+ var symbol = "SomeProperty";
+ var span = new TextSpan(generated.GeneratedCode.IndexOf(symbol), symbol.Length);
+
+ // Act
+ var result = RazorSpanMappingService.TryGetLinePositionSpan(span, await document.GetTextAsync(), generated, out var mapped);
+
+ // Assert
+ Assert.True(result);
+ Assert.Equal(new LinePositionSpan(new LinePosition(2, 22), new LinePosition(2, 34)), mapped);
+ }
+
+ [Fact]
+ public async Task TryGetLinePositionSpan_SpanOutsideSourceMapping_ReturnsFalse()
+ {
+ // Arrange
+ var sourceText = SourceText.From(@"
+@{
+ var x = SomeClass.SomeProperty;
+}
+");
+
+ var project = new DefaultProjectSnapshot(
+ ProjectState.Create(Workspace.Services, HostProject)
+ .WithAddedHostDocument(HostDocument, () =>
+ {
+ return Task.FromResult(TextAndVersion.Create(sourceText, VersionStamp.Create()));
+ }));
+
+ var document = project.GetDocument(HostDocument.FilePath);
+ var service = new RazorSpanMappingService(document);
+
+ var output = await document.GetGeneratedOutputAsync();
+ var generated = output.GetCSharpDocument();
+
+ var symbol = "ExecuteAsync";
+ var span = new TextSpan(generated.GeneratedCode.IndexOf(symbol), symbol.Length);
+
+ // Act
+ var result = RazorSpanMappingService.TryGetLinePositionSpan(span, await document.GetTextAsync(), generated, out var mapped);
+
+ // Assert
+ Assert.False(result);
+ }
+ }
+}
\ No newline at end of file