From 6de884a23a26c8045d24dd8beddcda12bbf57e1e Mon Sep 17 00:00:00 2001 From: David Wengier Date: Fri, 17 Oct 2025 16:46:36 +1100 Subject: [PATCH 01/15] Use the full file path as the target path when it's missing --- .../RazorSourceGenerator.RazorProviders.cs | 5 +- .../Extensions/TextDocumentExtensions.cs | 3 +- .../Shared/ComputedTargetPathTest.cs | 133 ++++++++++++++++++ 3 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/ComputedTargetPathTest.cs diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.RazorProviders.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.RazorProviders.cs index 8b802d89a68..570e6288f37 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.RazorProviders.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.RazorProviders.cs @@ -94,8 +94,9 @@ private static (SourceGeneratorProjectItem?, Diagnostic?) ComputeProjectItems((A } else { - // If the TargetPath is not provided, we effectively assume its in the root of the project. - relativePath = Path.GetFileName(additionalText.Path); + // If the TargetPath is not provided, it could be a Misc Files situation, or just a project that isn't using the + // Web or Razor SDK. In this case, we just use the physical path. + relativePath = additionalText.Path; } options.TryGetValue("build_metadata.AdditionalFiles.CssScope", out var cssScope); diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/TextDocumentExtensions.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/TextDocumentExtensions.cs index 3d7f24baa6f..8a7eed31576 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/TextDocumentExtensions.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/TextDocumentExtensions.cs @@ -28,8 +28,7 @@ public static bool TryComputeHintNameFromRazorDocument(this TextDocument razorDo var filePath = razorDocument.FilePath.AsSpanOrDefault(); if (string.IsNullOrEmpty(razorDocument.Project.FilePath)) { - var fileName = filePath[filePath.LastIndexOfAny(['/', '\\'])..].TrimStart(['/', '\\']); - hintName = RazorSourceGenerator.GetIdentifierFromPath(fileName); + hintName = RazorSourceGenerator.GetIdentifierFromPath(filePath); return hintName is not null; } diff --git a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/ComputedTargetPathTest.cs b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/ComputedTargetPathTest.cs new file mode 100644 index 00000000000..b7cd2bf5878 --- /dev/null +++ b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/ComputedTargetPathTest.cs @@ -0,0 +1,133 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using Microsoft.VisualStudio.Razor.LanguageClient.Cohost; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.VisualStudioCode.RazorExtension.Test.Endpoints.Shared; + +public class ComputedTargetPathTest(ITestOutputHelper testOutputHelper) : CohostEndpointTestBase(testOutputHelper) +{ + [Fact] + public async Task GetHintName() + { + // Creating a misc files project will mean that there is no globalconfig created, so no target paths will be set + var document = CreateProjectAndRazorDocument(""); + + _ = await document.Project.GetCompilationAsync(DisposalToken); + + Assert.True(document.TryComputeHintNameFromRazorDocument(out var hintName)); + var generatedDocument = await document.Project.TryGetSourceGeneratedDocumentFromHintNameAsync(hintName, DisposalToken); + Assert.NotNull(generatedDocument); + } + + [Fact] + public async Task NoGlobalConfig_WithProjectFilePath() + { + var doc1Path = FilePath(@"Pages\Index.razor"); + + var document = CreateProjectAndRazorDocument(""); + + var doc1 = document.Project.AddAdditionalDocument( + doc1Path, + SourceText.From(""" +
This is a page
+ """), + filePath: doc1Path); + + var project = doc1.Project.RemoveAnalyzerConfigDocument(doc1.Project.AnalyzerConfigDocuments.First().Id); + + _ = await project.GetCompilationAsync(DisposalToken); + + doc1 = project.GetAdditionalDocument(doc1.Id).AssumeNotNull(); + + Assert.True(doc1.TryComputeHintNameFromRazorDocument(out var hintName)); + var generatedDocument = await doc1.Project.TryGetSourceGeneratedDocumentFromHintNameAsync(hintName, DisposalToken); + Assert.NotNull(generatedDocument); + } + + [Fact] + public async Task NoGlobalConfig_NoProjectFilePath() + { + var doc1Path = FilePath(@"Pages\Index.razor"); + + // Creating a misc files project will mean that there is no globalconfig created, so no target paths will be set + var document = CreateProjectAndRazorDocument("", miscellaneousFile: true); + + var doc1 = document.Project.AddAdditionalDocument( + doc1Path, + SourceText.From(""" +
This is a page
+ """), + filePath: doc1Path); + + _ = await doc1.Project.GetCompilationAsync(DisposalToken); + + Assert.True(doc1.TryComputeHintNameFromRazorDocument(out var hintName)); + var generatedDocument = await doc1.Project.TryGetSourceGeneratedDocumentFromHintNameAsync(hintName, DisposalToken); + Assert.NotNull(generatedDocument); + } + + [Fact] + public async Task NoGlobalConfig_MultipleFilesWithTheSameName() + { + var doc1Path = FilePath(@"Pages\Index.razor"); + var doc2Path = FilePath(@"Components\Index.razor"); + + // Creating a misc files project will mean that there is no globalconfig created, so no target paths will be set + var document = CreateProjectAndRazorDocument("", miscellaneousFile: true); + + var doc1 = document.Project.AddAdditionalDocument( + doc1Path, + SourceText.From(""" +
This is a page
+ """), + filePath: doc1Path); + var doc2 = doc1.Project.AddAdditionalDocument( + doc2Path, + SourceText.From(""" +
This is a component
+ """), + filePath: doc2Path); + + // Make sure we have a doc1 from the final project + doc1 = doc2.Project.GetAdditionalDocument(doc1.Id).AssumeNotNull(); + + Assert.True(doc1.TryComputeHintNameFromRazorDocument(out var hintName1)); + var generatedDocument = await doc1.Project.TryGetSourceGeneratedDocumentFromHintNameAsync(hintName1, DisposalToken); + Assert.NotNull(generatedDocument); + + Assert.True(doc2.TryComputeHintNameFromRazorDocument(out var hintName2)); + generatedDocument = await doc2.Project.TryGetSourceGeneratedDocumentFromHintNameAsync(hintName2, DisposalToken); + Assert.NotNull(generatedDocument); + } + + [Fact] + public async Task NotAllFilesHaveTargetPaths() + { + var doc1Path = FilePath(@"Pages\Index.razor"); + + // This will create a project with a globalconfig, and target paths for a single razor file + var document = CreateProjectAndRazorDocument(""" +
This is a normal file with a target path + """); + + // Now add a file without updating the globalconfig + var doc1 = document.Project.AddAdditionalDocument( + doc1Path, + SourceText.From(""" +
This is an extra document
+ """), + filePath: doc1Path); + + Assert.True(doc1.TryComputeHintNameFromRazorDocument(out var hintName)); + var generatedDocument = await doc1.Project.TryGetSourceGeneratedDocumentFromHintNameAsync(hintName, DisposalToken); + Assert.NotNull(generatedDocument); + } +} From c1001ef1b0e6b6298c1806b7389a16f10b38966b Mon Sep 17 00:00:00 2001 From: David Wengier Date: Mon, 20 Oct 2025 12:50:08 +1100 Subject: [PATCH 02/15] Prefer matching on full path hint name, with fallback to project based otherwise --- ...hostDocumentPullDiagnosticsEndpointBase.cs | 7 +- .../Extensions/ProjectExtensions.cs | 67 +++++++++++++++++++ .../Extensions/TextDocumentExtensions.cs | 50 -------------- .../Cohost/CohostRoslynRenameTest.cs | 8 +-- .../Cohost/TextDocumentExtensionsTest.cs | 43 ------------ .../Shared/ComputedTargetPathTest.cs | 18 ++--- 6 files changed, 77 insertions(+), 116 deletions(-) delete mode 100644 src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/TextDocumentExtensions.cs delete mode 100644 src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/TextDocumentExtensionsTest.cs diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.CohostingShared/Diagnostics/CohostDocumentPullDiagnosticsEndpointBase.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.CohostingShared/Diagnostics/CohostDocumentPullDiagnosticsEndpointBase.cs index d86516a7fc2..540a58d37df 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.CohostingShared/Diagnostics/CohostDocumentPullDiagnosticsEndpointBase.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.CohostingShared/Diagnostics/CohostDocumentPullDiagnosticsEndpointBase.cs @@ -101,12 +101,7 @@ protected virtual TRequest CreateHtmlParams(Uri uri) protected static Task TryGetGeneratedDocumentAsync(TextDocument razorDocument, CancellationToken cancellationToken) { - if (!razorDocument.TryComputeHintNameFromRazorDocument(out var hintName)) - { - return SpecializedTasks.Null(); - } - - return razorDocument.Project.TryGetSourceGeneratedDocumentFromHintNameAsync(hintName, cancellationToken); + return razorDocument.Project.TryGetSourceGeneratedDocumentForRazorDocumentAsync(razorDocument, cancellationToken); } private async Task GetCSharpDiagnosticsAsync(TextDocument razorDocument, Guid correletionId, CancellationToken cancellationToken) diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/ProjectExtensions.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/ProjectExtensions.cs index 1901acc7784..909813cb613 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/ProjectExtensions.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/ProjectExtensions.cs @@ -8,12 +8,14 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.PooledObjects; using Microsoft.AspNetCore.Razor.Threading; using Microsoft.CodeAnalysis.ExternalAccess.Razor; using Microsoft.CodeAnalysis.Razor; using Microsoft.CodeAnalysis.Razor.Telemetry; +using Microsoft.NET.Sdk.Razor.SourceGenerators; namespace Microsoft.CodeAnalysis; @@ -103,6 +105,71 @@ private static ImmutableArray GetTagHelperDescript return generatedDocuments.SingleOrDefault(d => d.HintName == hintName); } + /// + /// Finds source generated documents by iterating through all of them. In OOP there are better options! + /// + public static async Task TryGetSourceGeneratedDocumentForRazorDocumentAsync(this Project project, TextDocument razorDocument, CancellationToken cancellationToken) + { + if (razorDocument.FilePath is null) + { + return null; + } + + var generatedDocuments = await project.GetSourceGeneratedDocumentsAsync(cancellationToken).ConfigureAwait(false); + + // For misc files, and projects that don't have a globalconfig file (eg, non Razor SDK projects), the hint name will be based + // on the full path of the file. + var fullPathHintName = RazorSourceGenerator.GetIdentifierFromPath(razorDocument.FilePath); + // For normal Razor SDK projects, the hint name will be based on the project-relative path of the file. + var projectRelativeHintName = GetProjectRelativeHintName(razorDocument); + + SourceGeneratedDocument? candidateDoc = null; + foreach (var doc in generatedDocuments) + { + if (doc.HintName == fullPathHintName) + { + // If the full path matches, we've found it for sure + return doc; + } + else if (doc.HintName == projectRelativeHintName) + { + if (candidateDoc is not null) + { + // Multiple documents with the same hint name found, can't be sure which one to return + // This can happen as a result of a bug in the source generator: https://github.com/dotnet/razor/issues/11578 + candidateDoc = null; + break; + } + + candidateDoc = doc; + } + } + + return candidateDoc; + + static string? GetProjectRelativeHintName(TextDocument razorDocument) + { + var filePath = razorDocument.FilePath.AsSpanOrDefault(); + if (string.IsNullOrEmpty(razorDocument.Project.FilePath)) + { + // Misc file - no project info to get a relative path + return null; + } + + var projectFilePath = razorDocument.Project.FilePath.AsSpanOrDefault(); + var projectBasePath = PathUtilities.GetDirectoryName(projectFilePath); + if (filePath.Length <= projectBasePath.Length) + { + // File must be from outside the project directory + return null; + } + + var relativeDocumentPath = filePath[projectBasePath.Length..].TrimStart(['/', '\\']); + + return RazorSourceGenerator.GetIdentifierFromPath(relativeDocumentPath); + } + } + /// /// Finds source generated documents by iterating through all of them. In OOP there are better options! /// diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/TextDocumentExtensions.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/TextDocumentExtensions.cs deleted file mode 100644 index 8a7eed31576..00000000000 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/TextDocumentExtensions.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Diagnostics.CodeAnalysis; -using Microsoft.AspNetCore.Razor; -using Microsoft.NET.Sdk.Razor.SourceGenerators; - -namespace Microsoft.CodeAnalysis; - -internal static class TextDocumentExtensions -{ - /// - /// This method tries to compute the source generated hint name for a Razor document using only string manipulation - /// - /// - /// This should only be used in the devenv process. In OOP we can look at the actual generated run result to find this - /// information. - /// - public static bool TryComputeHintNameFromRazorDocument(this TextDocument razorDocument, [NotNullWhen(true)] out string? hintName) - { - if (razorDocument.FilePath is null) - { - hintName = null; - return false; - } - - var filePath = razorDocument.FilePath.AsSpanOrDefault(); - if (string.IsNullOrEmpty(razorDocument.Project.FilePath)) - { - hintName = RazorSourceGenerator.GetIdentifierFromPath(filePath); - return hintName is not null; - } - - var projectFilePath = razorDocument.Project.FilePath.AsSpanOrDefault(); - var projectBasePath = PathUtilities.GetDirectoryName(projectFilePath); - if (filePath.Length <= projectBasePath.Length) - { - // File must be from outside the project directory - hintName = null; - return false; - } - - var relativeDocumentPath = filePath[projectBasePath.Length..].TrimStart(['/', '\\']); - - hintName = RazorSourceGenerator.GetIdentifierFromPath(relativeDocumentPath); - - return hintName is not null; - } -} diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostRoslynRenameTest.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostRoslynRenameTest.cs index 5ae9b5ffa99..a5a060b7bc0 100644 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostRoslynRenameTest.cs +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostRoslynRenameTest.cs @@ -169,8 +169,7 @@ private async Task VerifyRenamesAsync( var compilation = await project.GetCompilationAsync(DisposalToken); - Assert.True(razorDocument.TryComputeHintNameFromRazorDocument(out var hintName)); - var generatedDocument = await project.TryGetSourceGeneratedDocumentFromHintNameAsync(hintName, DisposalToken); + var generatedDocument = await project.TryGetSourceGeneratedDocumentForRazorDocumentAsync(razorDocument, DisposalToken); var node = await GetSyntaxNodeAsync(generatedDocument.AssumeNotNull(), razorFile.Position, razorDocument); @@ -227,10 +226,9 @@ private async Task VerifyVSRenameAsync(string newName, string expectedCSharpFile AssertEx.EqualOrDiff(expectedCSharpFile, csharpText.ToString()); // Normally in VS, TryApplyChanges would be called, and that calls into our edit mapping service. - Assert.True(razorDocument.TryComputeHintNameFromRazorDocument(out var hintName)); - var generatedDoc = await project.TryGetSourceGeneratedDocumentFromHintNameAsync(hintName, DisposalToken); + var generatedDoc = await project.TryGetSourceGeneratedDocumentForRazorDocumentAsync(razorDocument, DisposalToken); Assert.NotNull(generatedDoc); - var renamedGeneratedDoc = await solution.GetRequiredProject(project.Id).TryGetSourceGeneratedDocumentFromHintNameAsync(hintName, DisposalToken); + var renamedGeneratedDoc = await solution.GetRequiredProject(project.Id).TryGetSourceGeneratedDocumentForRazorDocumentAsync(razorDocument, DisposalToken); Assert.NotNull(renamedGeneratedDoc); // It could be argued this class is really a RazorSourceGeneratedDocumentSpanMappingService test :) diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/TextDocumentExtensionsTest.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/TextDocumentExtensionsTest.cs deleted file mode 100644 index 3c782b4a9fa..00000000000 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/TextDocumentExtensionsTest.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.IO; -using Microsoft.AspNetCore.Razor.Test.Common; -using Microsoft.AspNetCore.Razor.Test.Common.VisualStudio; -using Microsoft.CodeAnalysis; -using Xunit; -using Xunit.Abstractions; - -namespace Microsoft.VisualStudio.LanguageServices.Razor.Test.Cohost; - -public class TextDocumentExtensionsTest(ITestOutputHelper testOutput) : VisualStudioWorkspaceTestBase(testOutput) -{ - [Theory] - [InlineData(@"Pages\Index.razor")] - [InlineData(@"Pages/Index.razor")] - [InlineData(@"Pages.Index.razor")] - public void TryComputeHintNameFromRazorDocument(string razorFilePath) - { - var projectId = ProjectId.CreateNewId(); - var projectInfo = ProjectInfo - .Create( - projectId, - VersionStamp.Create(), - name: "Project", - assemblyName: "Project", - LanguageNames.CSharp, - TestProjectData.SomeProject.FilePath); - - var documentId = DocumentId.CreateNewId(projectId); - var solution = Workspace.CurrentSolution - .AddProject(projectInfo) - .AddAdditionalDocument(documentId, "File.razor", "", filePath: Path.Combine(TestProjectData.SomeProjectPath, razorFilePath)); - - var document = solution.GetAdditionalDocument(documentId); - - Assert.NotNull(document); - Assert.True(document.TryComputeHintNameFromRazorDocument(out var hintName)); - // This tests TryComputeHintNameFromRazorDocument and also neatly demonstrates a bug: https://github.com/dotnet/razor/issues/11578 - Assert.Equal("Pages_Index_razor.g.cs", hintName); - } -} diff --git a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/ComputedTargetPathTest.cs b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/ComputedTargetPathTest.cs index b7cd2bf5878..09e1d72f5eb 100644 --- a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/ComputedTargetPathTest.cs +++ b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/ComputedTargetPathTest.cs @@ -22,8 +22,7 @@ public async Task GetHintName() _ = await document.Project.GetCompilationAsync(DisposalToken); - Assert.True(document.TryComputeHintNameFromRazorDocument(out var hintName)); - var generatedDocument = await document.Project.TryGetSourceGeneratedDocumentFromHintNameAsync(hintName, DisposalToken); + var generatedDocument = await document.Project.TryGetSourceGeneratedDocumentForRazorDocumentAsync(document, DisposalToken); Assert.NotNull(generatedDocument); } @@ -47,8 +46,7 @@ public async Task NoGlobalConfig_WithProjectFilePath() doc1 = project.GetAdditionalDocument(doc1.Id).AssumeNotNull(); - Assert.True(doc1.TryComputeHintNameFromRazorDocument(out var hintName)); - var generatedDocument = await doc1.Project.TryGetSourceGeneratedDocumentFromHintNameAsync(hintName, DisposalToken); + var generatedDocument = await doc1.Project.TryGetSourceGeneratedDocumentForRazorDocumentAsync(document, DisposalToken); Assert.NotNull(generatedDocument); } @@ -69,8 +67,7 @@ public async Task NoGlobalConfig_NoProjectFilePath() _ = await doc1.Project.GetCompilationAsync(DisposalToken); - Assert.True(doc1.TryComputeHintNameFromRazorDocument(out var hintName)); - var generatedDocument = await doc1.Project.TryGetSourceGeneratedDocumentFromHintNameAsync(hintName, DisposalToken); + var generatedDocument = await doc1.Project.TryGetSourceGeneratedDocumentForRazorDocumentAsync(document, DisposalToken); Assert.NotNull(generatedDocument); } @@ -99,12 +96,10 @@ public async Task NoGlobalConfig_MultipleFilesWithTheSameName() // Make sure we have a doc1 from the final project doc1 = doc2.Project.GetAdditionalDocument(doc1.Id).AssumeNotNull(); - Assert.True(doc1.TryComputeHintNameFromRazorDocument(out var hintName1)); - var generatedDocument = await doc1.Project.TryGetSourceGeneratedDocumentFromHintNameAsync(hintName1, DisposalToken); + var generatedDocument = await doc1.Project.TryGetSourceGeneratedDocumentForRazorDocumentAsync(doc1, DisposalToken); Assert.NotNull(generatedDocument); - Assert.True(doc2.TryComputeHintNameFromRazorDocument(out var hintName2)); - generatedDocument = await doc2.Project.TryGetSourceGeneratedDocumentFromHintNameAsync(hintName2, DisposalToken); + generatedDocument = await doc2.Project.TryGetSourceGeneratedDocumentForRazorDocumentAsync(doc2, DisposalToken); Assert.NotNull(generatedDocument); } @@ -126,8 +121,7 @@ public async Task NotAllFilesHaveTargetPaths() """), filePath: doc1Path); - Assert.True(doc1.TryComputeHintNameFromRazorDocument(out var hintName)); - var generatedDocument = await doc1.Project.TryGetSourceGeneratedDocumentFromHintNameAsync(hintName, DisposalToken); + var generatedDocument = await doc1.Project.TryGetSourceGeneratedDocumentForRazorDocumentAsync(doc1, DisposalToken); Assert.NotNull(generatedDocument); } } From 378fc1b114f01ba64053b08adda84d7692891025 Mon Sep 17 00:00:00 2001 From: David Wengier Date: Mon, 20 Oct 2025 15:12:05 +1100 Subject: [PATCH 03/15] Fix document symbol test --- .../Endpoints/Shared/CohostDocumentSymbolEndpointTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CohostDocumentSymbolEndpointTest.cs b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CohostDocumentSymbolEndpointTest.cs index ce7c716b34d..6bfe185dd46 100644 --- a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CohostDocumentSymbolEndpointTest.cs +++ b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CohostDocumentSymbolEndpointTest.cs @@ -49,7 +49,7 @@ public Task DocumentSymbols_CSharpClassWithMethods_MiscFile(bool hierarchical) => VerifyDocumentSymbolsAsync( """ @functions { - class {|ASP.File1.C:C|} + class {|ASP.c_.users.example.src.SomeProject.File1.C:C|} { private void {|HandleString(string s):HandleString|}(string s) { From 81e90855d5dddc9f8082526e7405258f20aa6d9e Mon Sep 17 00:00:00 2001 From: David Wengier Date: Mon, 20 Oct 2025 16:18:42 +1100 Subject: [PATCH 04/15] Cross platform fix document symbol test --- .../Microsoft.CodeAnalysis.Razor.Compiler.csproj | 1 + .../Shared/CohostDocumentSymbolEndpointTest.cs | 13 ++++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Microsoft.CodeAnalysis.Razor.Compiler.csproj b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Microsoft.CodeAnalysis.Razor.Compiler.csproj index 3d491fee7b9..6b441266e8c 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Microsoft.CodeAnalysis.Razor.Compiler.csproj +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Microsoft.CodeAnalysis.Razor.Compiler.csproj @@ -68,6 +68,7 @@ + diff --git a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CohostDocumentSymbolEndpointTest.cs b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CohostDocumentSymbolEndpointTest.cs index 6bfe185dd46..bab97ce3211 100644 --- a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CohostDocumentSymbolEndpointTest.cs +++ b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CohostDocumentSymbolEndpointTest.cs @@ -6,6 +6,7 @@ using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor; +using Microsoft.AspNetCore.Razor.Utilities; using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.CodeAnalysis.Testing; using Microsoft.CodeAnalysis.Text; @@ -46,10 +47,15 @@ class {|SomeProject.File1.C:C|} [Theory] [CombinatorialData] public Task DocumentSymbols_CSharpClassWithMethods_MiscFile(bool hierarchical) - => VerifyDocumentSymbolsAsync( - """ + { + // What the source generator would product for TestProjectData.SomeProjectPath + var generatedNamespace = PlatformInformation.IsWindows + ? "c_.users.example.src.SomeProject" + : "home.example.SomeProject"; + return VerifyDocumentSymbolsAsync( + $$""" @functions { - class {|ASP.c_.users.example.src.SomeProject.File1.C:C|} + class {|ASP.{{generatedNamespace}}.File1.C:C|} { private void {|HandleString(string s):HandleString|}(string s) { @@ -69,6 +75,7 @@ class {|ASP.c_.users.example.src.SomeProject.File1.C:C|} } """, hierarchical, miscellaneousFile: true); + } [Theory] [CombinatorialData] From 66f2a18706120135271af159031cee90347d9df0 Mon Sep 17 00:00:00 2001 From: David Wengier Date: Mon, 20 Oct 2025 18:07:04 +1100 Subject: [PATCH 05/15] Make sure we're only looking at Razor generated files --- .../Extensions/ProjectExtensions.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/ProjectExtensions.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/ProjectExtensions.cs index 909813cb613..967f55a17d7 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/ProjectExtensions.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/ProjectExtensions.cs @@ -126,6 +126,11 @@ private static ImmutableArray GetTagHelperDescript SourceGeneratedDocument? candidateDoc = null; foreach (var doc in generatedDocuments) { + if (!doc.IsRazorSourceGeneratedDocument()) + { + continue; + } + if (doc.HintName == fullPathHintName) { // If the full path matches, we've found it for sure From 690cc378e67bebb579b1aa88f224ec8befb17f80 Mon Sep 17 00:00:00 2001 From: David Wengier Date: Wed, 22 Oct 2025 12:46:22 +1100 Subject: [PATCH 06/15] Use the MSBuildProjectDirectory property to compute a target path if it's available, and TargetPath isn't --- .../RazorSourceGenerator.RazorProviders.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.RazorProviders.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.RazorProviders.cs index 570e6288f37..9c4e6c353d4 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.RazorProviders.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.RazorProviders.cs @@ -77,6 +77,8 @@ private static (SourceGeneratorProjectItem?, Diagnostic?) ComputeProjectItems((A var (additionalText, globalOptions) = pair; var options = globalOptions.GetOptions(additionalText); + globalOptions.GlobalOptions.TryGetValue("build_property.MSBuildProjectDirectory", out var projectPath); + string relativePath; if (options.TryGetValue("build_metadata.AdditionalFiles.TargetPath", out var encodedRelativePath)) { @@ -92,6 +94,14 @@ private static (SourceGeneratorProjectItem?, Diagnostic?) ComputeProjectItems((A relativePath = Encoding.UTF8.GetString(Convert.FromBase64String(encodedRelativePath)); } + else if (projectPath is { Length: > 0 } && + additionalText.Path.StartsWith(projectPath, StringComparison.OrdinalIgnoreCase)) + { + // Fallback, when TargetPath isn't specified but we know about the project directory, we can do our own calulation of + // the project relative path, and use that as the target path. This is an easy way for a project that isn't using the + // Razor SDK to still get TargetPath functionality without the complexity of specifying metadata on every item. + relativePath = additionalText.Path[projectPath.Length..].TrimStart(['/', '\\']); + } else { // If the TargetPath is not provided, it could be a Misc Files situation, or just a project that isn't using the From 58b49fd21d1dab715b2e9245216ff23cb4c365c5 Mon Sep 17 00:00:00 2001 From: David Wengier Date: Wed, 22 Oct 2025 12:47:06 +1100 Subject: [PATCH 07/15] WIP: test --- .../Shared/ComputedTargetPathTest.cs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/ComputedTargetPathTest.cs b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/ComputedTargetPathTest.cs index 09e1d72f5eb..44516995208 100644 --- a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/ComputedTargetPathTest.cs +++ b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/ComputedTargetPathTest.cs @@ -2,9 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Linq; +using System.Text; +using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor; +using Microsoft.AspNetCore.Razor.Test.Common; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.Razor.LanguageClient.Cohost; using Xunit; @@ -124,4 +128,61 @@ public async Task NotAllFilesHaveTargetPaths() var generatedDocument = await doc1.Project.TryGetSourceGeneratedDocumentForRazorDocumentAsync(doc1, DisposalToken); Assert.NotNull(generatedDocument); } + + [Fact] + public async Task WithSuppliedMSBuildProjectPath_MultipleFilesWithTheSameName() + { + var doc1Path = FilePath(@"Pages\Index.razor"); + var doc2Path = FilePath(@"Components\Index.razor"); + + // Creating a misc files project will mean that there is no globalconfig created, so no target paths will be set + var document = CreateProjectAndRazorDocument("", miscellaneousFile: true); + + var globalConfigContent = new StringBuilder(); + globalConfigContent.AppendLine($""" + is_global = true + + build_property.MSBuildProjectDirectory = {TestProjectData.SomeProjectPath} + """); + + var globalConfigDoc = document.Project.AddAnalyzerConfigDocument( + name: ".globalconfig", + text: SourceText.From(globalConfigContent.ToString()), + filePath: FilePath(".globalconfig")); + + var doc1 = globalConfigDoc.Project.AddAdditionalDocument( + doc1Path, + SourceText.From(""" +
This is a page
+ """), + filePath: doc1Path); + var doc2 = doc1.Project.AddAdditionalDocument( + doc2Path, + SourceText.From(""" +
This is a component
+ """), + filePath: doc2Path); + + // Make sure we have a doc1 from the final project + doc1 = doc2.Project.GetAdditionalDocument(doc1.Id).AssumeNotNull(); + + var generatedDocument = await doc1.Project.TryGetSourceGeneratedDocumentForRazorDocumentAsync(doc1, DisposalToken); + Assert.NotNull(generatedDocument); + var className = await GetClassNameAsync(generatedDocument, DisposalToken); + Assert.Equal("Pages_Index", className); + + generatedDocument = await doc2.Project.TryGetSourceGeneratedDocumentForRazorDocumentAsync(doc2, DisposalToken); + Assert.NotNull(generatedDocument); + className = await GetClassNameAsync(generatedDocument, DisposalToken); + Assert.Equal("Components_Index", className); + } + + private async Task GetClassNameAsync(SourceGeneratedDocument generatedDocument, CancellationToken cancellationToken) + { + var root = await generatedDocument.GetSyntaxRootAsync(cancellationToken); + Assert.NotNull(root); + var classDeclaration = root.DescendantNodes().OfType().Single(); + + return classDeclaration.Identifier.ValueText; + } } From 8c61fed7a3a778b4834f2579621060258d513869 Mon Sep 17 00:00:00 2001 From: David Wengier Date: Wed, 22 Oct 2025 14:32:28 +1100 Subject: [PATCH 08/15] Extract project creation into something reusable --- .../CohostTestBase.cs | 127 +++++---------- .../RazorProjectBuilder.cs | 144 ++++++++++++++++++ .../Cohost/CohostEndpointTestBase.cs | 36 +++-- .../CohostEndpointTestBase.cs | 10 +- 4 files changed, 207 insertions(+), 110 deletions(-) create mode 100644 src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Cohosting/RazorProjectBuilder.cs diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Cohosting/CohostTestBase.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Cohosting/CohostTestBase.cs index 6ca282c248f..8fc91840526 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Cohosting/CohostTestBase.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Cohosting/CohostTestBase.cs @@ -6,16 +6,12 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; -using System.Text; using System.Threading.Tasks; using Basic.Reference.Assemblies; using Microsoft.AspNetCore.Razor; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Test.Common; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Diagnostics; -using Microsoft.CodeAnalysis.Razor.ProjectSystem; using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.CodeAnalysis.Razor.Remote; using Microsoft.CodeAnalysis.Razor.Workspaces; @@ -23,7 +19,6 @@ using Microsoft.CodeAnalysis.Remote.Razor; using Microsoft.CodeAnalysis.Remote.Razor.Logging; using Microsoft.CodeAnalysis.Text; -using Microsoft.NET.Sdk.Razor.SourceGenerators; using Microsoft.VisualStudio.Composition; using Roslyn.Test.Utilities; using Xunit; @@ -41,6 +36,7 @@ public abstract class CohostTestBase(ITestOutputHelper testOutputHelper) : Tooli private protected abstract IRemoteServiceInvoker RemoteServiceInvoker { get; } private protected abstract IClientSettingsManager ClientSettingsManager { get; } private protected abstract IFilePathService FilePathService { get; } + private protected abstract CodeAnalysis.Workspace LocalWorkspace { get; } private protected TestIncompatibleProjectService IncompatibleProjectService => _incompatibleProjectService.AssumeNotNull(); private protected RemoteLanguageServerFeatureOptions FeatureOptions => OOPExportProvider.GetExportedValue(); @@ -155,108 +151,57 @@ protected static TextDocument CreateProjectAndRazorDocument(CodeAnalysis.Workspa protected static TextDocument AddProjectAndRazorDocument(Solution solution, [DisallowNull] string? projectFilePath, ProjectId projectId, bool miscellaneousFile, DocumentId documentId, string documentFilePath, string contents, (string fileName, string contents)[]? additionalFiles, bool inGlobalNamespace) { - // We simulate a miscellaneous file project by not having a project file path. - projectFilePath = miscellaneousFile ? null : projectFilePath; - var projectName = miscellaneousFile ? "" : Path.GetFileNameWithoutExtension(projectFilePath).AssumeNotNull(); - - var sgAssembly = typeof(RazorSourceGenerator).Assembly; - - var projectInfo = ProjectInfo - .Create( - projectId, - VersionStamp.Create(), - name: projectName, - assemblyName: projectName, - LanguageNames.CSharp, - projectFilePath, - compilationOptions: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) - .WithMetadataReferences( - miscellaneousFile - ? Net461.ReferenceInfos.All.Select(r => r.Reference) // This isn't quite what Roslyn does, but its close enough for our tests - : AspNet80.ReferenceInfos.All.Select(r => r.Reference)) - .WithAnalyzerReferences([new AnalyzerFileReference(sgAssembly.Location, TestAnalyzerAssemblyLoader.LoadFromFile)]); - - if (!miscellaneousFile && !inGlobalNamespace) - { - projectInfo = projectInfo.WithDefaultNamespace(TestProjectData.SomeProject.RootNamespace); - } + var builder = new RazorProjectBuilder(projectId); - solution = solution.AddProject(projectInfo); + builder.AddReferences(miscellaneousFile + ? Net461.ReferenceInfos.All.Select(r => r.Reference) // This isn't quite what Roslyn does, but its close enough for our tests + : AspNet80.ReferenceInfos.All.Select(r => r.Reference)); + builder.GenerateGlobalConfigFile = !miscellaneousFile; + builder.RootNamespace = null; - solution = solution - .AddAdditionalDocument( - documentId, - documentFilePath, - SourceText.From(contents), - filePath: documentFilePath); + builder.AddAdditionalDocument(documentId, documentFilePath, SourceText.From(contents)); if (!miscellaneousFile) { - solution = solution.AddAdditionalDocument( - DocumentId.CreateNewId(projectId), - name: TestProjectData.SomeProjectComponentImportFile1.FilePath, - text: SourceText.From(""" - @using Microsoft.AspNetCore.Components - @using Microsoft.AspNetCore.Components.Authorization - @using Microsoft.AspNetCore.Components.Forms - @using Microsoft.AspNetCore.Components.Routing - @using Microsoft.AspNetCore.Components.Web - """), - filePath: TestProjectData.SomeProjectComponentImportFile1.FilePath) - .AddAdditionalDocument( - DocumentId.CreateNewId(projectId), - name: "_ViewImports.cshtml", - text: SourceText.From(""" - @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers - """), - filePath: TestProjectData.SomeProjectImportFile.FilePath); + builder.ProjectFilePath = projectFilePath; - if (additionalFiles is not null) + if (!inGlobalNamespace) { - foreach (var file in additionalFiles) - { - solution = Path.GetExtension(file.fileName) == ".cs" - ? solution.AddDocument(DocumentId.CreateNewId(projectId), name: file.fileName, text: SourceText.From(file.contents), filePath: file.fileName) - : solution.AddAdditionalDocument(DocumentId.CreateNewId(projectId), name: file.fileName, text: SourceText.From(file.contents), filePath: file.fileName); - } + builder.RootNamespace = TestProjectData.SomeProject.RootNamespace; } - var globalConfigContent = new StringBuilder(); - globalConfigContent.AppendLine($""" - is_global = true - - build_property.RazorLangVersion = {FallbackRazorConfiguration.Latest.LanguageVersion} - build_property.RazorConfiguration = {FallbackRazorConfiguration.Latest.ConfigurationName} - build_property.RootNamespace = {TestProjectData.SomeProject.RootNamespace} - - # This might suprise you, but by suppressing the source generator here, we're mirroring what happens in the Razor SDK - build_property.SuppressRazorSourceGenerator = true - """); + builder.AddAdditionalDocument( + filePath: TestProjectData.SomeProjectComponentImportFile1.FilePath, + text: SourceText.From(""" + @using Microsoft.AspNetCore.Components + @using Microsoft.AspNetCore.Components.Authorization + @using Microsoft.AspNetCore.Components.Forms + @using Microsoft.AspNetCore.Components.Routing + @using Microsoft.AspNetCore.Components.Web + """)); + builder.AddAdditionalDocument( + filePath: TestProjectData.SomeProjectImportFile.FilePath, + text: SourceText.From(""" + @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers + """)); - var projectBasePath = Path.GetDirectoryName(projectFilePath).AssumeNotNull(); - // Normally MS Build targets do this for us, but we're on our own! - foreach (var razorDocument in solution.GetRequiredProject(projectId).AdditionalDocuments) + if (additionalFiles is not null) { - if (razorDocument.FilePath is not null && - razorDocument.FilePath.StartsWith(projectBasePath)) + foreach (var file in additionalFiles) { - var relativePath = razorDocument.FilePath[(projectBasePath.Length + 1)..]; - globalConfigContent.AppendLine($""" - - [{razorDocument.FilePath.AssumeNotNull().Replace('\\', '/')}] - build_metadata.AdditionalFiles.TargetPath = {Convert.ToBase64String(Encoding.UTF8.GetBytes(relativePath))} - """); + if (Path.GetExtension(file.fileName) == ".cs") + { + builder.AddDocument(filePath: file.fileName, text: SourceText.From(file.contents)); + } + else + { + builder.AddAdditionalDocument(filePath: file.fileName, text: SourceText.From(file.contents)); + } } } - - solution = solution.AddAnalyzerConfigDocument( - DocumentId.CreateNewId(projectId), - name: ".globalconfig", - text: SourceText.From(globalConfigContent.ToString()), - filePath: Path.Combine(TestProjectData.SomeProjectPath, ".globalconfig")); } - return solution.GetAdditionalDocument(documentId).AssumeNotNull(); + return builder.Build(solution).GetAdditionalDocument(documentId).AssumeNotNull(); } protected static Uri FileUri(string projectRelativeFileName) diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Cohosting/RazorProjectBuilder.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Cohosting/RazorProjectBuilder.cs new file mode 100644 index 00000000000..623519b5246 --- /dev/null +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Cohosting/RazorProjectBuilder.cs @@ -0,0 +1,144 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Microsoft.AspNetCore.Razor; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; +using Microsoft.CodeAnalysis.Text; +using Microsoft.NET.Sdk.Razor.SourceGenerators; +using Roslyn.Test.Utilities; + +namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost; + +internal class RazorProjectBuilder(ProjectId? id = null) +{ + public ProjectId Id { get; } = id ?? ProjectId.CreateNewId(); + + public string? ProjectName { get; set; } + + public string? ProjectFilePath + { + get; + set + { + ProjectName ??= Path.GetFileNameWithoutExtension(value); + + field = value; + } + } + + public string? RootNamespace { get; set; } = "ASP"; + + public bool ReferenceRazorSourceGenerator { get; set; } = true; + public bool GenerateGlobalConfigFile { get; set; } = true; + + private readonly List _references = []; + private readonly List<(DocumentId id, string name, SourceText text, string filePath)> _documents = []; + private readonly List<(DocumentId id, string name, SourceText text, string filePath)> _additionalDocuments = []; + + internal void AddReferences(IEnumerable enumerable) + { + _references.AddRange(enumerable); + } + + internal DocumentId AddDocument(string filePath, SourceText text) + { + var name = Path.GetFileName(filePath); + var id = DocumentId.CreateNewId(Id, name); + _documents.Add((id, name, text, filePath)); + return id; + } + + internal DocumentId AddAdditionalDocument(string filePath, SourceText text) + { + var name = Path.GetFileName(filePath); + var id = DocumentId.CreateNewId(Id, name); + AddAdditionalDocument(id, filePath, text); + return id; + } + + internal void AddAdditionalDocument(DocumentId id, string filePath, SourceText text) + { + var name = Path.GetFileName(filePath); + _additionalDocuments.Add((id, name, text, filePath)); + } + + public Solution Build(Solution solution) + { + var sgAssembly = typeof(RazorSourceGenerator).Assembly; + + var projectInfo = ProjectInfo + .Create( + Id, + VersionStamp.Create(), + name: ProjectName ?? "", + assemblyName: ProjectName ?? "", + LanguageNames.CSharp, + ProjectFilePath, + compilationOptions: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) + .WithMetadataReferences(_references) + .WithDefaultNamespace(RootNamespace); + + if (ReferenceRazorSourceGenerator) + { + projectInfo = projectInfo.WithAnalyzerReferences([new AnalyzerFileReference(sgAssembly.Location, TestAnalyzerAssemblyLoader.LoadFromFile)]); + } + + solution = solution.AddProject(projectInfo); + + foreach (var document in _documents) + { + solution = solution.AddDocument(document.id, document.name, document.text, filePath: document.filePath); + } + + foreach (var additionalDocument in _additionalDocuments) + { + solution = solution.AddAdditionalDocument(additionalDocument.id, additionalDocument.name, additionalDocument.text, filePath: additionalDocument.filePath); + } + + if (GenerateGlobalConfigFile) + { + var globalConfigContent = new StringBuilder(); + + globalConfigContent.AppendLine($""" + is_global = true + + build_property.RazorLangVersion = {FallbackRazorConfiguration.Latest.LanguageVersion} + build_property.RazorConfiguration = {FallbackRazorConfiguration.Latest.ConfigurationName} + build_property.RootNamespace = {RootNamespace} + + # This might suprise you, but by suppressing the source generator here, we're mirroring what happens in the Razor SDK + build_property.SuppressRazorSourceGenerator = true + """); + + var projectBasePath = Path.GetDirectoryName(ProjectFilePath).AssumeNotNull(); + foreach (var additionalDocument in _additionalDocuments) + { + if (additionalDocument.filePath is not null && + additionalDocument.filePath.StartsWith(projectBasePath)) + { + var relativePath = additionalDocument.filePath[(projectBasePath.Length + 1)..]; + globalConfigContent.AppendLine($""" + + [{additionalDocument.filePath.AssumeNotNull().Replace('\\', '/')}] + build_metadata.AdditionalFiles.TargetPath = {Convert.ToBase64String(Encoding.UTF8.GetBytes(relativePath))} + """); + } + } + + solution = solution.AddAnalyzerConfigDocument( + DocumentId.CreateNewId(Id), + name: ".globalconfig", + text: SourceText.From(globalConfigContent.ToString()), + filePath: Path.Combine(projectBasePath, ".globalconfig")); + } + + return solution; + } +} diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostEndpointTestBase.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostEndpointTestBase.cs index 439cbf66772..0bb472b46fc 100644 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostEndpointTestBase.cs +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostEndpointTestBase.cs @@ -28,12 +28,14 @@ public abstract class CohostEndpointTestBase(ITestOutputHelper testOutputHelper) private IClientSettingsManager? _clientSettingsManager; private IFilePathService? _filePathService; private ISemanticTokensLegendService? _semanticTokensLegendService; + private CodeAnalysis.Workspace? _localWorkspace; private protected override IRemoteServiceInvoker RemoteServiceInvoker => _remoteServiceInvoker.AssumeNotNull(); private protected TestRemoteServiceInvoker TestRemoteServiceInvoker => _remoteServiceInvoker.AssumeNotNull(); private protected override IClientSettingsManager ClientSettingsManager => _clientSettingsManager.AssumeNotNull(); private protected override IFilePathService FilePathService => _filePathService.AssumeNotNull(); private protected ISemanticTokensLegendService SemanticTokensLegendService => _semanticTokensLegendService.AssumeNotNull(); + private protected override CodeAnalysis.Workspace LocalWorkspace => _localWorkspace.AssumeNotNull(); /// /// The export provider for Roslyn "devenv" services, if tests opt-in to using them @@ -52,6 +54,25 @@ protected override async Task InitializeAsync() _filePathService = new VisualStudioFilePathService(FeatureOptions); _semanticTokensLegendService = TestRazorSemanticTokensLegendService.GetInstance(supportsVSExtensions: true); + + _localWorkspace = CreateWorkspace(); + } + + private CodeAnalysis.Workspace? CreateWorkspace() + { + var composition = ConfigureRoslynDevenvComposition(TestComposition.Roslyn); + + // We can't enforce that the composition is entirely valid, because we don't have a full MEF catalog, but we + // can assume there should be no errors related to Razor, and having this array makes debugging failures a lot + // easier. + var errors = composition.GetCompositionErrors().ToArray(); + Assert.Empty(errors.Where(e => e.Contains("Razor"))); + + RoslynDevenvExportProvider = composition.ExportProviderFactory.CreateExportProvider(); + AddDisposable(RoslynDevenvExportProvider); + var workspace = TestWorkspace.CreateWithDiagnosticAnalyzers(RoslynDevenvExportProvider); + AddDisposable(workspace); + return workspace; } private protected override RemoteClientLSPInitializationOptions GetRemoteClientLSPInitializationOptions() @@ -137,20 +158,7 @@ private TextDocument CreateLocalProjectAndRazorDocument( (string fileName, string contents)[]? additionalFiles, bool inGlobalNamespace) { - var composition = ConfigureRoslynDevenvComposition(TestComposition.Roslyn); - - // We can't enforce that the composition is entirely valid, because we don't have a full MEF catalog, but we - // can assume there should be no errors related to Razor, and having this array makes debugging failures a lot - // easier. - var errors = composition.GetCompositionErrors().ToArray(); - Assert.Empty(errors.Where(e => e.Contains("Razor"))); - - RoslynDevenvExportProvider = composition.ExportProviderFactory.CreateExportProvider(); - AddDisposable(RoslynDevenvExportProvider); - var workspace = TestWorkspace.CreateWithDiagnosticAnalyzers(RoslynDevenvExportProvider); - AddDisposable(workspace); - - var razorDocument = CreateProjectAndRazorDocument(workspace, projectId, miscellaneousFile, documentId, documentFilePath, contents, additionalFiles, inGlobalNamespace); + var razorDocument = CreateProjectAndRazorDocument(LocalWorkspace, projectId, miscellaneousFile, documentId, documentFilePath, contents, additionalFiles, inGlobalNamespace); // If we're creating remote and local workspaces, then we'll return the local document, and have to allow // the remote service invoker to map from the local solution to the remote one. diff --git a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/CohostEndpointTestBase.cs b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/CohostEndpointTestBase.cs index 7cf127581ee..b1bc2251176 100644 --- a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/CohostEndpointTestBase.cs +++ b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/CohostEndpointTestBase.cs @@ -28,13 +28,13 @@ public abstract class CohostEndpointTestBase(ITestOutputHelper testOutputHelper) private VSCodeRemoteServiceInvoker? _remoteServiceInvoker; private IFilePathService? _filePathService; private ISemanticTokensLegendService? _semanticTokensLegendService; - private Workspace? _workspace; + private Workspace? _localWorkspace; private protected override IRemoteServiceInvoker RemoteServiceInvoker => _remoteServiceInvoker.AssumeNotNull(); private protected override IClientSettingsManager ClientSettingsManager => _clientSettingsManager.AssumeNotNull(); private protected override IFilePathService FilePathService => _filePathService.AssumeNotNull(); private protected ISemanticTokensLegendService SemanticTokensLegendService => _semanticTokensLegendService.AssumeNotNull(); - private protected Workspace Workspace => _workspace.AssumeNotNull(); + private protected override Workspace LocalWorkspace => _localWorkspace.AssumeNotNull(); protected override async Task InitializeAsync() { @@ -42,10 +42,10 @@ protected override async Task InitializeAsync() InProcServiceFactory.TestAccessor.SetExportProvider(OOPExportProvider); - _workspace = CreateWorkspace(); + _localWorkspace = CreateWorkspace(); var workspaceProvider = new VSCodeWorkspaceProvider(); - workspaceProvider.SetWorkspace(Workspace); + workspaceProvider.SetWorkspace(LocalWorkspace); _remoteServiceInvoker = new VSCodeRemoteServiceInvoker(workspaceProvider, LoggerFactory); AddDisposable(_remoteServiceInvoker); @@ -94,7 +94,7 @@ protected override TextDocument CreateProjectAndRazorDocument( bool inGlobalNamespace = false, bool miscellaneousFile = false) { - return CreateProjectAndRazorDocument(Workspace, contents, fileKind, documentFilePath, additionalFiles, inGlobalNamespace, miscellaneousFile); + return CreateProjectAndRazorDocument(LocalWorkspace, contents, fileKind, documentFilePath, additionalFiles, inGlobalNamespace, miscellaneousFile); } private AdhocWorkspace CreateWorkspace() From 28de95e883e61efd1bf967c2a0b2694d44146948 Mon Sep 17 00:00:00 2001 From: David Wengier Date: Wed, 22 Oct 2025 14:59:16 +1100 Subject: [PATCH 09/15] Optionally generate MSBuildProjectDirectory metadata --- .../RazorProjectBuilder.cs | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Cohosting/RazorProjectBuilder.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Cohosting/RazorProjectBuilder.cs index 623519b5246..8bd7ebb30ad 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Cohosting/RazorProjectBuilder.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Cohosting/RazorProjectBuilder.cs @@ -37,6 +37,8 @@ public string? ProjectFilePath public bool ReferenceRazorSourceGenerator { get; set; } = true; public bool GenerateGlobalConfigFile { get; set; } = true; + public bool GenerateMSBuildProjectDirectory { get; set; } = true; + public bool GenerateAdditionalDocumentMetadata { get; set; } = true; private readonly List _references = []; private readonly List<(DocumentId id, string name, SourceText text, string filePath)> _documents = []; @@ -117,18 +119,29 @@ public Solution Build(Solution solution) build_property.SuppressRazorSourceGenerator = true """); + if (GenerateMSBuildProjectDirectory) + { + globalConfigContent.AppendLine($""" + build_property.MSBuildProjectDirectory = {Path.GetDirectoryName(ProjectFilePath).AssumeNotNull()} + """); + } + var projectBasePath = Path.GetDirectoryName(ProjectFilePath).AssumeNotNull(); - foreach (var additionalDocument in _additionalDocuments) + + if (GenerateAdditionalDocumentMetadata) { - if (additionalDocument.filePath is not null && - additionalDocument.filePath.StartsWith(projectBasePath)) + foreach (var additionalDocument in _additionalDocuments) { - var relativePath = additionalDocument.filePath[(projectBasePath.Length + 1)..]; - globalConfigContent.AppendLine($""" + if (additionalDocument.filePath is not null && + additionalDocument.filePath.StartsWith(projectBasePath)) + { + var relativePath = additionalDocument.filePath[(projectBasePath.Length + 1)..]; + globalConfigContent.AppendLine($""" [{additionalDocument.filePath.AssumeNotNull().Replace('\\', '/')}] build_metadata.AdditionalFiles.TargetPath = {Convert.ToBase64String(Encoding.UTF8.GetBytes(relativePath))} """); + } } } From 91c1325d20b8755e632c04cec3bd0493266f1814 Mon Sep 17 00:00:00 2001 From: David Wengier Date: Wed, 22 Oct 2025 14:59:24 +1100 Subject: [PATCH 10/15] fix tests --- .../Shared/ComputedTargetPathTest.cs | 197 ++++++------------ 1 file changed, 62 insertions(+), 135 deletions(-) diff --git a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/ComputedTargetPathTest.cs b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/ComputedTargetPathTest.cs index 44516995208..050c6cf085f 100644 --- a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/ComputedTargetPathTest.cs +++ b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/ComputedTargetPathTest.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Razor; using Microsoft.AspNetCore.Razor.Test.Common; +using Microsoft.AspNetCore.Razor.Utilities; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; @@ -18,171 +19,97 @@ namespace Microsoft.VisualStudioCode.RazorExtension.Test.Endpoints.Shared; public class ComputedTargetPathTest(ITestOutputHelper testOutputHelper) : CohostEndpointTestBase(testOutputHelper) { - [Fact] - public async Task GetHintName() + // What the source generator would produce for TestProjectData.SomeProjectPath + private static readonly string s_hintNamePrefix = PlatformInformation.IsWindows + ? "c__users_example_src_SomeProject" + : "home_example_SomeProject"; + + [Theory] + [InlineData(true, false)] + [InlineData(true, true)] + [InlineData(false, false)] + public async Task SingleDocument(bool projectPath, bool generateConfigFile) { - // Creating a misc files project will mean that there is no globalconfig created, so no target paths will be set - var document = CreateProjectAndRazorDocument(""); + var builder = new RazorProjectBuilder + { + ProjectFilePath = projectPath ? TestProjectData.SomeProject.FilePath : null, + GenerateGlobalConfigFile = generateConfigFile, + GenerateAdditionalDocumentMetadata = false, + GenerateMSBuildProjectDirectory = false + }; - _ = await document.Project.GetCompilationAsync(DisposalToken); - - var generatedDocument = await document.Project.TryGetSourceGeneratedDocumentForRazorDocumentAsync(document, DisposalToken); - Assert.NotNull(generatedDocument); - } - - [Fact] - public async Task NoGlobalConfig_WithProjectFilePath() - { - var doc1Path = FilePath(@"Pages\Index.razor"); + var id = builder.AddAdditionalDocument(FilePath("File1.razor"), SourceText.From("")); - var document = CreateProjectAndRazorDocument(""); + var solution = LocalWorkspace.CurrentSolution; + solution = builder.Build(solution); - var doc1 = document.Project.AddAdditionalDocument( - doc1Path, - SourceText.From(""" -
This is a page
- """), - filePath: doc1Path); + var document = solution.GetAdditionalDocument(id).AssumeNotNull(); - var project = doc1.Project.RemoveAnalyzerConfigDocument(doc1.Project.AnalyzerConfigDocuments.First().Id); - - _ = await project.GetCompilationAsync(DisposalToken); - - doc1 = project.GetAdditionalDocument(doc1.Id).AssumeNotNull(); + _ = await document.Project.GetCompilationAsync(DisposalToken); - var generatedDocument = await doc1.Project.TryGetSourceGeneratedDocumentForRazorDocumentAsync(document, DisposalToken); + var generatedDocument = await document.Project.TryGetSourceGeneratedDocumentForRazorDocumentAsync(document, DisposalToken); Assert.NotNull(generatedDocument); + Assert.Equal($"{s_hintNamePrefix}_File1_razor.g.cs", generatedDocument.HintName); } - [Fact] - public async Task NoGlobalConfig_NoProjectFilePath() + [Theory] + [CombinatorialData] + public async Task TwoDocumentsWithTheSameBaseFileName(bool generateTargetPath) { - var doc1Path = FilePath(@"Pages\Index.razor"); + // This test just proves the "correct" behaviour, with the Razor SDL + var builder = new RazorProjectBuilder + { + ProjectFilePath = TestProjectData.SomeProject.FilePath, + GenerateAdditionalDocumentMetadata = generateTargetPath + }; - // Creating a misc files project will mean that there is no globalconfig created, so no target paths will be set - var document = CreateProjectAndRazorDocument("", miscellaneousFile: true); + var doc1Id = builder.AddAdditionalDocument(FilePath(@"Pages\Index.razor"), SourceText.From("")); + var doc2Id = builder.AddAdditionalDocument(FilePath(@"Components\Index.razor"), SourceText.From("")); - var doc1 = document.Project.AddAdditionalDocument( - doc1Path, - SourceText.From(""" -
This is a page
- """), - filePath: doc1Path); + var solution = LocalWorkspace.CurrentSolution; + solution = builder.Build(solution); - _ = await doc1.Project.GetCompilationAsync(DisposalToken); - - var generatedDocument = await doc1.Project.TryGetSourceGeneratedDocumentForRazorDocumentAsync(document, DisposalToken); - Assert.NotNull(generatedDocument); - } - - [Fact] - public async Task NoGlobalConfig_MultipleFilesWithTheSameName() - { - var doc1Path = FilePath(@"Pages\Index.razor"); - var doc2Path = FilePath(@"Components\Index.razor"); - - // Creating a misc files project will mean that there is no globalconfig created, so no target paths will be set - var document = CreateProjectAndRazorDocument("", miscellaneousFile: true); - - var doc1 = document.Project.AddAdditionalDocument( - doc1Path, - SourceText.From(""" -
This is a page
- """), - filePath: doc1Path); - var doc2 = doc1.Project.AddAdditionalDocument( - doc2Path, - SourceText.From(""" -
This is a component
- """), - filePath: doc2Path); - - // Make sure we have a doc1 from the final project - doc1 = doc2.Project.GetAdditionalDocument(doc1.Id).AssumeNotNull(); + var doc1 = solution.GetAdditionalDocument(doc1Id).AssumeNotNull(); + var doc2 = solution.GetAdditionalDocument(doc2Id).AssumeNotNull(); var generatedDocument = await doc1.Project.TryGetSourceGeneratedDocumentForRazorDocumentAsync(doc1, DisposalToken); Assert.NotNull(generatedDocument); + Assert.Equal($"Pages_Index_razor.g.cs", generatedDocument.HintName); generatedDocument = await doc2.Project.TryGetSourceGeneratedDocumentForRazorDocumentAsync(doc2, DisposalToken); Assert.NotNull(generatedDocument); + Assert.Equal($"Components_Index_razor.g.cs", generatedDocument.HintName); } - [Fact] - public async Task NotAllFilesHaveTargetPaths() + [Theory] + [InlineData(true, false)] + [InlineData(true, true)] + [InlineData(false, false)] + public async Task TwoDocumentsWithTheSameBaseFileName_FullPathHintName(bool projectPath, bool generateConfigFile) { - var doc1Path = FilePath(@"Pages\Index.razor"); + var builder = new RazorProjectBuilder + { + ProjectFilePath = projectPath ? TestProjectData.SomeProject.FilePath : null, + GenerateGlobalConfigFile = generateConfigFile, + GenerateAdditionalDocumentMetadata = false, + GenerateMSBuildProjectDirectory = false + }; - // This will create a project with a globalconfig, and target paths for a single razor file - var document = CreateProjectAndRazorDocument(""" -
This is a normal file with a target path - """); + var doc1Id = builder.AddAdditionalDocument(FilePath(@"Pages\Index.razor"), SourceText.From("")); + var doc2Id = builder.AddAdditionalDocument(FilePath(@"Components\Index.razor"), SourceText.From("")); - // Now add a file without updating the globalconfig - var doc1 = document.Project.AddAdditionalDocument( - doc1Path, - SourceText.From(""" -
This is an extra document
- """), - filePath: doc1Path); - - var generatedDocument = await doc1.Project.TryGetSourceGeneratedDocumentForRazorDocumentAsync(doc1, DisposalToken); - Assert.NotNull(generatedDocument); - } + var solution = LocalWorkspace.CurrentSolution; + solution = builder.Build(solution); - [Fact] - public async Task WithSuppliedMSBuildProjectPath_MultipleFilesWithTheSameName() - { - var doc1Path = FilePath(@"Pages\Index.razor"); - var doc2Path = FilePath(@"Components\Index.razor"); - - // Creating a misc files project will mean that there is no globalconfig created, so no target paths will be set - var document = CreateProjectAndRazorDocument("", miscellaneousFile: true); - - var globalConfigContent = new StringBuilder(); - globalConfigContent.AppendLine($""" - is_global = true - - build_property.MSBuildProjectDirectory = {TestProjectData.SomeProjectPath} - """); - - var globalConfigDoc = document.Project.AddAnalyzerConfigDocument( - name: ".globalconfig", - text: SourceText.From(globalConfigContent.ToString()), - filePath: FilePath(".globalconfig")); - - var doc1 = globalConfigDoc.Project.AddAdditionalDocument( - doc1Path, - SourceText.From(""" -
This is a page
- """), - filePath: doc1Path); - var doc2 = doc1.Project.AddAdditionalDocument( - doc2Path, - SourceText.From(""" -
This is a component
- """), - filePath: doc2Path); - - // Make sure we have a doc1 from the final project - doc1 = doc2.Project.GetAdditionalDocument(doc1.Id).AssumeNotNull(); + var doc1 = solution.GetAdditionalDocument(doc1Id).AssumeNotNull(); + var doc2 = solution.GetAdditionalDocument(doc2Id).AssumeNotNull(); var generatedDocument = await doc1.Project.TryGetSourceGeneratedDocumentForRazorDocumentAsync(doc1, DisposalToken); Assert.NotNull(generatedDocument); - var className = await GetClassNameAsync(generatedDocument, DisposalToken); - Assert.Equal("Pages_Index", className); + Assert.Equal($"{s_hintNamePrefix}_Pages_Index_razor.g.cs", generatedDocument.HintName); generatedDocument = await doc2.Project.TryGetSourceGeneratedDocumentForRazorDocumentAsync(doc2, DisposalToken); Assert.NotNull(generatedDocument); - className = await GetClassNameAsync(generatedDocument, DisposalToken); - Assert.Equal("Components_Index", className); - } - - private async Task GetClassNameAsync(SourceGeneratedDocument generatedDocument, CancellationToken cancellationToken) - { - var root = await generatedDocument.GetSyntaxRootAsync(cancellationToken); - Assert.NotNull(root); - var classDeclaration = root.DescendantNodes().OfType().Single(); - - return classDeclaration.Identifier.ValueText; + Assert.Equal($"{s_hintNamePrefix}_Components_Index_razor.g.cs", generatedDocument.HintName); } } From e70818a4d58576877633cf05fd3cff0f724933e4 Mon Sep 17 00:00:00 2001 From: David Wengier Date: Wed, 22 Oct 2025 16:12:41 +1100 Subject: [PATCH 11/15] Fix test that was being dodgy --- .../Endpoints/Shared/CohostGoToDefinitionEndpointTest.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CohostGoToDefinitionEndpointTest.cs b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CohostGoToDefinitionEndpointTest.cs index 47d20ed1028..cf58f19a46e 100644 --- a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CohostGoToDefinitionEndpointTest.cs +++ b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CohostGoToDefinitionEndpointTest.cs @@ -411,7 +411,7 @@ public async Task Html() }, }); - await VerifyGoToDefinitionAsync(input, htmlResponse: htmlResponse); + await VerifyGoToDefinitionAsync(input, htmlResponse: htmlResponse, razorDocument: document); } [Fact] @@ -824,9 +824,10 @@ public override void Process(TagHelperContext context, TagHelperOutput output) private async Task VerifyGoToDefinitionAsync( TestCode input, RazorFileKind? fileKind = null, - SumType? htmlResponse = null) + SumType? htmlResponse = null, + TextDocument? razorDocument = null) { - var document = CreateProjectAndRazorDocument(input.Text, fileKind); + var document = razorDocument ?? CreateProjectAndRazorDocument(input.Text, fileKind); var result = await GetGoToDefinitionResultCoreAsync(document, input, htmlResponse); Assumes.NotNull(result); From a335ba7714ffb0a1e5265b986386a0d74e4aa57a Mon Sep 17 00:00:00 2001 From: David Wengier Date: Wed, 22 Oct 2025 16:42:46 +1100 Subject: [PATCH 12/15] Share more test infra code --- .../CohostTestBase.cs | 34 ++++++++++++++++++- .../Cohost/CohostEndpointTestBase.cs | 33 +----------------- .../CohostInlineCompletionEndpointTest.cs | 2 +- .../Cohost/CohostRoslynRenameTest.cs | 4 +-- .../CohostEndpointTestBase.cs | 28 ++------------- 5 files changed, 40 insertions(+), 61 deletions(-) diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Cohosting/CohostTestBase.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Cohosting/CohostTestBase.cs index 8fc91840526..6b9c368f408 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Cohosting/CohostTestBase.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Cohosting/CohostTestBase.cs @@ -11,6 +11,8 @@ using Microsoft.AspNetCore.Razor; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Test.Common; +using Microsoft.AspNetCore.Razor.Test.Common.Mef; +using Microsoft.AspNetCore.Razor.Test.Common.Workspaces; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.CodeAnalysis.Razor.Remote; @@ -32,15 +34,23 @@ public abstract class CohostTestBase(ITestOutputHelper testOutputHelper) : Tooli private TestIncompatibleProjectService _incompatibleProjectService = null!; private RemoteClientInitializationOptions _clientInitializationOptions; private RemoteClientLSPInitializationOptions _clientLSPInitializationOptions; + private CodeAnalysis.Workspace? _localWorkspace; + private ExportProvider? _localExportProvider; private protected abstract IRemoteServiceInvoker RemoteServiceInvoker { get; } private protected abstract IClientSettingsManager ClientSettingsManager { get; } private protected abstract IFilePathService FilePathService { get; } - private protected abstract CodeAnalysis.Workspace LocalWorkspace { get; } + private protected abstract TestComposition LocalComposition { get; } private protected TestIncompatibleProjectService IncompatibleProjectService => _incompatibleProjectService.AssumeNotNull(); private protected RemoteLanguageServerFeatureOptions FeatureOptions => OOPExportProvider.GetExportedValue(); private protected RemoteClientCapabilitiesService ClientCapabilitiesService => (RemoteClientCapabilitiesService)OOPExportProvider.GetExportedValue(); + private protected CodeAnalysis.Workspace LocalWorkspace => _localWorkspace.AssumeNotNull(); + + /// + /// The export provider for client services (Roslyn) + /// + private protected ExportProvider LocalExportProvider => _localExportProvider.AssumeNotNull(); /// /// The export provider for Razor OOP services (not Roslyn) @@ -93,8 +103,30 @@ protected override async Task InitializeAsync() traceSource.Listeners.Add(new XunitTraceListener(TestOutputHelper)); await RemoteWorkspaceProvider.TestAccessor.InitializeRemoteExportProviderBuilderAsync(Path.GetTempPath(), traceSource, DisposalToken); _ = RemoteWorkspaceProvider.Instance.GetWorkspace(); + + _localWorkspace = CreateLocalWorkspace(); } + private AdhocWorkspace CreateLocalWorkspace() + { + var composition = ConfigureLocalComposition(LocalComposition); + + // We can't enforce that the composition is entirely valid, because we don't have a full MEF catalog, but we + // can assume there should be no errors related to Razor, and having this array makes debugging failures a lot + // easier. + var errors = composition.GetCompositionErrors().ToArray(); + Assert.Empty(errors.Where(e => e.Contains("Razor"))); + + _localExportProvider = composition.ExportProviderFactory.CreateExportProvider(); + AddDisposable(_localExportProvider); + var workspace = TestWorkspace.CreateWithDiagnosticAnalyzers(_localExportProvider); + AddDisposable(workspace); + return workspace; + } + + private protected virtual TestComposition ConfigureLocalComposition(TestComposition composition) + => composition; + private protected abstract RemoteClientLSPInitializationOptions GetRemoteClientLSPInitializationOptions(); private protected void UpdateClientInitializationOptions(Func mutation) diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostEndpointTestBase.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostEndpointTestBase.cs index 0bb472b46fc..fc2c00f62f5 100644 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostEndpointTestBase.cs +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostEndpointTestBase.cs @@ -2,22 +2,18 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.LanguageServer.Semantic; using Microsoft.AspNetCore.Razor.Test.Common.Mef; -using Microsoft.AspNetCore.Razor.Test.Common.Workspaces; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Razor.Remote; using Microsoft.CodeAnalysis.Razor.SemanticTokens; using Microsoft.CodeAnalysis.Razor.Workspaces; using Microsoft.CodeAnalysis.Razor.Workspaces.Settings; using Microsoft.CodeAnalysis.Remote.Razor; -using Microsoft.VisualStudio.Composition; using Microsoft.VisualStudio.Razor.Settings; -using Xunit; using Xunit.Abstractions; namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost; @@ -28,19 +24,14 @@ public abstract class CohostEndpointTestBase(ITestOutputHelper testOutputHelper) private IClientSettingsManager? _clientSettingsManager; private IFilePathService? _filePathService; private ISemanticTokensLegendService? _semanticTokensLegendService; - private CodeAnalysis.Workspace? _localWorkspace; private protected override IRemoteServiceInvoker RemoteServiceInvoker => _remoteServiceInvoker.AssumeNotNull(); private protected TestRemoteServiceInvoker TestRemoteServiceInvoker => _remoteServiceInvoker.AssumeNotNull(); private protected override IClientSettingsManager ClientSettingsManager => _clientSettingsManager.AssumeNotNull(); private protected override IFilePathService FilePathService => _filePathService.AssumeNotNull(); private protected ISemanticTokensLegendService SemanticTokensLegendService => _semanticTokensLegendService.AssumeNotNull(); - private protected override CodeAnalysis.Workspace LocalWorkspace => _localWorkspace.AssumeNotNull(); - /// - /// The export provider for Roslyn "devenv" services, if tests opt-in to using them - /// - private protected ExportProvider? RoslynDevenvExportProvider { get; private set; } + private protected override TestComposition LocalComposition => TestComposition.Roslyn; protected override async Task InitializeAsync() { @@ -54,25 +45,6 @@ protected override async Task InitializeAsync() _filePathService = new VisualStudioFilePathService(FeatureOptions); _semanticTokensLegendService = TestRazorSemanticTokensLegendService.GetInstance(supportsVSExtensions: true); - - _localWorkspace = CreateWorkspace(); - } - - private CodeAnalysis.Workspace? CreateWorkspace() - { - var composition = ConfigureRoslynDevenvComposition(TestComposition.Roslyn); - - // We can't enforce that the composition is entirely valid, because we don't have a full MEF catalog, but we - // can assume there should be no errors related to Razor, and having this array makes debugging failures a lot - // easier. - var errors = composition.GetCompositionErrors().ToArray(); - Assert.Empty(errors.Where(e => e.Contains("Razor"))); - - RoslynDevenvExportProvider = composition.ExportProviderFactory.CreateExportProvider(); - AddDisposable(RoslynDevenvExportProvider); - var workspace = TestWorkspace.CreateWithDiagnosticAnalyzers(RoslynDevenvExportProvider); - AddDisposable(workspace); - return workspace; } private protected override RemoteClientLSPInitializationOptions GetRemoteClientLSPInitializationOptions() @@ -106,9 +78,6 @@ private protected override RemoteClientLSPInitializationOptions GetRemoteClientL }; } - private protected virtual TestComposition ConfigureRoslynDevenvComposition(TestComposition composition) - => composition; - protected TextDocument CreateProjectAndRazorDocument( string contents, bool remoteOnly) diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostInlineCompletionEndpointTest.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostInlineCompletionEndpointTest.cs index 1af7cf964f5..8994ea6ef06 100644 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostInlineCompletionEndpointTest.cs +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostInlineCompletionEndpointTest.cs @@ -115,7 +115,7 @@ private async Task VerifyInlineCompletionAsync(TestCode input, string? output = AssertEx.EqualOrDiff(output, inputText.ToString()); } - private protected override TestComposition ConfigureRoslynDevenvComposition(TestComposition composition) + private protected override TestComposition ConfigureLocalComposition(TestComposition composition) { return composition.AddParts(typeof(TestSnippetInfoService)); } diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostRoslynRenameTest.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostRoslynRenameTest.cs index a5a060b7bc0..ec588261018 100644 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostRoslynRenameTest.cs +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostRoslynRenameTest.cs @@ -148,7 +148,7 @@ The end. """, useLsp); - private protected override TestComposition ConfigureRoslynDevenvComposition(TestComposition composition) + private protected override TestComposition ConfigureLocalComposition(TestComposition composition) { return composition .AddParts(typeof(RazorSourceGeneratedDocumentSpanMappingService)) @@ -251,7 +251,7 @@ private async Task VerifyLspRenameAsync(string newName, string expectedCSharpFil { // Normally in cohosting tests we directly construct and invoke the endpoints, but in this scenario Roslyn is going to do it // using a service in their MEF composition, so we have to jump through an extra hook to hook up our test invoker. - var invoker = RoslynDevenvExportProvider.AssumeNotNull().GetExportedValue(); + var invoker = LocalExportProvider.AssumeNotNull().GetExportedValue(); invoker.SetInvoker(RemoteServiceInvoker); var tree = node.SyntaxTree; diff --git a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/CohostEndpointTestBase.cs b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/CohostEndpointTestBase.cs index b1bc2251176..9a33c2a7b2d 100644 --- a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/CohostEndpointTestBase.cs +++ b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/CohostEndpointTestBase.cs @@ -2,13 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.LanguageServer.Test; using Microsoft.AspNetCore.Razor.Test.Common.Mef; -using Microsoft.AspNetCore.Razor.Test.Common.Workspaces; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Razor.Remote; using Microsoft.CodeAnalysis.Razor.SemanticTokens; @@ -17,7 +15,6 @@ using Microsoft.CodeAnalysis.Remote.Razor; using Microsoft.VisualStudioCode.RazorExtension.Configuration; using Microsoft.VisualStudioCode.RazorExtension.Services; -using Xunit; using Xunit.Abstractions; namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost; @@ -28,13 +25,13 @@ public abstract class CohostEndpointTestBase(ITestOutputHelper testOutputHelper) private VSCodeRemoteServiceInvoker? _remoteServiceInvoker; private IFilePathService? _filePathService; private ISemanticTokensLegendService? _semanticTokensLegendService; - private Workspace? _localWorkspace; private protected override IRemoteServiceInvoker RemoteServiceInvoker => _remoteServiceInvoker.AssumeNotNull(); private protected override IClientSettingsManager ClientSettingsManager => _clientSettingsManager.AssumeNotNull(); private protected override IFilePathService FilePathService => _filePathService.AssumeNotNull(); private protected ISemanticTokensLegendService SemanticTokensLegendService => _semanticTokensLegendService.AssumeNotNull(); - private protected override Workspace LocalWorkspace => _localWorkspace.AssumeNotNull(); + + private protected override TestComposition LocalComposition => TestComposition.RoslynFeatures; protected override async Task InitializeAsync() { @@ -42,8 +39,6 @@ protected override async Task InitializeAsync() InProcServiceFactory.TestAccessor.SetExportProvider(OOPExportProvider); - _localWorkspace = CreateWorkspace(); - var workspaceProvider = new VSCodeWorkspaceProvider(); workspaceProvider.SetWorkspace(LocalWorkspace); @@ -70,7 +65,7 @@ private protected override RemoteClientLSPInitializationOptions GetRemoteClientL CompletionItem = new CompletionItemSetting(), CompletionItemKind = new CompletionItemKindSetting() { - ValueSet = (CompletionItemKind[])Enum.GetValues(typeof(CompletionItemKind)), + ValueSet = Enum.GetValues(), }, CompletionListSetting = new CompletionListSetting() { @@ -96,21 +91,4 @@ protected override TextDocument CreateProjectAndRazorDocument( { return CreateProjectAndRazorDocument(LocalWorkspace, contents, fileKind, documentFilePath, additionalFiles, inGlobalNamespace, miscellaneousFile); } - - private AdhocWorkspace CreateWorkspace() - { - var composition = TestComposition.RoslynFeatures; - - // We can't enforce that the composition is entirely valid, because we don't have a full MEF catalog, but we - // can assume there should be no errors related to Razor, and having this array makes debugging failures a lot - // easier. - var errors = composition.GetCompositionErrors().ToArray(); - Assert.Empty(errors.Where(e => e.Contains("Razor"))); - - var roslynExportProvider = composition.ExportProviderFactory.CreateExportProvider(); - AddDisposable(roslynExportProvider); - var workspace = TestWorkspace.CreateWithDiagnosticAnalyzers(roslynExportProvider); - AddDisposable(workspace); - return workspace; - } } From 2fcc2f682ed08cfee52fa4a172a655fcac3d74c2 Mon Sep 17 00:00:00 2001 From: David Wengier Date: Wed, 22 Oct 2025 17:40:38 +1100 Subject: [PATCH 13/15] Fix prefix on *nix --- .../Endpoints/Shared/ComputedTargetPathTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/ComputedTargetPathTest.cs b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/ComputedTargetPathTest.cs index 050c6cf085f..91706ef0ba6 100644 --- a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/ComputedTargetPathTest.cs +++ b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/ComputedTargetPathTest.cs @@ -22,7 +22,7 @@ public class ComputedTargetPathTest(ITestOutputHelper testOutputHelper) : Cohost // What the source generator would produce for TestProjectData.SomeProjectPath private static readonly string s_hintNamePrefix = PlatformInformation.IsWindows ? "c__users_example_src_SomeProject" - : "home_example_SomeProject"; + : "_home_example_SomeProject"; [Theory] [InlineData(true, false)] From c9b6db8bfaca34294603b49aca29dc455ae96b59 Mon Sep 17 00:00:00 2001 From: David Wengier Date: Fri, 24 Oct 2025 14:30:59 +1100 Subject: [PATCH 14/15] Fall back to computing the target path if target path was supplied, but empty --- .../RazorSourceGenerator.RazorProviders.cs | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.RazorProviders.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.RazorProviders.cs index 9c4e6c353d4..eb089d0a7f4 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.RazorProviders.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.RazorProviders.cs @@ -77,24 +77,14 @@ private static (SourceGeneratorProjectItem?, Diagnostic?) ComputeProjectItems((A var (additionalText, globalOptions) = pair; var options = globalOptions.GetOptions(additionalText); - globalOptions.GlobalOptions.TryGetValue("build_property.MSBuildProjectDirectory", out var projectPath); - - string relativePath; - if (options.TryGetValue("build_metadata.AdditionalFiles.TargetPath", out var encodedRelativePath)) + string? relativePath = null; + var hasTargetPath = options.TryGetValue("build_metadata.AdditionalFiles.TargetPath", out var encodedRelativePath); + if (hasTargetPath && !string.IsNullOrWhiteSpace(encodedRelativePath)) { - // TargetPath is optional, but must have a value if provided. - if (string.IsNullOrWhiteSpace(encodedRelativePath)) - { - var diagnostic = Diagnostic.Create( - RazorDiagnostics.TargetPathNotProvided, - Location.None, - additionalText.Path); - return (null, diagnostic); - } - relativePath = Encoding.UTF8.GetString(Convert.FromBase64String(encodedRelativePath)); } - else if (projectPath is { Length: > 0 } && + else if (globalOptions.GlobalOptions.TryGetValue("build_property.MSBuildProjectDirectory", out var projectPath) && + projectPath is { Length: > 0 } && additionalText.Path.StartsWith(projectPath, StringComparison.OrdinalIgnoreCase)) { // Fallback, when TargetPath isn't specified but we know about the project directory, we can do our own calulation of @@ -102,13 +92,24 @@ private static (SourceGeneratorProjectItem?, Diagnostic?) ComputeProjectItems((A // Razor SDK to still get TargetPath functionality without the complexity of specifying metadata on every item. relativePath = additionalText.Path[projectPath.Length..].TrimStart(['/', '\\']); } - else + else if (!hasTargetPath) { // If the TargetPath is not provided, it could be a Misc Files situation, or just a project that isn't using the // Web or Razor SDK. In this case, we just use the physical path. relativePath = additionalText.Path; } + if (relativePath is null) + { + // If we had a TargetPath but it was empty or whitespace, and we couldn't fall back to computing it from the project path + // that's an error. + var diagnostic = Diagnostic.Create( + RazorDiagnostics.TargetPathNotProvided, + Location.None, + additionalText.Path); + return (null, diagnostic); + } + options.TryGetValue("build_metadata.AdditionalFiles.CssScope", out var cssScope); var projectItem = new SourceGeneratorProjectItem( From da6628901dddfacbcd09e5d651da789680a653bd Mon Sep 17 00:00:00 2001 From: David Wengier Date: Fri, 24 Oct 2025 14:31:06 +1100 Subject: [PATCH 15/15] Cleanup --- .../Endpoints/Shared/ComputedTargetPathTest.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/ComputedTargetPathTest.cs b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/ComputedTargetPathTest.cs index 91706ef0ba6..ac4c0b17878 100644 --- a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/ComputedTargetPathTest.cs +++ b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/ComputedTargetPathTest.cs @@ -1,15 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Linq; -using System.Text; -using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor; using Microsoft.AspNetCore.Razor.Test.Common; using Microsoft.AspNetCore.Razor.Utilities; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.Razor.LanguageClient.Cohost; using Xunit;