te|xt"));
+ Assert.Null(AttributeOf(""));
+ Assert.Null(AttributeOf("
"));
+ Assert.Null(AttributeOf("
"));
+ Assert.Null(AttributeOf("
"));
+ Assert.Null(AttributeOf("|"));
+ Assert.Null(HtmlMarkupContext.AttributeAt(string.Empty, 0));
+ Assert.Equal(("a", "a", "1"), AttributeOf("
"));
+ }
+
+ /// The attribute at the caret: its name, the text its name span covers, and its value's text.
+ private static (string Name, string NameText, string? Value)? AttributeOf(string withCaret)
+ {
+ var at = withCaret.IndexOf('|', StringComparison.Ordinal);
+ var html = withCaret.Remove(at, 1);
+ return HtmlMarkupContext.AttributeAt(html, at) is { } attribute
+ ? (
+ attribute.Name,
+ html[attribute.NameSpan.Start..attribute.NameSpan.End],
+ attribute.Value is { } value ? html[value.Start..value.End] : null
+ )
+ : null;
+ }
+
private static MarkupContext Of(string withCaret)
{
var at = withCaret.IndexOf('|', StringComparison.Ordinal);
diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/MergeDeclarationAssignmentTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/MergeDeclarationAssignmentTests.cs
index da93531e..8dda07f9 100644
--- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/MergeDeclarationAssignmentTests.cs
+++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/MergeDeclarationAssignmentTests.cs
@@ -1,6 +1,6 @@
-using Microsoft.CodeAnalysis.Text;
using SharpLsp.Sidecar.Common;
using SharpLsp.Sidecar.CSharp.Workspace;
+using static SharpLsp.Sidecar.CSharp.Tests.TextEditing;
#pragma warning disable CA1307 // StringComparison overloads add no value to xUnit assertions
#pragma warning disable CA1515 // Public xUnit discovery type
@@ -258,18 +258,4 @@ private static T Unwrap(Outcome.Result result)
Assert.False(result.IsError, result.Match(_ => "ok", error => error));
return +result;
}
-
- private static string ApplyEdits(string source, IEnumerable edits)
- {
- var text = SourceText.From(source);
- var changes = edits.Select(edit => new TextChange(EditSpan(text, edit), edit.NewText));
- return text.WithChanges(changes).ToString();
- }
-
- private static TextSpan EditSpan(SourceText text, TextEditResult edit)
- {
- var start = text.Lines.GetPosition(new LinePosition(edit.StartLine, edit.StartCharacter));
- var end = text.Lines.GetPosition(new LinePosition(edit.EndLine, edit.EndCharacter));
- return TextSpan.FromBounds(start, end);
- }
}
diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/NavigationEndToEndTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/NavigationEndToEndTests.cs
index cfff71bf..1f9933f6 100644
--- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/NavigationEndToEndTests.cs
+++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/NavigationEndToEndTests.cs
@@ -111,7 +111,7 @@ public async Task PrepareCallHierarchy_on_Use_returns_method_item()
fixture.PosPayload(119, 20)
);
Assert.Equal("Use", item.Name);
- Assert.Equal("method", item.Kind);
+ Assert.Equal("Method", item.Kind);
}
[Fact]
diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCodeActionsTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCodeActionsTests.cs
new file mode 100644
index 00000000..c91f90a0
--- /dev/null
+++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCodeActionsTests.cs
@@ -0,0 +1,349 @@
+using SharpLsp.Sidecar.Common;
+using SharpLsp.Sidecar.CSharp.Workspace;
+using static SharpLsp.Sidecar.CSharp.Tests.TextEditing;
+
+#pragma warning disable CA1515 // Public xUnit discovery type
+#pragma warning disable CA2007 // xUnit executes without a synchronization context
+#pragma warning disable RS1035 // Real-file reads are intentional in this end-to-end suite
+#pragma warning disable IDE0042 // Positions read better as (Line, Character) pairs
+
+namespace SharpLsp.Sidecar.CSharp.Tests;
+
+///
+/// Razor code actions against the real fixture: @using for an unbound type or an unknown
+/// component tag, a generated event handler, a component's @code block moved into a
+/// code-behind partial class, and sibling elements moved into a new component. Each action's
+/// edit is applied and the result re-read through the workspace. Implements [RAZOR-TESTING] for
+/// [RAZOR-CODE-ACTIONS].
+///
+public sealed class RazorCodeActionsTests : IClassFixture
+{
+ private readonly RazorFixture _fixture;
+
+ public RazorCodeActionsTests(RazorFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ private string Counter => _fixture.Path("BlazorApp", "Components", "Pages", "Counter.razor");
+
+ [Fact]
+ public async Task An_unbound_type_offers_its_namespace_as_an_at_using()
+ {
+ using var manager = await _fixture.OpenManagerAsync();
+ var original = await File.ReadAllTextAsync(Counter);
+ var text = original.Replace(
+ "Counter
",
+ "Counter
\n@(new StringBuilder(\"x\"))
",
+ StringComparison.Ordinal
+ );
+ _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(Counter, text));
+ Assert.Contains(await ErrorsAsync(manager, Counter), code => code == "CS0246");
+
+ // The fix names the namespace and is preferred: it is the only one. [RAZOR-CODE-ACTIONS-USING]
+ var at = Find(text, "StringBuilder");
+ var action = Assert.Single(
+ await ActionsAsync(manager, Counter, at),
+ a => a.Title == "@using System.Text"
+ );
+ Assert.Equal("quickfix", action.Kind);
+ Assert.True(action.IsPreferred);
+
+ // Its edit adds an `@using` line, and the file then compiles.
+ var edit = await ResolveAsync(manager, action);
+ var change = Assert.Single(edit.DocumentChanges);
+ Assert.True(NativePaths.AreEqual(Counter, change.FilePath));
+ var fixedText = ApplyEdits(text, change.Edits);
+ Assert.Contains("@using System.Text\n", fixedText, StringComparison.Ordinal);
+ Assert.StartsWith("@page \"/counter\"", fixedText, StringComparison.Ordinal);
+ _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(Counter, fixedText));
+ Assert.DoesNotContain(await ErrorsAsync(manager, Counter), code => code == "CS0246");
+ Assert.DoesNotContain(
+ await ActionsAsync(manager, Counter, Find(fixedText, "StringBuilder")),
+ a => a.Title.StartsWith("@using", StringComparison.Ordinal)
+ );
+ }
+
+ [Fact]
+ public async Task An_unknown_component_tag_offers_the_namespace_that_declares_it()
+ {
+ using var manager = await _fixture.OpenManagerAsync();
+ var text = (await File.ReadAllTextAsync(Counter)).Replace(
+ "Counter
",
+ "Counter
\n",
+ StringComparison.Ordinal
+ );
+ _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(Counter, text));
+
+ // RZ10012 names an element the compiler cannot bind: its component's namespace fixes it.
+ var at = Find(text, "Virtualize");
+ var action = Assert.Single(
+ await ActionsAsync(manager, Counter, at),
+ a => a.Title == "@using Microsoft.AspNetCore.Components.Web.Virtualization"
+ );
+ Assert.True(action.IsPreferred);
+ var edit = await ResolveAsync(manager, action);
+ var fixedText = ApplyEdits(text, Assert.Single(edit.DocumentChanges).Edits);
+ _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(Counter, fixedText));
+ var markup = await manager.FindRazorMarkupAsync(Counter, default);
+ Assert.NotNull(markup);
+ Assert.DoesNotContain(markup.Diagnostics, diagnostic => diagnostic.Id == "RZ10012");
+ Assert.Contains(markup.Tags, tag => tag.Name == "Virtualize" && tag.Component is not null);
+ }
+
+ [Fact]
+ public async Task A_missing_event_handler_is_generated_in_the_code_block()
+ {
+ using var manager = await _fixture.OpenManagerAsync();
+ var text = (await File.ReadAllTextAsync(Counter)).Replace(
+ "Counter
",
+ "Counter
",
+ StringComparison.Ordinal
+ );
+ _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(Counter, text));
+
+ // The handler takes the event's argument type, as the file can name it. [RAZOR-CODE-ACTIONS-HANDLER]
+ var action = Assert.Single(
+ await ActionsAsync(manager, Counter, Find(text, "Reset")),
+ a => a.Title == "Generate event handler 'Reset'"
+ );
+ var edit = await ResolveAsync(manager, action);
+ var generated = ApplyEdits(text, Assert.Single(edit.DocumentChanges).Edits);
+ Assert.Contains(
+ " currentCount++;\n }\n\n private void Reset(MouseEventArgs args)\n {\n }\n}",
+ generated,
+ StringComparison.Ordinal
+ );
+ _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(Counter, generated));
+ Assert.Empty(await ErrorsAsync(manager, Counter));
+
+ // A component's EventCallback parameter takes a string, and a file with no
+ // code block gets one at its end.
+ var tally = _fixture.Path("BlazorApp", "Components", "Pages", "Leaders.razor");
+ var leaders = (await File.ReadAllTextAsync(tally)).Replace(
+ "@code {",
+ "\n\n@code {",
+ StringComparison.Ordinal
+ );
+ var withoutCode =
+ leaders[..leaders.IndexOf("@code {", StringComparison.Ordinal)].TrimEnd() + "\n";
+ _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(tally, withoutCode));
+ var relabel = Assert.Single(
+ await ActionsAsync(manager, tally, Find(withoutCode, "Relabel")),
+ a => a.Title == "Generate event handler 'Relabel'"
+ );
+ var appended = ApplyEdits(
+ withoutCode,
+ Assert.Single((await ResolveAsync(manager, relabel)).DocumentChanges).Edits
+ );
+ Assert.EndsWith(
+ "LabelChanged=\"Relabel\" />\n\n@code {\n private void Relabel(string args)\n {\n }\n}\n",
+ appended,
+ StringComparison.Ordinal
+ );
+
+ // A name that is not passed to a callback offers no handler.
+ Assert.DoesNotContain(
+ await ActionsAsync(manager, tally, Find(withoutCode, "Scoring.total")),
+ a => a.Title.StartsWith("Generate event handler", StringComparison.Ordinal)
+ );
+ }
+
+ [Fact]
+ public async Task A_code_block_moves_into_a_code_behind_partial_class()
+ {
+ using var manager = await _fixture.OpenManagerAsync();
+ var text = await File.ReadAllTextAsync(Counter);
+
+ // Offered on `@code` of a component with no code-behind. [RAZOR-CODE-ACTIONS-CODE-BEHIND]
+ var action = Assert.Single(
+ await ActionsAsync(manager, Counter, Find(text, "@code")),
+ a => a.Title == "Extract block to code behind"
+ );
+ Assert.Equal("refactor.extract", action.Kind);
+ var edit = await ResolveAsync(manager, action);
+ Assert.Equal(2, edit.DocumentChanges.Count);
+ var created = edit.DocumentChanges[0];
+ Assert.True(created.Create);
+ Assert.True(NativePaths.AreEqual(NativePaths.CodeBehindOf(Counter), created.FilePath));
+ var codeBehind = Assert.Single(created.Edits).NewText;
+ Assert.Contains(
+ "namespace BlazorApp.Components.Pages;\n",
+ codeBehind,
+ StringComparison.Ordinal
+ );
+ Assert.Contains(
+ "public partial class Counter\n{\n private int currentCount = 0;",
+ codeBehind,
+ StringComparison.Ordinal
+ );
+ Assert.Contains(
+ "using Microsoft.AspNetCore.Components.Web;\n",
+ codeBehind,
+ StringComparison.Ordinal
+ );
+ Assert.DoesNotContain("using System;", codeBehind, StringComparison.Ordinal);
+ Assert.EndsWith(" }\n}\n", codeBehind, StringComparison.Ordinal);
+
+ // The Razor file keeps its markup and loses the block.
+ var razor = edit.DocumentChanges[1];
+ Assert.False(razor.Create);
+ var remaining = ApplyEdits(text, razor.Edits);
+ Assert.DoesNotContain("@code", remaining, StringComparison.Ordinal);
+ Assert.EndsWith("Click me\n\n", remaining, StringComparison.Ordinal);
+
+ // Badge has a block, but its code-behind already exists: no second one.
+ var badge = _fixture.Path("BlazorApp", "Components", "Shared", "Badge.razor");
+ Assert.DoesNotContain(
+ await ActionsAsync(manager, badge, (0, 0)),
+ a => a.Title == "Extract block to code behind"
+ );
+ }
+
+ [Fact]
+ public async Task Sibling_elements_move_into_a_new_component_only_when_they_stand_alone()
+ {
+ using var manager = await _fixture.OpenManagerAsync();
+ var text = await File.ReadAllTextAsync(Counter);
+
+ // `` names nothing of the component: it moves into Component.razor. [RAZOR-CODE-ACTIONS-COMPONENT]
+ var heading = Find(text, "Counter
");
+ var action = Assert.Single(
+ await ActionsAsync(
+ manager,
+ Counter,
+ heading,
+ (heading.Line, heading.Character + "Counter
".Length)
+ ),
+ a => a.Title == "Extract element to new component 'Component'"
+ );
+ var edit = await ResolveAsync(manager, action);
+ var created = edit.DocumentChanges[0];
+ Assert.True(created.Create);
+ Assert.True(
+ NativePaths.AreEqual(
+ _fixture.Path("BlazorApp", "Components", "Pages", "Component.razor"),
+ created.FilePath
+ )
+ );
+ Assert.Equal("Counter
\n", Assert.Single(created.Edits).NewText);
+ var replaced = ApplyEdits(text, edit.DocumentChanges[1].Edits);
+ Assert.Contains(
+ "Counter\n\n\n",
+ replaced,
+ StringComparison.Ordinal
+ );
+
+ // `
` reads the component's field, which the new component could not see.
+ var status = Find(text, "
a.Title.StartsWith("Extract element", StringComparison.Ordinal)
+ );
+ }
+
+ [Fact]
+ public async Task Extraction_trims_the_selection_dedents_it_and_refuses_outside_locals()
+ {
+ using var manager = await _fixture.OpenManagerAsync();
+ var text =
+ await File.ReadAllTextAsync(Counter)
+ + "\n
\n \n
\n"
+ + "@foreach (var item in new[] { 1, 2 })\n{\n @item\n}\n"
+ + "@(new[] { 1 }.Select(n => n + 1).First())\n";
+ _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(Counter, text));
+
+ // The selection's surrounding whitespace is not part of it, and each line of the
+ // element loses the first line's indentation. [RAZOR-CODE-ACTIONS-COMPONENT]
+ var section = Find(text, " ");
+ var close = Find(text, "