-
Notifications
You must be signed in to change notification settings - Fork 240
Initial support for razor isolation files (cs, css, js) #12981
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 8 commits
3f9eced
1baa75b
86a52b9
028fb60
8d8323f
065812d
6b60202
7d4a1c1
2abc423
df12c46
4a2283f
24e4422
db1eb22
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
|
|
||
| namespace Microsoft.CodeAnalysis.Razor.NestedFiles; | ||
|
|
||
| /// <summary> | ||
| /// Specifies which type of nested file to create for a Razor component. | ||
| /// </summary> | ||
| internal static class NestedFileKind | ||
| { | ||
| public const string Css = "css"; | ||
| public const string CSharp = "csharp"; | ||
| public const string JavaScript = "javascript"; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| // 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.Threading; | ||
| using System.Threading.Tasks; | ||
| using Microsoft.CodeAnalysis.ExternalAccess.Razor; | ||
|
|
||
| namespace Microsoft.CodeAnalysis.Razor.Remote; | ||
|
|
||
| internal interface IRemoteAddNestedFileService : IRemoteJsonService | ||
| { | ||
| /// <summary> | ||
| /// Creates a nested file (CSS, C# code-behind, or JavaScript) for a Razor file. | ||
| /// Returns a <see cref="WorkspaceEdit"/> containing CreateFile + TextDocumentEdit operations, | ||
| /// or null if the operation could not be completed. | ||
| /// </summary> | ||
| ValueTask<WorkspaceEdit?> AddNestedFileAsync( | ||
| JsonSerializableRazorPinnedSolutionInfoWrapper solutionInfo, | ||
| Uri razorFileUri, | ||
| string fileKind, | ||
| CancellationToken cancellationToken); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,208 @@ | ||||||
| // 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.IO; | ||||||
| using System.Threading; | ||||||
| using System.Threading.Tasks; | ||||||
| using Microsoft.AspNetCore.Razor.Language; | ||||||
| using Microsoft.AspNetCore.Razor.Language.Intermediate; | ||||||
| using Microsoft.AspNetCore.Razor.PooledObjects; | ||||||
| using Microsoft.CodeAnalysis.ExternalAccess.Razor; | ||||||
| using Microsoft.CodeAnalysis.Razor; | ||||||
| using Microsoft.CodeAnalysis.Razor.CodeActions; | ||||||
| using Microsoft.CodeAnalysis.Razor.Logging; | ||||||
| using Microsoft.CodeAnalysis.Razor.NestedFiles; | ||||||
| using Microsoft.CodeAnalysis.Razor.Remote; | ||||||
| using Microsoft.CodeAnalysis.Razor.Utilities; | ||||||
| using Microsoft.CodeAnalysis.Razor.Workspaces; | ||||||
| using Microsoft.CodeAnalysis.Remote.Razor.ProjectSystem; | ||||||
|
|
||||||
| namespace Microsoft.CodeAnalysis.Remote.Razor; | ||||||
|
|
||||||
| internal sealed class RemoteAddNestedFileService(in ServiceArgs args) | ||||||
| : RazorDocumentServiceBase(in args), IRemoteAddNestedFileService | ||||||
| { | ||||||
| internal sealed class Factory : FactoryBase<IRemoteAddNestedFileService> | ||||||
| { | ||||||
| protected override IRemoteAddNestedFileService CreateService(in ServiceArgs args) | ||||||
| => new RemoteAddNestedFileService(in args); | ||||||
| } | ||||||
|
|
||||||
| private readonly IRoslynCodeActionHelpers _roslynCodeActionHelpers = | ||||||
| args.ExportProvider.GetExportedValue<IRoslynCodeActionHelpers>(); | ||||||
|
|
||||||
| private readonly LanguageServerFeatureOptions _languageServerFeatureOptions = | ||||||
| args.ExportProvider.GetExportedValue<LanguageServerFeatureOptions>(); | ||||||
|
|
||||||
| public ValueTask<WorkspaceEdit?> AddNestedFileAsync( | ||||||
| JsonSerializableRazorPinnedSolutionInfoWrapper solutionInfo, | ||||||
| Uri razorFileUri, | ||||||
| string fileKind, | ||||||
| CancellationToken cancellationToken) | ||||||
| => RunServiceAsync( | ||||||
| solutionInfo, | ||||||
| solution => AddNestedFileAsync(solution, razorFileUri, fileKind, cancellationToken), | ||||||
| cancellationToken); | ||||||
|
|
||||||
| private async ValueTask<WorkspaceEdit?> AddNestedFileAsync( | ||||||
| Solution solution, | ||||||
| Uri razorFileUri, | ||||||
| string fileKind, | ||||||
| CancellationToken cancellationToken) | ||||||
| { | ||||||
| if (!solution.TryGetRazorDocument(razorFileUri, out var razorDocument)) | ||||||
| { | ||||||
| Logger.LogWarning($"Could not find Razor document for URI: {razorFileUri}"); | ||||||
| return null; | ||||||
| } | ||||||
|
|
||||||
| var documentContext = CreateRazorDocumentContext(solution, razorDocument.Id); | ||||||
| if (documentContext is null) | ||||||
| { | ||||||
| Logger.LogWarning($"Could not create document context for: {razorFileUri}"); | ||||||
| return null; | ||||||
| } | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I haven't looked at where this is called from yet, but if |
||||||
|
|
||||||
| var razorFilePath = FilePathNormalizer.Normalize(razorFileUri.GetAbsoluteOrUNCPath()); | ||||||
| var nestedFilePath = GetNestedFilePath(razorFilePath, fileKind); | ||||||
| if (nestedFilePath is null) | ||||||
| { | ||||||
| return null; | ||||||
| } | ||||||
|
|
||||||
| var nestedFileUri = LspFactory.CreateFilePathUri(nestedFilePath, _languageServerFeatureOptions); | ||||||
|
|
||||||
| var content = await GenerateContentAsync( | ||||||
| fileKind, documentContext, razorFilePath, nestedFileUri, cancellationToken).ConfigureAwait(false); | ||||||
|
|
||||||
| var nestedFileDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier | ||||||
| { | ||||||
| DocumentUri = new DocumentUri(nestedFileUri) | ||||||
| }; | ||||||
|
|
||||||
| var documentChanges = new SumType<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>[] | ||||||
| { | ||||||
| new CreateFile { DocumentUri = nestedFileDocumentIdentifier.DocumentUri }, | ||||||
| new TextDocumentEdit | ||||||
| { | ||||||
| TextDocument = nestedFileDocumentIdentifier, | ||||||
| Edits = [LspFactory.CreateTextEdit(position: (0, 0), content)] | ||||||
| } | ||||||
| }; | ||||||
|
|
||||||
| return new WorkspaceEdit | ||||||
| { | ||||||
| DocumentChanges = documentChanges, | ||||||
| }; | ||||||
| } | ||||||
|
|
||||||
| private static string? GetNestedFilePath(string razorFilePath, string fileKind) | ||||||
| { | ||||||
| return fileKind switch | ||||||
| { | ||||||
| NestedFileKind.Css => razorFilePath + ".css", | ||||||
| NestedFileKind.CSharp => razorFilePath + ".cs", | ||||||
| NestedFileKind.JavaScript => razorFilePath + ".js", | ||||||
| _ => null | ||||||
| }; | ||||||
| } | ||||||
|
|
||||||
| private async Task<string> GenerateContentAsync( | ||||||
| string fileKind, | ||||||
| RemoteDocumentContext documentContext, | ||||||
| string razorFilePath, | ||||||
| Uri nestedFileUri, | ||||||
| CancellationToken cancellationToken) | ||||||
| { | ||||||
| return fileKind switch | ||||||
| { | ||||||
| NestedFileKind.CSharp => await GenerateCSharpContentAsync( | ||||||
| documentContext, razorFilePath, nestedFileUri, cancellationToken).ConfigureAwait(false), | ||||||
| NestedFileKind.Css => GenerateCssContent(razorFilePath), | ||||||
| NestedFileKind.JavaScript => GenerateJavaScriptContent(razorFilePath), | ||||||
| _ => string.Empty | ||||||
| }; | ||||||
| } | ||||||
|
|
||||||
| private static string GenerateCssContent(string razorFilePath) | ||||||
| { | ||||||
| var componentName = Path.GetFileNameWithoutExtension(razorFilePath); | ||||||
| var fileType = FileKinds.GetFileKindFromPath(razorFilePath).IsComponent() ? "component" : "view"; | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should fix this at some point, but I always prefer this check to be inverted. ie:
Suggested change
The reason is that |
||||||
| return $"/* CSS for {componentName} {fileType} */\r\n"; | ||||||
| } | ||||||
|
|
||||||
| private static string GenerateJavaScriptContent(string razorFilePath) | ||||||
| { | ||||||
| var componentName = Path.GetFileNameWithoutExtension(razorFilePath); | ||||||
| var fileType = FileKinds.GetFileKindFromPath(razorFilePath).IsComponent() ? "component" : "view"; | ||||||
| return $"// JavaScript for {componentName} {fileType}\r\n"; | ||||||
| } | ||||||
|
|
||||||
| private async Task<string> GenerateCSharpContentAsync( | ||||||
| RemoteDocumentContext documentContext, | ||||||
| string razorFilePath, | ||||||
| Uri nestedFileUri, | ||||||
| CancellationToken cancellationToken) | ||||||
| { | ||||||
| var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false); | ||||||
| var className = Path.GetFileNameWithoutExtension(razorFilePath); | ||||||
|
|
||||||
| // Use the Razor compiler's namespace resolution which handles @namespace directives, | ||||||
| // _Imports.razor, and SDK-provided root namespace | ||||||
| if (!codeDocument.TryGetNamespace(fallbackToRootNamespace: true, out var @namespace)) | ||||||
| { | ||||||
| Logger.LogWarning($"Could not determine namespace for: {razorFilePath}"); | ||||||
| @namespace = "Unknown"; | ||||||
| } | ||||||
|
|
||||||
| var content = GenerateCodeBehindClass(className, @namespace, codeDocument); | ||||||
|
|
||||||
| // Format via Roslyn (handles file-scoped namespaces, indentation, etc.) | ||||||
| content = await _roslynCodeActionHelpers.GetFormattedNewFileContentsAsync( | ||||||
| documentContext.Snapshot.Project, | ||||||
| nestedFileUri, | ||||||
| content, | ||||||
| cancellationToken).ConfigureAwait(false); | ||||||
|
|
||||||
| return content; | ||||||
| } | ||||||
|
|
||||||
| private static string GenerateCodeBehindClass(string className, string namespaceName, RazorCodeDocument razorCodeDocument) | ||||||
| { | ||||||
| using var _ = StringBuilderPool.GetPooledObject(out var builder); | ||||||
|
|
||||||
| var usingDirectives = razorCodeDocument | ||||||
| .GetRequiredDocumentNode() | ||||||
| .FindDescendantNodes<UsingDirectiveIntermediateNode>(); | ||||||
|
|
||||||
| foreach (var usingDirective in usingDirectives) | ||||||
| { | ||||||
| builder.Append("using "); | ||||||
|
|
||||||
| var content = usingDirective.Content; | ||||||
| var startIndex = content.StartsWith("global::", StringComparison.Ordinal) | ||||||
| ? 8 | ||||||
| : 0; | ||||||
|
|
||||||
| builder.Append(content, startIndex, content.Length - startIndex); | ||||||
| builder.Append(';'); | ||||||
| builder.AppendLine(); | ||||||
| } | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The FormatNewFile function also removes unused usings, which for this file would be all of these, so there is no point doing all of this :) |
||||||
|
|
||||||
| builder.AppendLine(); | ||||||
| builder.Append("namespace "); | ||||||
| builder.AppendLine(namespaceName); | ||||||
| builder.Append('{'); | ||||||
| builder.AppendLine(); | ||||||
| builder.Append("public partial class "); | ||||||
| builder.AppendLine(className); | ||||||
| builder.Append('{'); | ||||||
| builder.AppendLine(); | ||||||
| builder.Append('}'); | ||||||
| builder.AppendLine(); | ||||||
| builder.Append('}'); | ||||||
|
|
||||||
| return builder.ToString(); | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| // 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.Text.Json.Serialization; | ||
| using Microsoft.CodeAnalysis.Razor.NestedFiles; | ||
|
|
||
| namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost; | ||
|
|
||
| /// <summary> | ||
| /// Parameters for the razor/addNestedFile endpoint. | ||
| /// </summary> | ||
| internal sealed class AddNestedFileParams | ||
| { | ||
| /// <summary> | ||
| /// The URI of the Razor file (.razor or .cshtml) to create a nested file for. | ||
| /// </summary> | ||
| [JsonPropertyName("razorFileUri")] | ||
| public required Uri RazorFileUri { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// The kind of nested file to create (one of <see cref="NestedFileKind"/> constants). | ||
| /// </summary> | ||
| [JsonPropertyName("fileKind")] | ||
| public required string FileKind { get; set; } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
|
|
||
| using System.Composition; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
| using Microsoft.AspNetCore.Razor; | ||
| using Microsoft.CodeAnalysis.ExternalAccess.Razor; | ||
| using Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost; | ||
| using Microsoft.CodeAnalysis.ExternalAccess.Razor.Features; | ||
| using Microsoft.CodeAnalysis.Razor.Logging; | ||
| using Microsoft.CodeAnalysis.Razor.Remote; | ||
|
|
||
| namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost; | ||
|
|
||
| #pragma warning disable RS0030 // Do not use banned APIs | ||
| [Shared] | ||
| [RazorMethod(RazorLSPConstants.AddNestedFileName)] | ||
| [ExportRazorStatelessLspService(typeof(CohostAddNestedFileEndpoint))] | ||
| [method: ImportingConstructor] | ||
| #pragma warning restore RS0030 // Do not use banned APIs | ||
| internal sealed class CohostAddNestedFileEndpoint( | ||
| IRemoteServiceInvoker remoteServiceInvoker, | ||
| ILoggerFactory loggerFactory) | ||
| : AbstractRazorCohostRequestHandler<AddNestedFileParams, VoidResult> | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should be an If you need it, |
||
| { | ||
| private readonly IRemoteServiceInvoker _remoteServiceInvoker = remoteServiceInvoker; | ||
| private readonly ILogger _logger = loggerFactory.GetOrCreateLogger<CohostAddNestedFileEndpoint>(); | ||
|
|
||
| protected override bool MutatesSolutionState => true; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should be false, it doesn't mutate anything, it asks the LSP client to do it. When the LSP client does, it will send |
||
|
|
||
| protected override bool RequiresLSPSolution => true; | ||
|
|
||
| protected override async Task<VoidResult> HandleRequestAsync( | ||
| AddNestedFileParams request, | ||
| RazorCohostRequestContext context, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| var solution = context.Solution; | ||
| if (solution is null) | ||
| { | ||
| _logger.LogWarning($"No solution available for addNestedFile request."); | ||
| return new(); | ||
| } | ||
|
|
||
| var workspaceEdit = await _remoteServiceInvoker.TryInvokeAsync<IRemoteAddNestedFileService, WorkspaceEdit?>( | ||
| solution, | ||
| (service, solutionInfo, ct) => service.AddNestedFileAsync( | ||
| solutionInfo, | ||
| request.RazorFileUri, | ||
| request.FileKind, | ||
| ct), | ||
| cancellationToken).ConfigureAwait(false); | ||
|
|
||
| if (workspaceEdit is null) | ||
| { | ||
| _logger.LogWarning($"Remote service returned no edit for addNestedFile."); | ||
| return new(); | ||
| } | ||
|
|
||
| var razorCohostClientLanguageServerManager = context.GetRequiredService<IRazorClientLanguageServerManager>(); | ||
| var response = await razorCohostClientLanguageServerManager.SendRequestAsync<ApplyWorkspaceEditParams, ApplyWorkspaceEditResponse>( | ||
| Methods.WorkspaceApplyEditName, | ||
| new ApplyWorkspaceEditParams { Edit = workspaceEdit }, | ||
| cancellationToken).ConfigureAwait(false); | ||
|
|
||
| if (!response.Applied) | ||
| { | ||
| _logger.LogWarning($"Failed to apply workspace edit for addNestedFile: {response.FailureReason}"); | ||
| } | ||
|
|
||
| return new(); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit, but a fairly large one: I think the method name here, and the doc comment, are misleading as this doesn't add/create a nested file.
GetNewNestedFileEditAsyncperhaps?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point, that is definitely unclear from the current name