Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eng/targets/Services.props
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
<ServiceHubService Include="Microsoft.VisualStudio.Razor.Hover" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteHoverService+Factory" />
<ServiceHubService Include="Microsoft.VisualStudio.Razor.Completion" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteCompletionService+Factory" />
<ServiceHubService Include="Microsoft.VisualStudio.Razor.CodeActions" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteCodeActionsService+Factory" />
<ServiceHubService Include="Microsoft.VisualStudio.Razor.AddNestedFile" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteAddNestedFileService+Factory" />
<ServiceHubService Include="Microsoft.VisualStudio.Razor.FindAllReferences" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteFindAllReferencesService+Factory" />
<ServiceHubService Include="Microsoft.VisualStudio.Razor.InlineCompletion" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteInlineCompletionService+Factory" />
<ServiceHubService Include="Microsoft.VisualStudio.Razor.DebugInfo" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteDebugInfoService+Factory" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,6 @@ internal static class RazorLSPConstants
public const string RoslynSemanticTokenRangesEndpointName = "roslyn/semanticTokenRanges";

public const string ApplyRenameEditName = "razor/applyRenameEdit";

public const string AddNestedFileName = "razor/addNestedFile";
}
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(

Copy link
Copy Markdown
Member

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. GetNewNestedFileEditAsync perhaps?

Copy link
Copy Markdown
Author

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

JsonSerializableRazorPinnedSolutionInfoWrapper solutionInfo,
Uri razorFileUri,
string fileKind,
CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ internal static class RazorServices
(typeof(IRemoteDiagnosticsService), null),
(typeof(IRemoteCompletionService), null),
(typeof(IRemoteCodeActionsService), null),
(typeof(IRemoteAddNestedFileService), null),
(typeof(IRemoteFindAllReferencesService), null),
(typeof(IRemoteMEFInitializationService), null),
(typeof(IRemoteCodeLensService), null),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@
<_ExcludedFiles Include="$(PublishDir)**\Microsoft.CodeAnalysis.Remote.Razor.CoreComponents.*" />

<_PublishedFiles Include="$(PublishDir)**\Microsoft.CodeAnalysis.Razor.*" />
<_PublishedFiles Include="$(PublishDir)**\Microsoft.CodeAnalysis.Remote.Razor.*" Exclude="@(_ExcludedFiles)"/>
<_PublishedFiles Include="$(PublishDir)**\Microsoft.CodeAnalysis.Remote.Razor.*" Exclude="@(_ExcludedFiles)" />
<_PublishedFiles Include="$(PublishDir)**\Microsoft.AspNetCore.*" />
<_PublishedFiles Include="$(PublishDir)**\Microsoft.Extensions.ObjectPool.dll" />

Expand Down
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;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 Uri razorFileUri can be a JsonSerializableDocumentId, then all of this is done for you, and will be generally more reliable.


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";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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
var fileType = FileKinds.GetFileKindFromPath(razorFilePath).IsComponent() ? "component" : "view";
var fileType = FileKinds.GetFileKindFromPath(razorFilePath).IsLegacy() ? "view" : "component";

The reason is that _Imports.razor returns false for IsComponent() so it would end up being described as a view. Having said that, we probably shouldn't bother showing these menu items for _Imports.razor or ViewImports.cshtml anyway, so if we do that restriction, this comment becomes moot. 🤷‍♂️

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();
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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>

@davidwengier davidwengier Apr 1, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be an AbstractCohostDocumentEndpoint, and AddNestedFileParams should implement ITextDocumentParams (ie, rename RazorFileUri to TextDocument), that way the LSP server will be able to better manage requests, solution snapshots etc. It will also mean context.TextDocument will be non-null, so you can grab the DocumentId to pass through to OOP etc.

If you need it, CohostWrapWithTagEndpoint is an example of a "custom" endpoint that does all of this.

{
private readonly IRemoteServiceInvoker _remoteServiceInvoker = remoteServiceInvoker;
private readonly ILogger _logger = loggerFactory.GetOrCreateLogger<CohostAddNestedFileEndpoint>();

protected override bool MutatesSolutionState => true;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 didOpen/didChange requests, and those are marked as mutating. Having this true here will just slow throughput for the LSP server.


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();
}
}
Loading