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
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ public static void AddCodeActionsServices(this IServiceCollection services)
services.AddSingleton<ICSharpCodeActionResolver, UnformattedRemappingCSharpCodeActionResolver>();

// Razor Code actions
services.AddSingleton<IRazorCodeActionProvider, ExtractToCssCodeActionProvider>();
services.AddSingleton<IRazorCodeActionResolver, ExtractToCssCodeActionResolver>();
services.AddSingleton<IRazorCodeActionProvider, ExtractToCodeBehindCodeActionProvider>();
services.AddSingleton<IRazorCodeActionResolver, ExtractToCodeBehindCodeActionResolver>();
services.AddSingleton<IRazorCodeActionProvider, ExtractToComponentCodeActionProvider>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json.Serialization;

namespace Microsoft.CodeAnalysis.Razor.CodeActions.Models;

internal sealed class ExtractToCssCodeActionParams
{
[JsonPropertyName("extractStart")]
public int ExtractStart { get; set; }

[JsonPropertyName("extractEnd")]
public int ExtractEnd { get; set; }

[JsonPropertyName("removeStart")]
public int RemoveStart { get; set; }

[JsonPropertyName("removeEnd")]
public int RemoveEnd { get; set; }
Comment on lines +10 to +20
Copy link
Member

Choose a reason for hiding this comment

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

💭 Broader question: Should we just be using TextSpan for "start/end" pairs of properties like these? After all, we generally assign them with TextSpan.Start and TextSpan.End. If you agree, that'd probably be better in a separate change since here are several "params" classes with a similar shape.

Copy link
Member Author

Choose a reason for hiding this comment

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

These need to be JSON serializable so we'd have to use Range, which means it's probably a wash to me, but I don't mind it. One day I'll beat these provider and resolver classes into a shape I like, and have things spread across less files, so maybe then? :)

Copy link
Member

Choose a reason for hiding this comment

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

Just wanted to ask the question. I'd forgotten that [DataMember] isn't supported by System.Text.Json, but I'm glad you're keeping track of those details. 😄

It does seem like it'd be pretty easy to add RazorTextSpan that is JSON-serializable and implicitly convertible to/from TextSpan, if you fancy that idea.

Copy link
Member Author

Choose a reason for hiding this comment

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

I forgot about that type!

Copy link
Member

Choose a reason for hiding this comment

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

Oh, I didn't realize we already had. FWIW, it looks like that type should probably be a record struct rather than a sealed record to avoid heap allocations. Then, it's just a matter of making it implicitly convertible to/from TextSpan it should be super easy to use.

}
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,7 @@ internal class CreateComponentCodeActionResolver(LanguageServerFeatureOptions la
}

var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);

// VS Code in Windows expects path to start with '/'
var updatedPath = _languageServerFeatureOptions.ReturnCodeActionAndRenamePathsWithPrefixedSlash && !actionParams.Path.StartsWith("/")
? '/' + actionParams.Path
: actionParams.Path;
var newComponentUri = LspFactory.CreateFilePathUri(updatedPath);
var newComponentUri = LspFactory.CreateFilePathUri(actionParams.Path, _languageServerFeatureOptions);

