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/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.RazorProviders.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.RazorProviders.cs index 8b802d89a68..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,25 +77,37 @@ private static (SourceGeneratorProjectItem?, Diagnostic?) ComputeProjectItems((A var (additionalText, globalOptions) = pair; var options = globalOptions.GetOptions(additionalText); - 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 + 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 + // 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 (!hasTargetPath) { - // 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; + } + + 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); 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..967f55a17d7 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,76 @@ 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.IsRazorSourceGeneratedDocument()) + { + continue; + } + + 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 3d7f24baa6f..00000000000 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/TextDocumentExtensions.cs +++ /dev/null @@ -1,51 +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)) - { - var fileName = filePath[filePath.LastIndexOfAny(['/', '\\'])..].TrimStart(['/', '\\']); - hintName = RazorSourceGenerator.GetIdentifierFromPath(fileName); - 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.AspNetCore.Razor.Test.Common.Cohosting/CohostTestBase.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Cohosting/CohostTestBase.cs index 6ca282c248f..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 @@ -6,16 +6,14 @@ 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.AspNetCore.Razor.Test.Common.Mef; +using Microsoft.AspNetCore.Razor.Test.Common.Workspaces; 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 +21,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; @@ -37,14 +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 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) @@ -97,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) @@ -155,108 +183,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..8bd7ebb30ad --- /dev/null +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Cohosting/RazorProjectBuilder.cs @@ -0,0 +1,157 @@ +// 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; + 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 = []; + 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 + """); + + if (GenerateMSBuildProjectDirectory) + { + globalConfigContent.AppendLine($""" + build_property.MSBuildProjectDirectory = {Path.GetDirectoryName(ProjectFilePath).AssumeNotNull()} + """); + } + + var projectBasePath = Path.GetDirectoryName(ProjectFilePath).AssumeNotNull(); + + if (GenerateAdditionalDocumentMetadata) + { + 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..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; @@ -35,10 +31,7 @@ public abstract class CohostEndpointTestBase(ITestOutputHelper testOutputHelper) private protected override IFilePathService FilePathService => _filePathService.AssumeNotNull(); private protected ISemanticTokensLegendService SemanticTokensLegendService => _semanticTokensLegendService.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() { @@ -85,9 +78,6 @@ private protected override RemoteClientLSPInitializationOptions GetRemoteClientL }; } - private protected virtual TestComposition ConfigureRoslynDevenvComposition(TestComposition composition) - => composition; - protected TextDocument CreateProjectAndRazorDocument( string contents, bool remoteOnly) @@ -137,20 +127,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.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 5ae9b5ffa99..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)) @@ -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 :) @@ -253,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.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/CohostEndpointTestBase.cs b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/CohostEndpointTestBase.cs index 7cf127581ee..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? _workspace; 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 TestComposition LocalComposition => TestComposition.RoslynFeatures; protected override async Task InitializeAsync() { @@ -42,10 +39,8 @@ protected override async Task InitializeAsync() InProcServiceFactory.TestAccessor.SetExportProvider(OOPExportProvider); - _workspace = CreateWorkspace(); - var workspaceProvider = new VSCodeWorkspaceProvider(); - workspaceProvider.SetWorkspace(Workspace); + workspaceProvider.SetWorkspace(LocalWorkspace); _remoteServiceInvoker = new VSCodeRemoteServiceInvoker(workspaceProvider, LoggerFactory); AddDisposable(_remoteServiceInvoker); @@ -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() { @@ -94,23 +89,6 @@ protected override TextDocument CreateProjectAndRazorDocument( bool inGlobalNamespace = false, bool miscellaneousFile = false) { - return CreateProjectAndRazorDocument(Workspace, 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; + return CreateProjectAndRazorDocument(LocalWorkspace, contents, fileKind, documentFilePath, additionalFiles, inGlobalNamespace, miscellaneousFile); } } 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..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.File1.C:C|} + class {|ASP.{{generatedNamespace}}.File1.C:C|} { private void {|HandleString(string s):HandleString|}(string s) { @@ -69,6 +75,7 @@ class {|ASP.File1.C:C|} } """, hierarchical, miscellaneousFile: true); + } [Theory] [CombinatorialData] 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); 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..ac4c0b17878 --- /dev/null +++ b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/ComputedTargetPathTest.cs @@ -0,0 +1,111 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +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.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) +{ + // 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) + { + var builder = new RazorProjectBuilder + { + ProjectFilePath = projectPath ? TestProjectData.SomeProject.FilePath : null, + GenerateGlobalConfigFile = generateConfigFile, + GenerateAdditionalDocumentMetadata = false, + GenerateMSBuildProjectDirectory = false + }; + + var id = builder.AddAdditionalDocument(FilePath("File1.razor"), SourceText.From("")); + + var solution = LocalWorkspace.CurrentSolution; + solution = builder.Build(solution); + + var document = solution.GetAdditionalDocument(id).AssumeNotNull(); + + _ = await document.Project.GetCompilationAsync(DisposalToken); + + var generatedDocument = await document.Project.TryGetSourceGeneratedDocumentForRazorDocumentAsync(document, DisposalToken); + Assert.NotNull(generatedDocument); + Assert.Equal($"{s_hintNamePrefix}_File1_razor.g.cs", generatedDocument.HintName); + } + + [Theory] + [CombinatorialData] + public async Task TwoDocumentsWithTheSameBaseFileName(bool generateTargetPath) + { + // This test just proves the "correct" behaviour, with the Razor SDL + var builder = new RazorProjectBuilder + { + ProjectFilePath = TestProjectData.SomeProject.FilePath, + GenerateAdditionalDocumentMetadata = generateTargetPath + }; + + var doc1Id = builder.AddAdditionalDocument(FilePath(@"Pages\Index.razor"), SourceText.From("")); + var doc2Id = builder.AddAdditionalDocument(FilePath(@"Components\Index.razor"), SourceText.From("")); + + var solution = LocalWorkspace.CurrentSolution; + solution = builder.Build(solution); + + 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); + } + + [Theory] + [InlineData(true, false)] + [InlineData(true, true)] + [InlineData(false, false)] + public async Task TwoDocumentsWithTheSameBaseFileName_FullPathHintName(bool projectPath, bool generateConfigFile) + { + var builder = new RazorProjectBuilder + { + ProjectFilePath = projectPath ? TestProjectData.SomeProject.FilePath : null, + GenerateGlobalConfigFile = generateConfigFile, + GenerateAdditionalDocumentMetadata = false, + GenerateMSBuildProjectDirectory = false + }; + + var doc1Id = builder.AddAdditionalDocument(FilePath(@"Pages\Index.razor"), SourceText.From("")); + var doc2Id = builder.AddAdditionalDocument(FilePath(@"Components\Index.razor"), SourceText.From("")); + + var solution = LocalWorkspace.CurrentSolution; + solution = builder.Build(solution); + + 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($"{s_hintNamePrefix}_Pages_Index_razor.g.cs", generatedDocument.HintName); + + generatedDocument = await doc2.Project.TryGetSourceGeneratedDocumentForRazorDocumentAsync(doc2, DisposalToken); + Assert.NotNull(generatedDocument); + Assert.Equal($"{s_hintNamePrefix}_Components_Index_razor.g.cs", generatedDocument.HintName); + } +}