diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToCodeBehindCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToCodeBehindCodeActionResolver.cs index 28268895c68..f5271385a47 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToCodeBehindCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToCodeBehindCodeActionResolver.cs @@ -2,10 +2,8 @@ // Licensed under the MIT license. See License.txt in the project root for license information. using System; -using System.Collections.Generic; using System.Globalization; using System.IO; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language; @@ -13,21 +11,22 @@ using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; using Microsoft.AspNetCore.Razor.LanguageServer.Common; using Microsoft.AspNetCore.Razor.LanguageServer.Extensions; +using Microsoft.AspNetCore.Razor.PooledObjects; using Microsoft.AspNetCore.Razor.Utilities; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; using Microsoft.CodeAnalysis.Razor; using Microsoft.CodeAnalysis.Razor.Workspaces; using Microsoft.VisualStudio.LanguageServer.Protocol; using Newtonsoft.Json.Linq; -using CSharpSyntaxFactory = Microsoft.CodeAnalysis.CSharp.SyntaxFactory; -using CSharpSyntaxKind = Microsoft.CodeAnalysis.CSharp.SyntaxKind; namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; internal sealed class ExtractToCodeBehindCodeActionResolver : IRazorCodeActionResolver { + private static readonly Workspace s_workspace = new AdhocWorkspace(); + private readonly DocumentContextFactory _documentContextFactory; private readonly LanguageServerFeatureOptions _languageServerFeatureOptions; @@ -170,20 +169,6 @@ private static string GenerateCodeBehindPath(string path) return codeBehindPath; } - /// - /// Determine all explicit and implicit using statements in the code - /// document using the intermediate node. - /// - /// The code document to analyze. - /// An enumerable of the qualified namespaces. - private static IEnumerable FindUsings(RazorCodeDocument razorCodeDocument) - { - return razorCodeDocument - .GetDocumentIntermediateNode() - .FindDescendantNodes() - .Select(n => n.Content); - } - /// /// Generate a complete C# compilation unit containing a partial class /// with the given name, body contents, and the namespace and all @@ -194,27 +179,43 @@ private static IEnumerable FindUsings(RazorCodeDocument razorCodeDocumen /// Class body contents. /// Existing code document we're extracting from. /// - private static string GenerateCodeBehindClass(string className, string namespaceName, string contents, RazorCodeDocument razorCodeDocument) + private string GenerateCodeBehindClass(string className, string namespaceName, string contents, RazorCodeDocument razorCodeDocument) { - var mock = (ClassDeclarationSyntax)CSharpSyntaxFactory.ParseMemberDeclaration($"class Class {contents}")!; - var @class = CSharpSyntaxFactory - .ClassDeclaration(className) - .AddModifiers(CSharpSyntaxFactory.Token(CSharpSyntaxKind.PublicKeyword), CSharpSyntaxFactory.Token(CSharpSyntaxKind.PartialKeyword)) - .AddMembers(mock.Members.ToArray()) - .WithCloseBraceToken(mock.CloseBraceToken); - - var @namespace = CSharpSyntaxFactory - .NamespaceDeclaration(CSharpSyntaxFactory.ParseName(namespaceName)) - .AddMembers(@class); - - var usings = FindUsings(razorCodeDocument) - .Select(u => CSharpSyntaxFactory.UsingDirective(CSharpSyntaxFactory.ParseName(u))) - .ToArray(); - var compilationUnit = CSharpSyntaxFactory - .CompilationUnit() - .AddUsings(usings) - .AddMembers(@namespace); - - return compilationUnit.NormalizeWhitespace().ToFullString(); + using var _ = StringBuilderPool.GetPooledObject(out var builder); + + var usingDirectives = razorCodeDocument + .GetDocumentIntermediateNode() + .FindDescendantNodes(); + 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(); + } + + builder.AppendLine(); + builder.Append("namespace "); + builder.AppendLine(namespaceName); + builder.Append('{'); + builder.AppendLine(); + builder.Append("public partial class "); + builder.AppendLine(className); + builder.AppendLine(contents); + builder.Append('}'); + + // Sadly we can't use a "real" workspace here, because we don't have access. If we use our workspace, it wouldn't have the right settings + // for C# formatting, only Razor formatting, and we have no access to Roslyn's real workspace, since it could be in another process. + // TODO: Rather than format here, call Roslyn via LSP to format, and remove and sort usings: https://github.com/dotnet/razor/issues/8766 + var node = CSharpSyntaxTree.ParseText(builder.ToString()).GetRoot(); + node = Formatter.Format(node, s_workspace); + + return node.ToFullString(); } } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToCodeBehindCodeActionResolverTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToCodeBehindCodeActionResolverTest.cs index 7ae774a5d0f..00caaecd14a 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToCodeBehindCodeActionResolverTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToCodeBehindCodeActionResolverTest.cs @@ -3,7 +3,6 @@ using System; using System.Linq; -using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; @@ -12,9 +11,8 @@ using Microsoft.AspNetCore.Razor.Test.Common; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Razor.Workspaces.Extensions; -using Microsoft.VisualStudio.LanguageServer.Protocol; -using Moq; using Newtonsoft.Json.Linq; +using Roslyn.Test.Utilities; using Xunit; using Xunit.Abstractions; @@ -57,7 +55,7 @@ public async Task Handle_Unsupported() { // Arrange var documentPath = new Uri("c:\\Test.razor"); - var contents = $"@page \"/test\"{Environment.NewLine}@code {{ private var x = 1; }}"; + var contents = $"@page \"/test\"{Environment.NewLine}@code {{ private int x = 1; }}"; var codeDocument = CreateCodeDocument(contents); codeDocument.SetUnsupported(); @@ -76,7 +74,7 @@ public async Task Handle_InvalidFileKind() { // Arrange var documentPath = new Uri("c:\\Test.razor"); - var contents = $"@page \"/test\"{Environment.NewLine}@code {{ private var x = 1; }}"; + var contents = $"@page \"/test\"{Environment.NewLine}@code {{ private int x = 1; }}"; var codeDocument = CreateCodeDocument(contents); codeDocument.SetFileKind(FileKinds.Legacy); @@ -95,7 +93,225 @@ public async Task Handle_ExtractCodeBlock() { // Arrange var documentPath = new Uri("c:/Test.razor"); - var contents = $"@page \"/test\"{Environment.NewLine}@code {{ private var x = 1; }}"; + var contents = """ + @page "/test" + + @code { + private int x = 1; + } + """; + var codeDocument = CreateCodeDocument(contents); + Assert.True(codeDocument.TryComputeNamespace(fallbackToRootNamespace: true, out var @namespace)); + + var resolver = new ExtractToCodeBehindCodeActionResolver(CreateDocumentContextFactory(documentPath, codeDocument), TestLanguageServerFeatureOptions.Instance); + var actionParams = CreateExtractToCodeBehindCodeActionParams(documentPath, contents, "@code", @namespace); + var data = JObject.FromObject(actionParams); + + // Act + var workspaceEdit = await resolver.ResolveAsync(data, default); + + // Assert + Assert.NotNull(workspaceEdit); + Assert.NotNull(workspaceEdit!.DocumentChanges); + Assert.Equal(3, workspaceEdit.DocumentChanges!.Value.Count()); + + var documentChanges = workspaceEdit.DocumentChanges!.Value.ToArray(); + var createFileChange = documentChanges[0]; + Assert.True(createFileChange.TryGetSecond(out var _)); + + var editCodeDocumentChange = documentChanges[1]; + Assert.True(editCodeDocumentChange.TryGetFirst(out var textDocumentEdit1)); + var editCodeDocumentEdit = textDocumentEdit1!.Edits.First(); + Assert.True(editCodeDocumentEdit.Range.Start.TryGetAbsoluteIndex(codeDocument.GetSourceText(), Logger, out var removeStart)); + Assert.Equal(actionParams.RemoveStart, removeStart); + Assert.True(editCodeDocumentEdit.Range.End.TryGetAbsoluteIndex(codeDocument.GetSourceText(), Logger, out var removeEnd)); + Assert.Equal(actionParams.RemoveEnd, removeEnd); + + var editCodeBehindChange = documentChanges[2]; + Assert.True(editCodeBehindChange.TryGetFirst(out var textDocumentEdit2)); + var editCodeBehindEdit = textDocumentEdit2!.Edits.First(); + + AssertEx.EqualOrDiff(""" + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Components; + + namespace test.Pages + { + public partial class Test + { + private int x = 1; + } + } + """, + editCodeBehindEdit.NewText); + } + + [Fact] + public async Task Handle_ExtractCodeBlock2() + { + // Arrange + var documentPath = new Uri("c:/Test.razor"); + var contents = """ + @page "/test" + + @code + { + private int x = 1; + } + """; + var codeDocument = CreateCodeDocument(contents); + Assert.True(codeDocument.TryComputeNamespace(fallbackToRootNamespace: true, out var @namespace)); + + var resolver = new ExtractToCodeBehindCodeActionResolver(CreateDocumentContextFactory(documentPath, codeDocument), TestLanguageServerFeatureOptions.Instance); + var actionParams = CreateExtractToCodeBehindCodeActionParams(documentPath, contents, "@code", @namespace); + var data = JObject.FromObject(actionParams); + + // Act + var workspaceEdit = await resolver.ResolveAsync(data, default); + + // Assert + Assert.NotNull(workspaceEdit); + Assert.NotNull(workspaceEdit!.DocumentChanges); + Assert.Equal(3, workspaceEdit.DocumentChanges!.Value.Count()); + + var documentChanges = workspaceEdit.DocumentChanges!.Value.ToArray(); + var createFileChange = documentChanges[0]; + Assert.True(createFileChange.TryGetSecond(out var _)); + + var editCodeDocumentChange = documentChanges[1]; + Assert.True(editCodeDocumentChange.TryGetFirst(out var textDocumentEdit1)); + var editCodeDocumentEdit = textDocumentEdit1!.Edits.First(); + Assert.True(editCodeDocumentEdit.Range.Start.TryGetAbsoluteIndex(codeDocument.GetSourceText(), Logger, out var removeStart)); + Assert.Equal(actionParams.RemoveStart, removeStart); + Assert.True(editCodeDocumentEdit.Range.End.TryGetAbsoluteIndex(codeDocument.GetSourceText(), Logger, out var removeEnd)); + Assert.Equal(actionParams.RemoveEnd, removeEnd); + + var editCodeBehindChange = documentChanges[2]; + Assert.True(editCodeBehindChange.TryGetFirst(out var textDocumentEdit2)); + var editCodeBehindEdit = textDocumentEdit2!.Edits.First(); + + AssertEx.EqualOrDiff(""" + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Components; + + namespace test.Pages + { + public partial class Test + { + private int x = 1; + } + } + """, + editCodeBehindEdit.NewText); + } + + [Fact] + public async Task Handle_ExtractCodeBlock_MultipleMembers() + { + // Arrange + var documentPath = new Uri("c:/Test.razor"); + var contents = """ + @page "/test" + + @code { + private int x = 1; + private int z = 2; + + private string y = "hello"; + + // Here is a comment + private void M() + { + // okay + } + } + """; + var codeDocument = CreateCodeDocument(contents); + Assert.True(codeDocument.TryComputeNamespace(fallbackToRootNamespace: true, out var @namespace)); + + var resolver = new ExtractToCodeBehindCodeActionResolver(CreateDocumentContextFactory(documentPath, codeDocument), TestLanguageServerFeatureOptions.Instance); + var actionParams = CreateExtractToCodeBehindCodeActionParams(documentPath, contents, "@code", @namespace); + var data = JObject.FromObject(actionParams); + + // Act + var workspaceEdit = await resolver.ResolveAsync(data, default); + + // Assert + Assert.NotNull(workspaceEdit); + Assert.NotNull(workspaceEdit!.DocumentChanges); + Assert.Equal(3, workspaceEdit.DocumentChanges!.Value.Count()); + + var documentChanges = workspaceEdit.DocumentChanges!.Value.ToArray(); + var createFileChange = documentChanges[0]; + Assert.True(createFileChange.TryGetSecond(out var _)); + + var editCodeDocumentChange = documentChanges[1]; + Assert.True(editCodeDocumentChange.TryGetFirst(out var textDocumentEdit1)); + var editCodeDocumentEdit = textDocumentEdit1!.Edits.First(); + Assert.True(editCodeDocumentEdit.Range.Start.TryGetAbsoluteIndex(codeDocument.GetSourceText(), Logger, out var removeStart)); + Assert.Equal(actionParams.RemoveStart, removeStart); + Assert.True(editCodeDocumentEdit.Range.End.TryGetAbsoluteIndex(codeDocument.GetSourceText(), Logger, out var removeEnd)); + Assert.Equal(actionParams.RemoveEnd, removeEnd); + + var editCodeBehindChange = documentChanges[2]; + Assert.True(editCodeBehindChange.TryGetFirst(out var textDocumentEdit2)); + var editCodeBehindEdit = textDocumentEdit2!.Edits.First(); + + AssertEx.EqualOrDiff(""" + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Components; + + namespace test.Pages + { + public partial class Test + { + private int x = 1; + private int z = 2; + + private string y = "hello"; + + // Here is a comment + private void M() + { + // okay + } + } + } + """, + editCodeBehindEdit.NewText); + } + + [Fact] + public async Task Handle_ExtractCodeBlock_MultipleMembers2() + { + // Arrange + var documentPath = new Uri("c:/Test.razor"); + var contents = """ + @page "/test" + + @code + { + private int x = 1; + private int z = 2; + + private string y = "hello"; + + // Here is a comment + private void M() + { + // okay + } + } + """; var codeDocument = CreateCodeDocument(contents); Assert.True(codeDocument.TryComputeNamespace(fallbackToRootNamespace: true, out var @namespace)); @@ -126,9 +342,114 @@ public async Task Handle_ExtractCodeBlock() var editCodeBehindChange = documentChanges[2]; Assert.True(editCodeBehindChange.TryGetFirst(out var textDocumentEdit2)); var editCodeBehindEdit = textDocumentEdit2!.Edits.First(); - Assert.Contains("public partial class Test", editCodeBehindEdit.NewText, StringComparison.Ordinal); - Assert.Contains("private var x = 1", editCodeBehindEdit.NewText, StringComparison.Ordinal); - Assert.Contains("namespace test.Pages", editCodeBehindEdit.NewText, StringComparison.Ordinal); + + AssertEx.EqualOrDiff(""" + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Components; + + namespace test.Pages + { + public partial class Test + { + private int x = 1; + private int z = 2; + + private string y = "hello"; + + // Here is a comment + private void M() + { + // okay + } + } + } + """, + editCodeBehindEdit.NewText); + } + + [Fact] + public async Task Handle_ExtractCodeBlock_MultipleMembers3() + { + // Arrange + var documentPath = new Uri("c:/Test.razor"); + var contents = """ + @page "/test" + +
+ @code + { + private int x = 1; + private int z = 2; + + private string y = "hello"; + + // Here is a comment + private void M() + { + // okay + } + } +
+ """; + var codeDocument = CreateCodeDocument(contents); + Assert.True(codeDocument.TryComputeNamespace(fallbackToRootNamespace: true, out var @namespace)); + + var resolver = new ExtractToCodeBehindCodeActionResolver(CreateDocumentContextFactory(documentPath, codeDocument), TestLanguageServerFeatureOptions.Instance); + var actionParams = CreateExtractToCodeBehindCodeActionParams(documentPath, contents, "@code", @namespace); + var data = JObject.FromObject(actionParams); + + // Act + var workspaceEdit = await resolver.ResolveAsync(data, default); + + // Assert + Assert.NotNull(workspaceEdit); + Assert.NotNull(workspaceEdit!.DocumentChanges); + Assert.Equal(3, workspaceEdit.DocumentChanges!.Value.Count()); + + var documentChanges = workspaceEdit.DocumentChanges!.Value.ToArray(); + var createFileChange = documentChanges[0]; + Assert.True(createFileChange.TryGetSecond(out var _)); + + var editCodeDocumentChange = documentChanges[1]; + Assert.True(editCodeDocumentChange.TryGetFirst(out var textDocumentEdit1)); + var editCodeDocumentEdit = textDocumentEdit1!.Edits.First(); + Assert.True(editCodeDocumentEdit.Range.Start.TryGetAbsoluteIndex(codeDocument.GetSourceText(), Logger, out var removeStart)); + Assert.Equal(actionParams.RemoveStart, removeStart); + Assert.True(editCodeDocumentEdit.Range.End.TryGetAbsoluteIndex(codeDocument.GetSourceText(), Logger, out var removeEnd)); + Assert.Equal(actionParams.RemoveEnd, removeEnd); + + var editCodeBehindChange = documentChanges[2]; + Assert.True(editCodeBehindChange.TryGetFirst(out var textDocumentEdit2)); + var editCodeBehindEdit = textDocumentEdit2!.Edits.First(); + + AssertEx.EqualOrDiff(""" + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Components; + + namespace test.Pages + { + public partial class Test + { + private int x = 1; + private int z = 2; + + private string y = "hello"; + + // Here is a comment + private void M() + { + // okay + } + } + } + """, + editCodeBehindEdit.NewText); } [Fact] @@ -136,7 +457,13 @@ public async Task Handle_ExtractFunctionsBlock() { // Arrange var documentPath = new Uri("c:/Test.razor"); - var contents = $"@page \"/test\"{Environment.NewLine}@functions {{ private var x = 1; }}"; + var contents = """ + @page "/test" + + @functions { + private int x = 1; + } + """; var codeDocument = CreateCodeDocument(contents); Assert.True(codeDocument.TryComputeNamespace(fallbackToRootNamespace: true, out var @namespace)); @@ -167,9 +494,23 @@ public async Task Handle_ExtractFunctionsBlock() var editCodeBehindChange = documentChanges[2]; Assert.True(editCodeBehindChange.TryGetFirst(out var editCodeBehind)); var editCodeBehindEdit = editCodeBehind!.Edits.First(); - Assert.Contains("public partial class Test", editCodeBehindEdit.NewText, StringComparison.Ordinal); - Assert.Contains("private var x = 1", editCodeBehindEdit.NewText, StringComparison.Ordinal); - Assert.Contains("namespace test.Pages", editCodeBehindEdit.NewText, StringComparison.Ordinal); + + AssertEx.EqualOrDiff(""" + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Components; + + namespace test.Pages + { + public partial class Test + { + private int x = 1; + } + } + """, + editCodeBehindEdit.NewText); } [Fact] @@ -177,7 +518,14 @@ public async Task Handle_ExtractCodeBlockWithUsing() { // Arrange var documentPath = new Uri("c:/Test.razor"); - var contents = $"@page \"/test\"\n@using System.Diagnostics{Environment.NewLine}@code {{ private var x = 1; }}"; + var contents = """ + @page "/test" + @using System.Diagnostics + + @code { + private int x = 1; + } + """; var codeDocument = CreateCodeDocument(contents); Assert.True(codeDocument.TryComputeNamespace(fallbackToRootNamespace: true, out var @namespace)); @@ -208,10 +556,24 @@ public async Task Handle_ExtractCodeBlockWithUsing() var editCodeBehindChange = documentChanges[2]; Assert.True(editCodeBehindChange.TryGetFirst(out var editCodeBehind)); var editCodeBehindEdit = editCodeBehind!.Edits.First(); - Assert.Contains("using System.Diagnostics", editCodeBehindEdit.NewText, StringComparison.Ordinal); - Assert.Contains("public partial class Test", editCodeBehindEdit.NewText, StringComparison.Ordinal); - Assert.Contains("private var x = 1", editCodeBehindEdit.NewText, StringComparison.Ordinal); - Assert.Contains("namespace test.Pages", editCodeBehindEdit.NewText, StringComparison.Ordinal); + + AssertEx.EqualOrDiff(""" + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Components; + using System.Diagnostics; + + namespace test.Pages + { + public partial class Test + { + private int x = 1; + } + } + """, + editCodeBehindEdit.NewText); } [Fact] @@ -219,7 +581,15 @@ public async Task Handle_ExtractCodeBlockWithDirectives() { // Arrange var documentPath = new Uri("c:/Test.razor"); - var contents = $"@page \"/test\"{Environment.NewLine}@code {{ {Environment.NewLine} #region TestRegion {Environment.NewLine} private var x = 1; {Environment.NewLine} #endregion {Environment.NewLine}}}"; + var contents = """ + @page "/test" + + @code { + #region TestRegion + private int x = 1; + #endregion + } + """; var codeDocument = CreateCodeDocument(contents); Assert.True(codeDocument.TryComputeNamespace(fallbackToRootNamespace: true, out var @namespace)); @@ -250,11 +620,25 @@ public async Task Handle_ExtractCodeBlockWithDirectives() var editCodeBehindChange = documentChanges[2]; Assert.True(editCodeBehindChange.TryGetFirst(out var textDocumentEdit2)); var editCodeBehindEdit = textDocumentEdit2!.Edits.First(); - Assert.Contains("public partial class Test", editCodeBehindEdit.NewText, StringComparison.Ordinal); - Assert.Contains("#region TestRegion", editCodeBehindEdit.NewText, StringComparison.Ordinal); - Assert.Contains("private var x = 1", editCodeBehindEdit.NewText, StringComparison.Ordinal); - Assert.Contains("#endregion", editCodeBehindEdit.NewText, StringComparison.Ordinal); - Assert.Contains("namespace test.Pages", editCodeBehindEdit.NewText, StringComparison.Ordinal); + + AssertEx.EqualOrDiff(""" + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Components; + + namespace test.Pages + { + public partial class Test + { + #region TestRegion + private int x = 1; + #endregion + } + } + """, + editCodeBehindEdit.NewText); } private static RazorCodeDocument CreateCodeDocument(string text) @@ -271,7 +655,7 @@ private static RazorCodeDocument CreateCodeDocument(string text) private static ExtractToCodeBehindCodeActionParams CreateExtractToCodeBehindCodeActionParams(Uri uri, string contents, string removeStart, string @namespace) { // + 1 to ensure we do not cut off the '}'. - var endIndex = contents.IndexOf("}", StringComparison.Ordinal) + 1; + var endIndex = contents.LastIndexOf("}", StringComparison.Ordinal) + 1; return new ExtractToCodeBehindCodeActionParams { Uri = uri,