using var documentChanges = new PooledArrayBuilder<SumType<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>>();
documentChanges.Add(new CreateFile() { DocumentUri = new(newComponentUri) });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ public Task<ImmutableArray<RazorVSInternalCodeAction>> ProvideAsync(RazorCodeAct
var resolutionParams = new RazorCodeActionResolutionParams()
{
TextDocument = context.Request.TextDocument,
Action = LanguageServerConstants.CodeActions.ExtractToCodeBehindAction,
Action = LanguageServerConstants.CodeActions.ExtractToCodeBehind,
Language = RazorLanguageKind.Razor,
DelegatedDocumentUri = context.DelegatedDocumentUri,
Data = actionParams,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ internal class ExtractToCodeBehindCodeActionResolver(
private readonly LanguageServerFeatureOptions _languageServerFeatureOptions = languageServerFeatureOptions;
private readonly IRoslynCodeActionHelpers _roslynCodeActionHelpers = roslynCodeActionHelpers;

public string Action => LanguageServerConstants.CodeActions.ExtractToCodeBehindAction;
public string Action => LanguageServerConstants.CodeActions.ExtractToCodeBehind;

public async Task<WorkspaceEdit?> ResolveAsync(DocumentContext documentContext, JsonElement data, RazorFormattingOptions options, CancellationToken cancellationToken)
{
Expand All @@ -37,22 +37,11 @@ internal class ExtractToCodeBehindCodeActionResolver(
return null;
}

if (!documentContext.FileKind.IsComponent())
{
return null;
}

var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);

var path = FilePathNormalizer.Normalize(documentContext.Uri.GetAbsoluteOrUNCPath());
var codeBehindPath = FileUtilities.GenerateUniquePath(path, $"{Path.GetExtension(path)}.cs");

// VS Code in Windows expects path to start with '/'
var updatedCodeBehindPath = _languageServerFeatureOptions.ReturnCodeActionAndRenamePathsWithPrefixedSlash && !codeBehindPath.StartsWith("/")
? '/' + codeBehindPath
: codeBehindPath;

var codeBehindUri = LspFactory.CreateFilePathUri(updatedCodeBehindPath);
var codeBehindUri = LspFactory.CreateFilePathUri(codeBehindPath, _languageServerFeatureOptions);

var text = await documentContext.GetSourceTextAsync(cancellationToken).ConfigureAwait(false);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public Task<ImmutableArray<RazorVSInternalCodeAction>> ProvideAsync(RazorCodeAct
var resolutionParams = new RazorCodeActionResolutionParams()
{
TextDocument = context.Request.TextDocument,
Action = LanguageServerConstants.CodeActions.ExtractToNewComponentAction,
Action = LanguageServerConstants.CodeActions.ExtractToNewComponent,
Language = RazorLanguageKind.Razor,
DelegatedDocumentUri = context.DelegatedDocumentUri,
Data = actionParams,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ internal class ExtractToComponentCodeActionResolver(
{
private readonly LanguageServerFeatureOptions _languageServerFeatureOptions = languageServerFeatureOptions;

public string Action => LanguageServerConstants.CodeActions.ExtractToNewComponentAction;
public string Action => LanguageServerConstants.CodeActions.ExtractToNewComponent;

public async Task<WorkspaceEdit?> ResolveAsync(DocumentContext documentContext, JsonElement data, RazorFormattingOptions options, CancellationToken cancellationToken)
{
Expand All @@ -50,13 +50,7 @@ internal class ExtractToComponentCodeActionResolver(
var templatePath = Path.Combine(directoryName, "Component.razor");
var componentPath = FileUtilities.GenerateUniquePath(templatePath, ".razor");
var componentName = Path.GetFileNameWithoutExtension(componentPath);

// VS Code in Windows expects path to start with '/'
componentPath = _languageServerFeatureOptions.ReturnCodeActionAndRenamePathsWithPrefixedSlash && !componentPath.StartsWith('/')
? '/' + componentPath
: componentPath;

var newComponentUri = new DocumentUri(LspFactory.CreateFilePathUri(componentPath));
var newComponentUri = new DocumentUri(LspFactory.CreateFilePathUri(componentPath, _languageServerFeatureOptions));

using var _ = StringBuilderPool.GetPooledObject(out var builder);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.Language.Syntax;
using Microsoft.AspNetCore.Razor.Threading;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Razor.CodeActions.Models;
using Microsoft.CodeAnalysis.Razor.CodeActions.Razor;
using Microsoft.CodeAnalysis.Razor.Logging;
using Microsoft.CodeAnalysis.Razor.Protocol;

namespace Microsoft.CodeAnalysis.Razor.CodeActions;

internal class ExtractToCssCodeActionProvider(ILoggerFactory loggerFactory) : IRazorCodeActionProvider
{
private readonly ILogger _logger = loggerFactory.GetOrCreateLogger<ExtractToCssCodeActionProvider>();

public Task<ImmutableArray<RazorVSInternalCodeAction>> ProvideAsync(RazorCodeActionContext context, CancellationToken cancellationToken)
{
if (!context.SupportsFileCreation)
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

if (!context.CodeDocument.FileKind.IsComponent())
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

if (!context.CodeDocument.TryGetSyntaxRoot(out var root))
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

var owner = root.FindInnermostNode(context.StartAbsoluteIndex);
if (owner is null)
{
_logger.LogWarning("Owner should never be null.");
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

// If we're inside an element, move to the start tag so the following checks work as expected
if (owner is MarkupTextLiteralSyntax { Parent: MarkupElementSyntax { StartTag: { } startTag } })
{
owner = startTag;
}

// We have to be in a style tag (or inside it, but we'll have moved to the parent if so, above)
if (owner is not (MarkupStartTagSyntax { Name.Content: "style" } or MarkupEndTagSyntax { Name.Content: "style" }))
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

// If there is any C# or Razor in the style tag, we can't offer, so it has to be one big text literal.
if (owner.Parent is not MarkupElementSyntax { Body: [MarkupTextLiteralSyntax textLiteral] } markupElement ||
textLiteral.ChildNodes().Any())
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

if (textLiteral.LiteralTokens.All(static t => t.IsWhitespace()))
{
// If the text literal is all whitespace, we don't want to offer the action.
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

// If there are diagnostics, we can't trust the tree to be what we expect.
if (markupElement.GetDiagnostics().Any(static d => d.Severity == RazorDiagnosticSeverity.Error))
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

var actionParams = new ExtractToCssCodeActionParams()
{
ExtractStart = textLiteral.Span.Start,
ExtractEnd = textLiteral.Span.End,
RemoveStart = markupElement.Span.Start,
RemoveEnd = markupElement.Span.End
};

var resolutionParams = new RazorCodeActionResolutionParams()
{
TextDocument = context.Request.TextDocument,
Action = LanguageServerConstants.CodeActions.ExtractToCss,
Language = RazorLanguageKind.Razor,
DelegatedDocumentUri = context.DelegatedDocumentUri,
Data = actionParams,
};

var razorFileName = Path.GetFileName(context.Request.TextDocument.DocumentUri.GetAbsoluteOrUNCPath());
var codeAction = RazorCodeActionFactory.CreateExtractToCss(razorFileName, resolutionParams);
return Task.FromResult<ImmutableArray<RazorVSInternalCodeAction>>([codeAction]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// 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.Buffers;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.CodeAnalysis.Razor.CodeActions.Models;
using Microsoft.CodeAnalysis.Razor.Formatting;
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Razor.Protocol;
using Microsoft.CodeAnalysis.Razor.Utilities;
using Microsoft.CodeAnalysis.Razor.Workspaces;
using Microsoft.CodeAnalysis.Text;

namespace Microsoft.CodeAnalysis.Razor.CodeActions;

internal class ExtractToCssCodeActionResolver(
LanguageServerFeatureOptions languageServerFeatureOptions,
IFileSystem fileSystem) : IRazorCodeActionResolver
{
private readonly LanguageServerFeatureOptions _languageServerFeatureOptions = languageServerFeatureOptions;
private readonly IFileSystem _fileSystem = fileSystem;

public string Action => LanguageServerConstants.CodeActions.ExtractToCss;

public async Task<WorkspaceEdit?> ResolveAsync(DocumentContext documentContext, JsonElement data, RazorFormattingOptions options, CancellationToken cancellationToken)
{
var actionParams = data.Deserialize<ExtractToCssCodeActionParams>();
if (actionParams is null)
{
return null;
}

var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);

var cssFilePath = $"{FilePathNormalizer.Normalize(documentContext.Uri.GetAbsoluteOrUNCPath())}.css";
var cssFileUri = LspFactory.CreateFilePathUri(cssFilePath, _languageServerFeatureOptions);

var text = await documentContext.GetSourceTextAsync(cancellationToken).ConfigureAwait(false);

var cssContent = text.GetSubTextString(new TextSpan(actionParams.ExtractStart, actionParams.ExtractEnd - actionParams.ExtractStart)).Trim();
var removeRange = codeDocument.Source.Text.GetRange(actionParams.RemoveStart, actionParams.RemoveEnd);

var codeDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { DocumentUri = new(documentContext.Uri) };
var cssDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { DocumentUri = new(cssFileUri) };

using var changes = new PooledArrayBuilder<SumType<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>>(capacity: 3);

// First, an edit to remove the script tag and its contents.
changes.Add(new TextDocumentEdit
{
TextDocument = codeDocumentIdentifier,
Edits = [LspFactory.CreateTextEdit(removeRange, string.Empty)]
});

if (_fileSystem.FileExists(cssFilePath))
{
// CSS file already exists, insert the content at the end.
GetLastLineNumberAndLength(cssFilePath, out var lastLineNumber, out var lastLineLength);

changes.Add(new TextDocumentEdit
{
TextDocument = cssDocumentIdentifier,
Edits = [LspFactory.CreateTextEdit(
position: (lastLineNumber, lastLineLength),
newText: lastLineNumber == 0 && lastLineLength == 0
? cssContent
: Environment.NewLine + Environment.NewLine + cssContent)]
Copy link
Contributor

Choose a reason for hiding this comment

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

Environment.NewLine + Environment.NewLine

Another super-nit: could cache two new lines in a static (unless compiler optimizes this)

Copy link
Member Author

@davidwengier davidwengier Jul 1, 2025

Choose a reason for hiding this comment

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

Should make no difference. My understanding is the concat will lower to a string.Concat call, which will pre-allocate a string of the right length, and copy everything in. So there is no intermediate allocation for each piece.

I'm sure Todd or Dustin will correct me if I'm wrong :)

Copy link
Member

Choose a reason for hiding this comment

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

No, the compiler doesn't optimize this, since NewLine can be different at runtime.

It's correct that this'll just be a string.Contact call. it'll allocate a string of size Environment.NewLine.Length + Environment.NewLine.Length + cssContent.Length and perform three copies into the new string to bring the data over. Creating a static with Environment.NewLine + Environment.NewLine to use instead wouldn't be noticeable from a CPU perspective, unless this were some fabulously tight loop executing millions of times. Even then, it probably wouldn't bubble up.

});
}
else
{
// No CSS file, create it and fill it in
changes.Add(new CreateFile { DocumentUri = cssDocumentIdentifier.DocumentUri });
changes.Add(new TextDocumentEdit
{
TextDocument = cssDocumentIdentifier,
Edits = [LspFactory.CreateTextEdit(position: (0, 0), cssContent)]
});
}

return new WorkspaceEdit
{
DocumentChanges = changes.ToArray(),
};
}

private void GetLastLineNumberAndLength(string cssFilePath, out int lastLineNumber, out int lastLineLength)
{
using var stream = _fileSystem.OpenReadStream(cssFilePath);
GetLastLineNumberAndLength(stream, bufferSize: 4096, out lastLineNumber, out lastLineLength);
}

private static void GetLastLineNumberAndLength(Stream stream, int bufferSize, out int lastLineNumber, out int lastLineLength)
Copy link
Contributor

Choose a reason for hiding this comment

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

GetLastLineNumberAndLength

nit: Seems like this could go to StreamHelpers.cs or StreamExtensions.cs somewhere, and then you wouldn't need TestAccessor

Copy link
Member Author

Choose a reason for hiding this comment

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

It could, but also feels pretty niche to me so I'm not sure

{
lastLineNumber = 0;
lastLineLength = 0;

using var _ = ArrayPool<char>.Shared.GetPooledArray(bufferSize, out var buffer);
using var reader = new StreamReader(stream);

var currLineLength = 0;
var currLineNumber = 0;

int charsRead;
while ((charsRead = reader.Read(buffer, 0, buffer.Length)) > 0)
{
var chunk = buffer.AsSpan(0, charsRead);
while (true)
{
// Since we're only concerned with the last line length, we don't need to worry about \r\n. Strictly speaking,
// we're incorrectly counting the \r in the line length, but since the last line can't end with a \n (since that
// starts a new line) it doesn't actually change the output of the method.
var index = chunk.IndexOf('\n');
if (index == -1)
{
currLineLength += chunk.Length;
break;
}

currLineNumber++;
currLineLength = 0;
chunk = chunk[(index + 1)..];
}
}

lastLineNumber = currLineNumber;
lastLineLength = currLineLength;
}

internal readonly struct TestAccessor
{
public static void GetLastLineNumberAndLength(Stream stream, int bufferSize, out int lastLineNumber, out int lastLineLength)
{
ExtractToCssCodeActionResolver.GetLastLineNumberAndLength(stream, bufferSize, out lastLineNumber, out lastLineLength);
}
}
}
Loading