Skip to content

Commit f7f9a7c

Browse files
authored
Implement "Extract to Foo.razor.css" code action (#11989)
Just a little quality-of-life feature I felt I was missing
2 parents 5556916 + 292b41a commit f7f9a7c

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+943
-129
lines changed

src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Extensions/IServiceCollectionExtensions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ public static void AddCodeActionsServices(this IServiceCollection services)
143143
services.AddSingleton<ICSharpCodeActionResolver, UnformattedRemappingCSharpCodeActionResolver>();
144144

145145
// Razor Code actions
146+
services.AddSingleton<IRazorCodeActionProvider, ExtractToCssCodeActionProvider>();
147+
services.AddSingleton<IRazorCodeActionResolver, ExtractToCssCodeActionResolver>();
146148
services.AddSingleton<IRazorCodeActionProvider, ExtractToCodeBehindCodeActionProvider>();
147149
services.AddSingleton<IRazorCodeActionResolver, ExtractToCodeBehindCodeActionResolver>();
148150
services.AddSingleton<IRazorCodeActionProvider, ExtractToComponentCodeActionProvider>();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Text.Json.Serialization;
5+
6+
namespace Microsoft.CodeAnalysis.Razor.CodeActions.Models;
7+
8+
internal sealed class ExtractToCssCodeActionParams
9+
{
10+
[JsonPropertyName("extractStart")]
11+
public int ExtractStart { get; set; }
12+
13+
[JsonPropertyName("extractEnd")]
14+
public int ExtractEnd { get; set; }
15+
16+
[JsonPropertyName("removeStart")]
17+
public int RemoveStart { get; set; }
18+
19+
[JsonPropertyName("removeEnd")]
20+
public int RemoveEnd { get; set; }
21+
}

src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/CodeActions/Razor/CreateComponentCodeActionResolver.cs

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,7 @@ internal class CreateComponentCodeActionResolver(LanguageServerFeatureOptions la
3939
}
4040

4141
var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);
42-
43-
// VS Code in Windows expects path to start with '/'
44-
var updatedPath = _languageServerFeatureOptions.ReturnCodeActionAndRenamePathsWithPrefixedSlash && !actionParams.Path.StartsWith("/")
45-
? '/' + actionParams.Path
46-
: actionParams.Path;
47-
var newComponentUri = LspFactory.CreateFilePathUri(updatedPath);
42+
var newComponentUri = LspFactory.CreateFilePathUri(actionParams.Path, _languageServerFeatureOptions);
4843

4944
using var documentChanges = new PooledArrayBuilder<SumType<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>>();
5045
documentChanges.Add(new CreateFile() { DocumentUri = new(newComponentUri) });

