Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -9,8 +9,8 @@ namespace Microsoft.AspNetCore.Razor.Language;

public static class FileKinds
{
private const string ComponentFileExtension = ".razor";
private const string LegacyFileExtension = ".cshtml";
public const string ComponentFileExtension = ".razor";
public const string LegacyFileExtension = ".cshtml";

/// <summary>
/// Returns <see langword="true"/> if the specified value represents a component or component import.
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 enum NestedFileKind
{
CSharp,
Css,
JavaScript,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// 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.CodeAnalysis.Razor.Protocol.NestedFiles;

/// <summary>
/// Parameters for the razor/addNestedFile endpoint.
/// </summary>
internal sealed class AddNestedFileParams : ITextDocumentParams
{
/// <summary>
/// The text document identifier for the Razor file (.razor or .cshtml) to create a nested file for.
/// </summary>
[JsonPropertyName("textDocument")]
public required TextDocumentIdentifier TextDocument { get; set; }

/// <summary>
/// The kind of nested file to create.
/// </summary>
[JsonPropertyName("fileKind")]
public required NestedFileKind FileKind { get; set; }

public static AddNestedFileParams Create(Uri razorFileUri, NestedFileKind fileKind)
{
return new AddNestedFileParams
{
TextDocument = new TextDocumentIdentifier { DocumentUri = new(razorFileUri) },
FileKind = fileKind
};
}
}
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.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.ExternalAccess.Razor;
using Microsoft.CodeAnalysis.Razor.NestedFiles;

namespace Microsoft.CodeAnalysis.Razor.Remote;

internal interface IRemoteAddNestedFileService : IRemoteJsonService
{
/// <summary>
/// Gets an edit to create 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?> GetNewNestedFileWorkspaceEditAsync(
JsonSerializableRazorPinnedSolutionInfoWrapper solutionInfo,
JsonSerializableDocumentId documentId,
NestedFileKind 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,167 @@
// 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.PooledObjects;
using Microsoft.CodeAnalysis.ExternalAccess.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.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?> GetNewNestedFileWorkspaceEditAsync(
JsonSerializableRazorPinnedSolutionInfoWrapper solutionInfo,
JsonSerializableDocumentId documentId,
NestedFileKind fileKind,
CancellationToken cancellationToken)
=> RunServiceAsync(
solutionInfo,
documentId,
context => GetNewNestedFileWorkspaceEditAsync(context, fileKind, cancellationToken),
cancellationToken);

private async ValueTask<WorkspaceEdit?> GetNewNestedFileWorkspaceEditAsync(
RemoteDocumentContext context,
NestedFileKind fileKind,
CancellationToken cancellationToken)
{
var razorFilePath = context.FilePath;
if (GetNestedFilePath(razorFilePath, fileKind) is not string nestedFilePath)
{
return null;
}

var nestedFileUri = LspFactory.CreateFilePathUri(nestedFilePath, _languageServerFeatureOptions);

var content = await GenerateContentAsync(
fileKind, context, 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, NestedFileKind fileKind)
{
return fileKind switch
{
NestedFileKind.Css => razorFilePath + ".css",
NestedFileKind.CSharp => razorFilePath + ".cs",
NestedFileKind.JavaScript => razorFilePath + ".js",
_ => null
};
}

private async Task<string> GenerateContentAsync(
NestedFileKind 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).IsLegacy() ? "view" : "component";
return $"/* CSS for {componentName} {fileType} */\r\n";
}

private static string GenerateJavaScriptContent(string razorFilePath)
{
var componentName = Path.GetFileNameWithoutExtension(razorFilePath);
var fileType = FileKinds.GetFileKindFromPath(razorFilePath).IsLegacy() ? "view" : "component";
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 ns) || ns.IsNullOrEmpty())
{
Logger.LogWarning($"Could not determine namespace for: {razorFilePath}");
ns = "Unknown";

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 think if you pass inGlobalNamespace: true to CreateProjectAndRazorDocument in a test, we should get coverage of this. Might be worth it just for regression prevention. If it's not that simple, I wouldn't worry.

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.

It was that easy, and it actually behaved differently than I was expecting. Good catch!

}

var content = GenerateCodeBehindClass(className, ns);

// 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)
{
using var _ = StringBuilderPool.GetPooledObject(out var builder);

builder.AppendLine($"namespace {namespaceName}");
builder.AppendLine("{");
builder.AppendLine($"public partial class {className}");
builder.AppendLine("{");
builder.AppendLine("}");
builder.AppendLine("}");

return builder.ToString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// 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;
using Microsoft.CodeAnalysis.ExternalAccess.Razor;
using Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost;
using Microsoft.CodeAnalysis.Razor.Cohost;
using Microsoft.CodeAnalysis.Razor.Logging;
using Microsoft.CodeAnalysis.Razor.Protocol.NestedFiles;
using Microsoft.CodeAnalysis.Razor.Remote;

namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost;

#pragma warning disable RS0030 // Do not use banned APIs
[Shared]
[CohostEndpoint(RazorLSPConstants.AddNestedFileName)]
[ExportCohostStatelessLspService(typeof(CohostAddNestedFileEndpoint))]
[method: ImportingConstructor]
#pragma warning restore RS0030 // Do not use banned APIs
internal sealed class CohostAddNestedFileEndpoint(
IRemoteServiceInvoker remoteServiceInvoker,
IIncompatibleProjectService incompatibleProjectService,
ILoggerFactory loggerFactory)
: AbstractCohostDocumentEndpoint<AddNestedFileParams, VoidResult>(incompatibleProjectService)
{
private readonly IRemoteServiceInvoker _remoteServiceInvoker = remoteServiceInvoker;
private readonly ILogger _logger = loggerFactory.GetOrCreateLogger<CohostAddNestedFileEndpoint>();

protected override bool MutatesSolutionState => false;

protected override bool RequiresLSPSolution => true;

protected override RazorTextDocumentIdentifier? GetRazorTextDocumentIdentifier(AddNestedFileParams request)
=> request.TextDocument.ToRazorTextDocumentIdentifier();

protected override Task<VoidResult> HandleRequestAsync(
AddNestedFileParams request,
TextDocument razorDocument,
CancellationToken cancellationToken)
=> Assumed.Unreachable<Task<VoidResult>>();

protected override async Task<VoidResult> HandleRequestAsync(
AddNestedFileParams request,
RazorCohostRequestContext context,
TextDocument razorDocument,
CancellationToken cancellationToken)
{
var workspaceEdit = await _remoteServiceInvoker.TryInvokeAsync<IRemoteAddNestedFileService, WorkspaceEdit?>(
razorDocument.Project.Solution,
(service, solutionInfo, ct) => service.GetNewNestedFileWorkspaceEditAsync(
solutionInfo,
razorDocument.Id,
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