src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/CodeActions/Razor/ExtractToCodeBehindCodeActionProvider.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ public Task<ImmutableArray<RazorVSInternalCodeAction>> ProvideAsync(RazorCodeAct
113113
var resolutionParams = new RazorCodeActionResolutionParams()
114114
{
115115
TextDocument = context.Request.TextDocument,
116-
Action = LanguageServerConstants.CodeActions.ExtractToCodeBehindAction,
116+
Action = LanguageServerConstants.CodeActions.ExtractToCodeBehind,
117117
Language = RazorLanguageKind.Razor,
118118
DelegatedDocumentUri = context.DelegatedDocumentUri,
119119
Data = actionParams,

src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/CodeActions/Razor/ExtractToCodeBehindCodeActionResolver.cs

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ internal class ExtractToCodeBehindCodeActionResolver(
2727
private readonly LanguageServerFeatureOptions _languageServerFeatureOptions = languageServerFeatureOptions;
2828
private readonly IRoslynCodeActionHelpers _roslynCodeActionHelpers = roslynCodeActionHelpers;
2929

30-
public string Action => LanguageServerConstants.CodeActions.ExtractToCodeBehindAction;
30+
public string Action => LanguageServerConstants.CodeActions.ExtractToCodeBehind;
3131

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

40-
if (!documentContext.FileKind.IsComponent())
41-
{
42-
return null;
43-
}
44-
4540
var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);
4641

4742
var path = FilePathNormalizer.Normalize(documentContext.Uri.GetAbsoluteOrUNCPath());
4843
var codeBehindPath = FileUtilities.GenerateUniquePath(path, $"{Path.GetExtension(path)}.cs");
49-
50-
// VS Code in Windows expects path to start with '/'
51-
var updatedCodeBehindPath = _languageServerFeatureOptions.ReturnCodeActionAndRenamePathsWithPrefixedSlash && !codeBehindPath.StartsWith("/")
52-
? '/' + codeBehindPath
53-
: codeBehindPath;
54-
55-
var codeBehindUri = LspFactory.CreateFilePathUri(updatedCodeBehindPath);
44+
var codeBehindUri = LspFactory.CreateFilePathUri(codeBehindPath, _languageServerFeatureOptions);
5645

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

src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ public Task<ImmutableArray<RazorVSInternalCodeAction>> ProvideAsync(RazorCodeAct
7474
var resolutionParams = new RazorCodeActionResolutionParams()
7575
{
7676
TextDocument = context.Request.TextDocument,
77-
Action = LanguageServerConstants.CodeActions.ExtractToNewComponentAction,
77+
Action = LanguageServerConstants.CodeActions.ExtractToNewComponent,
7878
Language = RazorLanguageKind.Razor,
7979
DelegatedDocumentUri = context.DelegatedDocumentUri,
8080
Data = actionParams,

src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ internal class ExtractToComponentCodeActionResolver(
2727
{
2828
private readonly LanguageServerFeatureOptions _languageServerFeatureOptions = languageServerFeatureOptions;
2929

30-
public string Action => LanguageServerConstants.CodeActions.ExtractToNewComponentAction;
30+
public string Action => LanguageServerConstants.CodeActions.ExtractToNewComponent;
3131

3232
public async Task<WorkspaceEdit?> ResolveAsync(DocumentContext documentContext, JsonElement data, RazorFormattingOptions options, CancellationToken cancellationToken)
3333
{
@@ -50,13 +50,7 @@ internal class ExtractToComponentCodeActionResolver(
5050
var templatePath = Path.Combine(directoryName, "Component.razor");
5151
var componentPath = FileUtilities.GenerateUniquePath(templatePath, ".razor");
5252
var componentName = Path.GetFileNameWithoutExtension(componentPath);
53-
54-
// VS Code in Windows expects path to start with '/'
55-
componentPath = _languageServerFeatureOptions.ReturnCodeActionAndRenamePathsWithPrefixedSlash && !componentPath.StartsWith('/')
56-
? '/' + componentPath
57-
: componentPath;
58-
59-
var newComponentUri = new DocumentUri(LspFactory.CreateFilePathUri(componentPath));
53+
var newComponentUri = new DocumentUri(LspFactory.CreateFilePathUri(componentPath, _languageServerFeatureOptions));
6054

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

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Collections.Generic;
5+
using System.Collections.Immutable;
6+
using System.IO;
7+
using System.Linq;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
using Microsoft.AspNetCore.Razor.Language;
11+
using Microsoft.AspNetCore.Razor.Language.Syntax;
12+
using Microsoft.AspNetCore.Razor.Threading;
13+
using Microsoft.CodeAnalysis;
14+
using Microsoft.CodeAnalysis.Razor.CodeActions.Models;
15+
using Microsoft.CodeAnalysis.Razor.CodeActions.Razor;
16+
using Microsoft.CodeAnalysis.Razor.Logging;
17+
using Microsoft.CodeAnalysis.Razor.Protocol;
18+
19+
namespace Microsoft.CodeAnalysis.Razor.CodeActions;
20+
21+
internal class ExtractToCssCodeActionProvider(ILoggerFactory loggerFactory) : IRazorCodeActionProvider
22+
{
23+
private readonly ILogger _logger = loggerFactory.GetOrCreateLogger<ExtractToCssCodeActionProvider>();
24+
25+
public Task<ImmutableArray<RazorVSInternalCodeAction>> ProvideAsync(RazorCodeActionContext context, CancellationToken cancellationToken)
26+
{
27+
if (!context.SupportsFileCreation)
28+
{
29+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
30+
}
31+
32+
if (!context.CodeDocument.FileKind.IsComponent())
33+
{
34+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
35+
}
36+
37+
if (!context.CodeDocument.TryGetSyntaxRoot(out var root))
38+
{
39+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
40+
}
41+
42+
var owner = root.FindInnermostNode(context.StartAbsoluteIndex);
43+
if (owner is null)
44+
{
45+
_logger.LogWarning("Owner should never be null.");
46+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
47+
}
48+
49+
// If we're inside an element, move to the start tag so the following checks work as expected
50+
if (owner is MarkupTextLiteralSyntax { Parent: MarkupElementSyntax { StartTag: { } startTag } })
51+
{
52+
owner = startTag;
53+
}
54+
55+
// We have to be in a style tag (or inside it, but we'll have moved to the parent if so, above)
56+
if (owner is not (MarkupStartTagSyntax { Name.Content: "style" } or MarkupEndTagSyntax { Name.Content: "style" }))
57+
{
58+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
59+
}
60+
61+
// If there is any C# or Razor in the style tag, we can't offer, so it has to be one big text literal.
62+
if (owner.Parent is not MarkupElementSyntax { Body: [MarkupTextLiteralSyntax textLiteral] } markupElement ||
63+
textLiteral.ChildNodes().Any())
64+
{
65+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
66+
}
67+
68+
if (textLiteral.LiteralTokens.All(static t => t.IsWhitespace()))
69+
{
70+
// If the text literal is all whitespace, we don't want to offer the action.
71+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
72+
}
73+
74+
// If there are diagnostics, we can't trust the tree to be what we expect.
75+
if (markupElement.GetDiagnostics().Any(static d => d.Severity == RazorDiagnosticSeverity.Error))
76+
{
77+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
78+
}
79+
80+
var actionParams = new ExtractToCssCodeActionParams()
81+
{
82+
ExtractStart = textLiteral.Span.Start,
83+
ExtractEnd = textLiteral.Span.End,
84+
RemoveStart = markupElement.Span.Start,
85+
RemoveEnd = markupElement.Span.End
86+
};
87+
88+
var resolutionParams = new RazorCodeActionResolutionParams()
89+
{
90+
TextDocument = context.Request.TextDocument,
91+
Action = LanguageServerConstants.CodeActions.ExtractToCss,
92+
Language = RazorLanguageKind.Razor,
93+
DelegatedDocumentUri = context.DelegatedDocumentUri,
94+
Data = actionParams,
95+
};
96+
97+
var razorFileName = Path.GetFileName(context.Request.TextDocument.DocumentUri.GetAbsoluteOrUNCPath());
98+
var codeAction = RazorCodeActionFactory.CreateExtractToCss(razorFileName, resolutionParams);
99+
return Task.FromResult<ImmutableArray<RazorVSInternalCodeAction>>([codeAction]);
100+
}
101+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Buffers;
6+
using System.IO;
7+
using System.Text.Json;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
using Microsoft.AspNetCore.Razor.PooledObjects;
11+
using Microsoft.CodeAnalysis.Razor.CodeActions.Models;
12+
using Microsoft.CodeAnalysis.Razor.Formatting;
13+
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
14+
using Microsoft.CodeAnalysis.Razor.Protocol;
15+
using Microsoft.CodeAnalysis.Razor.Utilities;
16+
using Microsoft.CodeAnalysis.Razor.Workspaces;
17+
using Microsoft.CodeAnalysis.Text;
18+
19+
namespace Microsoft.CodeAnalysis.Razor.CodeActions;
20+
21+
internal class ExtractToCssCodeActionResolver(
22+
LanguageServerFeatureOptions languageServerFeatureOptions,
23+
IFileSystem fileSystem) : IRazorCodeActionResolver
24+
{
25+
private readonly LanguageServerFeatureOptions _languageServerFeatureOptions = languageServerFeatureOptions;
26+
private readonly IFileSystem _fileSystem = fileSystem;
27+
28+
public string Action => LanguageServerConstants.CodeActions.ExtractToCss;
29+
30+
public async Task<WorkspaceEdit?> ResolveAsync(DocumentContext documentContext, JsonElement data, RazorFormattingOptions options, CancellationToken cancellationToken)
31+
{
32+
var actionParams = data.Deserialize<ExtractToCssCodeActionParams>();
33+
if (actionParams is null)
34+
{
35+
return null;
36+
}
37+
38+
var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);
39+
40+
var cssFilePath = $"{FilePathNormalizer.Normalize(documentContext.Uri.GetAbsoluteOrUNCPath())}.css";
41+
var cssFileUri = LspFactory.CreateFilePathUri(cssFilePath, _languageServerFeatureOptions);
42+
43+
var text = await documentContext.GetSourceTextAsync(cancellationToken).ConfigureAwait(false);
44+
45+
var cssContent = text.GetSubTextString(new TextSpan(actionParams.ExtractStart, actionParams.ExtractEnd - actionParams.ExtractStart)).Trim();
46+
var removeRange = codeDocument.Source.Text.GetRange(actionParams.RemoveStart, actionParams.RemoveEnd);
47+
48+
var codeDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { DocumentUri = new(documentContext.Uri) };
49+
var cssDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { DocumentUri = new(cssFileUri) };
50+
51+
using var changes = new PooledArrayBuilder<SumType<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>>(capacity: 3);
52+
53+
// First, an edit to remove the script tag and its contents.
54+
changes.Add(new TextDocumentEdit
55+
{
56+
TextDocument = codeDocumentIdentifier,
57+
Edits = [LspFactory.CreateTextEdit(removeRange, string.Empty)]
58+
});
59+
60+
if (_fileSystem.FileExists(cssFilePath))
61+
{
62+
// CSS file already exists, insert the content at the end.
63+
GetLastLineNumberAndLength(cssFilePath, out var lastLineNumber, out var lastLineLength);
64+
65+
changes.Add(new TextDocumentEdit
66+
{
67+
TextDocument = cssDocumentIdentifier,
68+
Edits = [LspFactory.CreateTextEdit(
69+
position: (lastLineNumber, lastLineLength),
70+
newText: lastLineNumber == 0 && lastLineLength == 0
71+
? cssContent
72+
: Environment.NewLine + Environment.NewLine + cssContent)]
73+
});
74+
}
75+
else
76+
{
77+
// No CSS file, create it and fill it in
78+
changes.Add(new CreateFile { DocumentUri = cssDocumentIdentifier.DocumentUri });
79+
changes.Add(new TextDocumentEdit
80+
{
81+
TextDocument = cssDocumentIdentifier,
82+
Edits = [LspFactory.CreateTextEdit(position: (0, 0), cssContent)]
83+
});
84+
}
85+
86+
return new WorkspaceEdit
87+
{
88+
DocumentChanges = changes.ToArray(),
89+
};
90+
}
91+
92+
private void GetLastLineNumberAndLength(string cssFilePath, out int lastLineNumber, out int lastLineLength)
93+
{
94+
using var stream = _fileSystem.OpenReadStream(cssFilePath);
95+
GetLastLineNumberAndLength(stream, bufferSize: 4096, out lastLineNumber, out lastLineLength);
96+
}
97+
98+
private static void GetLastLineNumberAndLength(Stream stream, int bufferSize, out int lastLineNumber, out int lastLineLength)
99+
{
100+
lastLineNumber = 0;
101+
lastLineLength = 0;
102+
103+
using var _ = ArrayPool<char>.Shared.GetPooledArray(bufferSize, out var buffer);
104+
using var reader = new StreamReader(stream);
105+
106+
var currLineLength = 0;
107+
var currLineNumber = 0;
108+
109+
int charsRead;
110+
while ((charsRead = reader.Read(buffer, 0, buffer.Length)) > 0)
111+
{
112+
var chunk = buffer.AsSpan(0, charsRead);
113+
while (true)
114+
{
115+
// Since we're only concerned with the last line length, we don't need to worry about \r\n. Strictly speaking,
116+
// we're incorrectly counting the \r in the line length, but since the last line can't end with a \n (since that
117+
// starts a new line) it doesn't actually change the output of the method.
118+
var index = chunk.IndexOf('\n');
119+
if (index == -1)
120+
{
121+
currLineLength += chunk.Length;
122+
break;
123+
}
124+
125+
currLineNumber++;
126+
currLineLength = 0;
127+
chunk = chunk[(index + 1)..];
128+
}
129+
}
130+
131+
lastLineNumber = currLineNumber;
132+
lastLineLength = currLineLength;
133+
}
134+
135+
internal readonly struct TestAccessor
136+
{
137+
public static void GetLastLineNumberAndLength(Stream stream, int bufferSize, out int lastLineNumber, out int lastLineLength)
138+
{
139+
ExtractToCssCodeActionResolver.GetLastLineNumberAndLength(stream, bufferSize, out lastLineNumber, out lastLineLength);
140+
}
141+
}
142+
}

0 commit comments

Comments
 (0)