diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer.Common/LanguageServerConstants.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer.Common/LanguageServerConstants.cs index d13de329ce9..11966ab5276 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer.Common/LanguageServerConstants.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer.Common/LanguageServerConstants.cs @@ -26,5 +26,14 @@ public static class LanguageServerConstants public const string RazorMapToDocumentRangesEndpoint = "razor/mapToDocumentRanges"; public const string SemanticTokensProviderName = "semanticTokensProvider"; + + public const string RazorCodeActionRunnerCommand = "razor/runCodeAction"; + + public const string RazorCodeActionResolutionEndpoint = "razor/resolveCodeAction"; + + public static class CodeActions + { + public const string ExtractToCodeBehindAction = "ExtractToCodeBehind"; + } } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CodeActionEndpoint.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CodeActionEndpoint.cs new file mode 100644 index 00000000000..414968d7661 --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CodeActionEndpoint.cs @@ -0,0 +1,116 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.LanguageServer.Common; +using Microsoft.AspNetCore.Razor.LanguageServer.ProjectSystem; +using Microsoft.CodeAnalysis.Razor; +using Microsoft.CodeAnalysis.Text; +using Microsoft.Extensions.Logging; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; + +namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions +{ + internal class CodeActionEndpoint : ICodeActionHandler + { + private readonly IEnumerable _providers; + private readonly ForegroundDispatcher _foregroundDispatcher; + private readonly DocumentResolver _documentResolver; + private readonly ILogger _logger; + + private CodeActionCapability _capability; + + public CodeActionEndpoint( + IEnumerable providers, + ForegroundDispatcher foregroundDispatcher, + DocumentResolver documentResolver, + ILoggerFactory loggerFactory) + { + if (loggerFactory is null) + { + throw new ArgumentNullException(nameof(loggerFactory)); + } + + _providers = providers ?? throw new ArgumentNullException(nameof(providers)); + _foregroundDispatcher = foregroundDispatcher ?? throw new ArgumentNullException(nameof(foregroundDispatcher)); + _documentResolver = documentResolver ?? throw new ArgumentNullException(nameof(documentResolver)); + _logger = loggerFactory.CreateLogger(); + } + + public CodeActionRegistrationOptions GetRegistrationOptions() + { + return new CodeActionRegistrationOptions() + { + DocumentSelector = RazorDefaults.Selector + }; + } + + public async Task Handle(CodeActionParams request, CancellationToken cancellationToken) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + var document = await Task.Factory.StartNew(() => + { + _documentResolver.TryResolveDocument(request.TextDocument.Uri.GetAbsoluteOrUNCPath(), out var documentSnapshot); + return documentSnapshot; + }, cancellationToken, TaskCreationOptions.None, _foregroundDispatcher.ForegroundScheduler).ConfigureAwait(false); + + if (document is null) + { + return null; + } + + var codeDocument = await document.GetGeneratedOutputAsync().ConfigureAwait(false); + if (codeDocument.IsUnsupported()) + { + return null; + } + + var sourceText = await document.GetTextAsync().ConfigureAwait(false); + var linePosition = new LinePosition((int)request.Range.Start.Line, (int)request.Range.Start.Character); + var hostDocumentIndex = sourceText.Lines.GetPosition(linePosition); + var location = new SourceLocation(hostDocumentIndex, (int)request.Range.Start.Line, (int)request.Range.Start.Character); + + var context = new RazorCodeActionContext(request, codeDocument, location); + var tasks = new List>(); + + foreach (var provider in _providers) + { + var result = provider.ProvideAsync(context, cancellationToken); + if (result != null) + { + tasks.Add(result); + } + } + + var results = await Task.WhenAll(tasks).ConfigureAwait(false); + var container = new List(); + foreach (var result in results) + { + if (result != null) + { + foreach (var commandOrCodeAction in result) + { + container.Add(commandOrCodeAction); + } + } + } + + return new CommandOrCodeActionContainer(container); + } + + public void SetCapability(CodeActionCapability capability) + { + _capability = capability; + } + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CodeActionResolutionEndpoint.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CodeActionResolutionEndpoint.cs new file mode 100644 index 00000000000..c73fc748c9e --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CodeActionResolutionEndpoint.cs @@ -0,0 +1,65 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions +{ + internal class CodeActionResolutionEndpoint : IRazorCodeActionResolutionHandler + { + private readonly IReadOnlyDictionary _resolvers; + private readonly ILogger _logger; + + public CodeActionResolutionEndpoint( + IEnumerable resolvers, + ILoggerFactory loggerFactory) + { + if (loggerFactory is null) + { + throw new ArgumentNullException(nameof(loggerFactory)); + } + + _logger = loggerFactory.CreateLogger(); + + if (resolvers is null) + { + throw new ArgumentNullException(nameof(resolvers)); + } + + var resolverMap = new Dictionary(); + foreach (var resolver in resolvers) + { + if (resolverMap.ContainsKey(resolver.Action)) + { + Debug.Fail($"Duplicate resolver action for {resolver.Action}."); + } + resolverMap[resolver.Action] = resolver; + } + _resolvers = resolverMap; + } + + public async Task Handle(RazorCodeActionResolutionParams request, CancellationToken cancellationToken) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + _logger.LogDebug($"Resolving action {request.Action} with data {request.Data}."); + + if (!_resolvers.TryGetValue(request.Action, out var resolver)) + { + Debug.Fail($"No resolver registered for {request.Action}."); + return new RazorCodeActionResolutionResponse(); + } + + var edit = await resolver.ResolveAsync(request.Data, cancellationToken).ConfigureAwait(false); + return new RazorCodeActionResolutionResponse() { Edit = edit }; + } + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/ExtractToCodeBehindCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/ExtractToCodeBehindCodeActionProvider.cs new file mode 100644 index 00000000000..580dbd7eb7e --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/ExtractToCodeBehindCodeActionProvider.cs @@ -0,0 +1,115 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Components; +using Microsoft.AspNetCore.Razor.Language.Extensions; +using Microsoft.AspNetCore.Razor.Language.Syntax; +using Microsoft.AspNetCore.Razor.Language.Legacy; +using Microsoft.AspNetCore.Razor.LanguageServer.Common; +using Newtonsoft.Json.Linq; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions +{ + internal class ExtractToCodeBehindCodeActionProvider : RazorCodeActionProvider + { + private static readonly Task EmptyResult = Task.FromResult(null); + + override public Task ProvideAsync(RazorCodeActionContext context, CancellationToken cancellationToken) + { + if (context is null) + { + return EmptyResult; + } + + if (!FileKinds.IsComponent(context.Document.GetFileKind())) + { + return EmptyResult; + } + + var change = new SourceChange(context.Location.AbsoluteIndex, length: 0, newText: string.Empty); + var syntaxTree = context.Document.GetSyntaxTree(); + if (syntaxTree?.Root is null) + { + return EmptyResult; + } + + var owner = syntaxTree.Root.LocateOwner(change); + var node = owner.Ancestors().FirstOrDefault(n => n.Kind == SyntaxKind.RazorDirective); + if (node == null || !(node is RazorDirectiveSyntax directiveNode)) + { + return EmptyResult; + } + + // Make sure we've found a @code or @functions + if (directiveNode.DirectiveDescriptor != ComponentCodeDirective.Directive && directiveNode.DirectiveDescriptor != FunctionsDirective.Directive) + { + return EmptyResult; + } + + // No code action if malformed + if (directiveNode.GetDiagnostics().Any(d => d.Severity == RazorDiagnosticSeverity.Error)) + { + return EmptyResult; + } + + var cSharpCodeBlockNode = directiveNode.Body.DescendantNodes().FirstOrDefault(n => n is CSharpCodeBlockSyntax); + if (cSharpCodeBlockNode is null) + { + return EmptyResult; + } + + if (HasUnsupportedChildren(cSharpCodeBlockNode)) + { + return EmptyResult; + } + + // Do not provide code action if the cursor is inside the code block + if (context.Location.AbsoluteIndex > cSharpCodeBlockNode.SpanStart) + { + return EmptyResult; + } + + var actionParams = new ExtractToCodeBehindParams() + { + Uri = context.Request.TextDocument.Uri, + ExtractStart = cSharpCodeBlockNode.Span.Start, + ExtractEnd = cSharpCodeBlockNode.Span.End, + RemoveStart = directiveNode.Span.Start, + RemoveEnd = directiveNode.Span.End + }; + var data = JObject.FromObject(actionParams); + + var resolutionParams = new RazorCodeActionResolutionParams() + { + Action = LanguageServerConstants.CodeActions.ExtractToCodeBehindAction, + Data = data, + }; + var serializedParams = JToken.FromObject(resolutionParams); + var arguments = new JArray(serializedParams); + + var container = new List + { + new Command() + { + Title = "Extract block to code behind", + Name = LanguageServerConstants.RazorCodeActionRunnerCommand, + Arguments = arguments, + } + }; + + return Task.FromResult((CommandOrCodeActionContainer)container); + } + + private static bool HasUnsupportedChildren(Language.Syntax.SyntaxNode node) + { + return node.DescendantNodes().Any(n => n is MarkupBlockSyntax || n is CSharpTransitionSyntax || n is RazorCommentBlockSyntax); + } + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/ExtractToCodeBehindCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/ExtractToCodeBehindCodeActionResolver.cs new file mode 100644 index 00000000000..c5f514e5a33 --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/ExtractToCodeBehindCodeActionResolver.cs @@ -0,0 +1,207 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Intermediate; +using Microsoft.AspNetCore.Razor.LanguageServer.Common; +using Microsoft.AspNetCore.Razor.LanguageServer.ProjectSystem; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Razor; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using CSharpSyntaxFactory = Microsoft.CodeAnalysis.CSharp.SyntaxFactory; +using CSharpSyntaxKind = Microsoft.CodeAnalysis.CSharp.SyntaxKind; + +namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions +{ + internal class ExtractToCodeBehindCodeActionResolver : RazorCodeActionResolver + { + private readonly ForegroundDispatcher _foregroundDispatcher; + private readonly DocumentResolver _documentResolver; + private readonly FilePathNormalizer _filePathNormalizer; + + private static readonly Range StartOfDocumentRange = new Range(new Position(0, 0), new Position(0, 0)); + + public ExtractToCodeBehindCodeActionResolver( + ForegroundDispatcher foregroundDispatcher, + DocumentResolver documentResolver, + FilePathNormalizer filePathNormalizer) + { + _foregroundDispatcher = foregroundDispatcher ?? throw new ArgumentNullException(nameof(foregroundDispatcher)); + _documentResolver = documentResolver ?? throw new ArgumentNullException(nameof(documentResolver)); + _filePathNormalizer = filePathNormalizer ?? throw new ArgumentNullException(nameof(filePathNormalizer)); + } + + public override string Action => LanguageServerConstants.CodeActions.ExtractToCodeBehindAction; + + public override async Task ResolveAsync(JObject data, CancellationToken cancellationToken) + { + var actionParams = data.ToObject(); + var path = _filePathNormalizer.Normalize(actionParams.Uri.GetAbsoluteOrUNCPath()); + + var document = await Task.Factory.StartNew(() => + { + _documentResolver.TryResolveDocument(path, out var documentSnapshot); + return documentSnapshot; + }, cancellationToken, TaskCreationOptions.None, _foregroundDispatcher.ForegroundScheduler).ConfigureAwait(false); + if (document is null) + { + return null; + } + + var codeDocument = await document.GetGeneratedOutputAsync().ConfigureAwait(false); + if (codeDocument.IsUnsupported()) + { + return null; + } + + if (!FileKinds.IsComponent(codeDocument.GetFileKind())) + { + return null; + } + + var codeBehindPath = GenerateCodeBehindPath(path); + var codeBehindUri = new UriBuilder + { + Scheme = Uri.UriSchemeFile, + Path = codeBehindPath, + Host = string.Empty, + }.Uri; + + var text = await document.GetTextAsync().ConfigureAwait(false); + if (text is null) + { + return null; + } + + var className = Path.GetFileNameWithoutExtension(path); + var codeBlockContent = text.GetSubTextString(new CodeAnalysis.Text.TextSpan(actionParams.ExtractStart, actionParams.ExtractEnd - actionParams.ExtractStart)); + var codeBehindContent = GenerateCodeBehindClass(className, codeBlockContent, codeDocument); + + var start = codeDocument.Source.Lines.GetLocation(actionParams.RemoveStart); + var end = codeDocument.Source.Lines.GetLocation(actionParams.RemoveEnd); + var removeRange = new Range( + new Position(start.LineIndex, start.CharacterIndex), + new Position(end.LineIndex, end.CharacterIndex)); + + var codeDocumentIdentifier = new VersionedTextDocumentIdentifier { Uri = actionParams.Uri }; + var codeBehindDocumentIdentifier = new VersionedTextDocumentIdentifier { Uri = codeBehindUri }; + + var documentChanges = new List + { + new WorkspaceEditDocumentChange(new CreateFile { Uri = codeBehindUri.ToString() }), + new WorkspaceEditDocumentChange(new TextDocumentEdit + { + TextDocument = codeDocumentIdentifier, + Edits = new[] + { + new TextEdit + { + NewText = string.Empty, + Range = removeRange, + } + }, + }), + new WorkspaceEditDocumentChange(new TextDocumentEdit + { + TextDocument = codeBehindDocumentIdentifier, + Edits = new[] + { + new TextEdit + { + NewText = codeBehindContent, + Range = StartOfDocumentRange, + } + }, + }) + }; + + return new WorkspaceEdit + { + DocumentChanges = documentChanges, + }; + } + + /// + /// Generate a file path with adjacent to our input path that has the + /// correct codebehind extension, using numbers to differentiate from + /// any collisions. + /// + /// The origin file path. + /// A non-existent file path with the same base name and a codebehind extension. + private string GenerateCodeBehindPath(string path) + { + var n = 0; + string codeBehindPath; + do + { + var identifier = n > 0 ? n.ToString() : string.Empty; // Make it look nice + codeBehindPath = Path.Combine( + Path.GetDirectoryName(path), + $"{Path.GetFileNameWithoutExtension(path)}{identifier}{Path.GetExtension(path)}.cs"); + n++; + } while (File.Exists(codeBehindPath)); + 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 + /// usings from the existing code document. + /// + /// Name of the resultant partial class. + /// Class body contents. + /// Existing code document we're extracting from. + /// + private static string GenerateCodeBehindClass(string className, string contents, RazorCodeDocument razorCodeDocument) + { + var namespaceNode = (NamespaceDeclarationIntermediateNode)razorCodeDocument + .GetDocumentIntermediateNode() + .FindDescendantNodes() + .FirstOrDefault(n => n is NamespaceDeclarationIntermediateNode); + + 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()); + + var @namespace = CSharpSyntaxFactory + .NamespaceDeclaration(CSharpSyntaxFactory.ParseName(namespaceNode.Content)) + .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(); + } + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/ExtractToCodeBehindParams.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/ExtractToCodeBehindParams.cs new file mode 100644 index 00000000000..e420f12ee0b --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/ExtractToCodeBehindParams.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions +{ + internal class ExtractToCodeBehindParams + { + public Uri Uri { get; set; } + public int ExtractStart { get; set; } + public int ExtractEnd { get; set; } + public int RemoveStart { get; set; } + public int RemoveEnd { get; set; } + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/RazorCodeActionContext.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/RazorCodeActionContext.cs new file mode 100644 index 00000000000..a8820180b31 --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/RazorCodeActionContext.cs @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Razor.Language; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions +{ + internal sealed class RazorCodeActionContext + { + public RazorCodeActionContext(CodeActionParams request, RazorCodeDocument document, SourceLocation location) + { + Request = request ?? throw new ArgumentNullException(nameof(request)); + Document = document ?? throw new ArgumentNullException(nameof(document)); + Location = location; + } + + public CodeActionParams Request { get; } + public RazorCodeDocument Document { get; } + public SourceLocation Location { get; } + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/RazorCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/RazorCodeActionProvider.cs new file mode 100644 index 00000000000..010f4047974 --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/RazorCodeActionProvider.cs @@ -0,0 +1,14 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading; +using System.Threading.Tasks; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions +{ + internal abstract class RazorCodeActionProvider + { + public abstract Task ProvideAsync(RazorCodeActionContext context, CancellationToken cancellationToken); + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/RazorCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/RazorCodeActionResolver.cs new file mode 100644 index 00000000000..2cfcb191f30 --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/RazorCodeActionResolver.cs @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions +{ + internal abstract class RazorCodeActionResolver + { + public abstract string Action { get; } + + public abstract Task ResolveAsync(JObject data, CancellationToken cancellationToken); + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/IRazorCodeActionResolutionHandler.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/IRazorCodeActionResolutionHandler.cs new file mode 100644 index 00000000000..599e584c782 --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/IRazorCodeActionResolutionHandler.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Razor.LanguageServer.Common; +using OmniSharp.Extensions.JsonRpc; + +namespace Microsoft.AspNetCore.Razor.LanguageServer +{ + [Serial, Method(LanguageServerConstants.RazorCodeActionResolutionEndpoint)] + internal interface IRazorCodeActionResolutionHandler : IJsonRpcRequestHandler + { + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorCodeActionResolutionParams.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorCodeActionResolutionParams.cs new file mode 100644 index 00000000000..753e85f405a --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorCodeActionResolutionParams.cs @@ -0,0 +1,14 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using MediatR; +using Newtonsoft.Json.Linq; + +namespace Microsoft.AspNetCore.Razor.LanguageServer +{ + internal class RazorCodeActionResolutionParams : IRequest + { + public string Action { get; set; } + public JObject Data { get; set; } + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorCodeActionResolutionResponse.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorCodeActionResolutionResponse.cs new file mode 100644 index 00000000000..c5cdd6b9aef --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorCodeActionResolutionResponse.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.AspNetCore.Razor.LanguageServer +{ + internal class RazorCodeActionResolutionResponse + { + public WorkspaceEdit Edit { get; set; } + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorLanguageServer.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorLanguageServer.cs index ad2acf2790c..39a07e17058 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorLanguageServer.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorLanguageServer.cs @@ -15,6 +15,7 @@ using Microsoft.AspNetCore.Razor.LanguageServer.Hover; using Microsoft.AspNetCore.Razor.LanguageServer.ProjectSystem; using Microsoft.AspNetCore.Razor.LanguageServer.Semantic; +using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; using Microsoft.CodeAnalysis.Razor; using Microsoft.CodeAnalysis.Razor.Completion; using Microsoft.CodeAnalysis.Razor.ProjectSystem; @@ -80,6 +81,8 @@ public static Task CreateAsync(Stream input, Stream output, Tra .WithHandler() .WithHandler() .WithHandler() + .WithHandler() + .WithHandler() .WithServices(services => { var filePathNormalizer = new FilePathNormalizer(); @@ -156,6 +159,10 @@ public static Task CreateAsync(Stream input, Stream output, Tra services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + + // Code actions + services.AddSingleton(); + services.AddSingleton(); })); try diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/CodeActions/RazorCodeActionRunner.ts b/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/CodeActions/RazorCodeActionRunner.ts new file mode 100644 index 00000000000..62f2815b820 --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/CodeActions/RazorCodeActionRunner.ts @@ -0,0 +1,38 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ + +import * as vscode from 'vscode'; +import { RazorLanguageServerClient } from '../RazorLanguageServerClient'; +import { RazorLogger } from '../RazorLogger'; +import { CodeActionResolutionRequest } from '../RPC/CodeActionResolutionRequest'; +import { CodeActionResolutionResponse } from '../RPC/CodeActionResolutionResponse'; +import { convertWorkspaceEditFromSerializable } from '../RPC/SerializableWorkspaceEdit'; + +export class RazorCodeActionRunner { + + constructor( + private readonly serverClient: RazorLanguageServerClient, + private readonly logger: RazorLogger, + ) {} + + public register(): vscode.Disposable { + return vscode.commands.registerCommand('razor/runCodeAction', (request: CodeActionResolutionRequest) => this.runCodeAction(request), this); + } + + private async runCodeAction(request: CodeActionResolutionRequest): Promise { + const response: CodeActionResolutionResponse = await this.serverClient.sendRequest('razor/resolveCodeAction', {Action: request.Action, Data: request.Data}); + let changesWorkspaceEdit: vscode.WorkspaceEdit; + let documentChangesWorkspaceEdit: vscode.WorkspaceEdit; + this.logger.logAlways(`Received response ${JSON.stringify(response)}`); + try { + changesWorkspaceEdit = convertWorkspaceEditFromSerializable({changes: response.edit.changes}); + documentChangesWorkspaceEdit = convertWorkspaceEditFromSerializable({documentChanges: response.edit.documentChanges}); + } catch (error) { + this.logger.logError(`Unexpected error deserializing code action for ${request.Action}`, error); + return Promise.resolve(false); + } + return vscode.workspace.applyEdit(documentChangesWorkspaceEdit).then(() => vscode.workspace.applyEdit(changesWorkspaceEdit)); + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/RPC/CodeActionResolutionRequest.ts b/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/RPC/CodeActionResolutionRequest.ts new file mode 100644 index 00000000000..5097f91f0f0 --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/RPC/CodeActionResolutionRequest.ts @@ -0,0 +1,9 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ + +export interface CodeActionResolutionRequest { + Action: string; + Data: object; +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/RPC/CodeActionResolutionResponse.ts b/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/RPC/CodeActionResolutionResponse.ts new file mode 100644 index 00000000000..e512fbc5747 --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/RPC/CodeActionResolutionResponse.ts @@ -0,0 +1,10 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ + +import { SerializableWorkspaceEdit } from './SerializableWorkspaceEdit'; + +export interface CodeActionResolutionResponse { + edit: SerializableWorkspaceEdit; +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/RPC/SerializableCreateDocument.ts b/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/RPC/SerializableCreateDocument.ts new file mode 100644 index 00000000000..52488d841ce --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/RPC/SerializableCreateDocument.ts @@ -0,0 +1,13 @@ +/* -------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.txt in the project root for license information. +* ------------------------------------------------------------------------------------------ */ + +export interface SerializableCreateDocument { + kind: 'create'; + uri: string; + options: { + overwrite: boolean; + ignoreIfExists: boolean; + }; +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/RPC/SerializableDeleteDocument.ts b/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/RPC/SerializableDeleteDocument.ts new file mode 100644 index 00000000000..4f54f2b4a51 --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/RPC/SerializableDeleteDocument.ts @@ -0,0 +1,13 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ + + export interface SerializableDeleteDocument { + kind: 'delete'; + uri: string; + options: { + recursive: boolean; + ignoreIfNotExists: boolean; + }; +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/RPC/SerializableRenameDocument.ts b/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/RPC/SerializableRenameDocument.ts new file mode 100644 index 00000000000..01ad8c5ae1e --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/RPC/SerializableRenameDocument.ts @@ -0,0 +1,14 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ + + export interface SerializableRenameDocument { + kind: 'rename'; + oldUri: string; + newUri: string; + options: { + overwrite: boolean; + ignoreIfExists: boolean; + }; +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/RPC/SerializableTextDocumentEdit.ts b/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/RPC/SerializableTextDocumentEdit.ts new file mode 100644 index 00000000000..7e2fd99bcc6 --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/RPC/SerializableTextDocumentEdit.ts @@ -0,0 +1,15 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ + +import { SerializableTextEdit } from './SerializableTextEdit'; + +export interface SerializableTextDocumentEdit { + kind: undefined; + textDocument: { + uri: string; + version: number; + }; + edits: Array; +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/RPC/SerializableWorkspaceEdit.ts b/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/RPC/SerializableWorkspaceEdit.ts new file mode 100644 index 00000000000..f46fad0a111 --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/RPC/SerializableWorkspaceEdit.ts @@ -0,0 +1,49 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ + +import * as vscode from 'vscode'; +import { SerializableCreateDocument } from './SerializableCreateDocument'; +import { SerializableDeleteDocument } from './SerializableDeleteDocument'; +import { SerializableRenameDocument } from './SerializableRenameDocument'; +import { SerializableTextDocumentEdit } from './SerializableTextDocumentEdit'; +import { convertTextEditFromSerializable, SerializableTextEdit } from './SerializableTextEdit'; + +type SerializableDocumentChange = SerializableCreateDocument | SerializableRenameDocument | SerializableDeleteDocument | SerializableTextDocumentEdit; + +export interface SerializableWorkspaceEdit { + changes?: {[key: string]: Array}; + documentChanges?: Array; +} + +export function convertWorkspaceEditFromSerializable(data: SerializableWorkspaceEdit): vscode.WorkspaceEdit { + const workspaceEdit = new vscode.WorkspaceEdit(); + + if (Array.isArray(data.documentChanges)) { + for (const documentChange of data.documentChanges) { + if (documentChange.kind === 'create') { + workspaceEdit.createFile(vscode.Uri.parse(documentChange.uri), documentChange.options); + } else if (documentChange.kind === 'rename') { + workspaceEdit.renameFile(vscode.Uri.parse(documentChange.oldUri), vscode.Uri.parse(documentChange.newUri), documentChange.options); + } else if (documentChange.kind === 'delete') { + workspaceEdit.deleteFile(vscode.Uri.parse(documentChange.uri), documentChange.options); + } else { + const changes = documentChange.edits.map(convertTextEditFromSerializable); + workspaceEdit.set(vscode.Uri.parse(documentChange.textDocument.uri), changes); + } + } + } + + if (data.changes !== undefined) { + for (const uri in data.changes) { + if (!data.changes.hasOwnProperty(uri)) { + continue; + } + const changes = data.changes[uri].map(convertTextEditFromSerializable); + workspaceEdit.set(vscode.Uri.parse(uri), changes); + } + } + + return workspaceEdit; +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/extension.ts b/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/extension.ts index 9cf13d824d8..f4f1c2c72fc 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/extension.ts +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.VSCode/src/extension.ts @@ -9,6 +9,7 @@ import { ExtensionContext } from 'vscode'; import { BlazorDebugConfigurationProvider } from './BlazorDebug/BlazorDebugConfigurationProvider'; import { CompositeCodeActionTranslator } from './CodeActions/CompositeRazorCodeActionTranslator'; import { RazorCodeActionProvider } from './CodeActions/RazorCodeActionProvider'; +import { RazorCodeActionRunner } from './CodeActions/RazorCodeActionRunner'; import { RazorFullyQualifiedCodeActionTranslator } from './CodeActions/RazorFullyQualifiedCodeActionTranslator'; import { listenToConfigurationChanges } from './ConfigurationChangeListener'; import { RazorCSharpFeature } from './CSharp/RazorCSharpFeature'; @@ -70,6 +71,7 @@ export async function activate(vscodeType: typeof vscodeapi, context: ExtensionC const localRegistrations: vscode.Disposable[] = []; const reportIssueCommand = new ReportIssueCommand(vscodeType, documentManager, logger); const razorFormattingFeature = new RazorFormattingFeature(languageServerClient, documentManager, logger); + const razorCodeActionRunner = new RazorCodeActionRunner(languageServerClient, logger); const onStartRegistration = languageServerClient.onStart(async () => { vscodeType.commands.executeCommand('omnisharp.registerLanguageMiddleware', razorLanguageMiddleware); @@ -183,6 +185,7 @@ export async function activate(vscodeType: typeof vscodeapi, context: ExtensionC } razorFormattingFeature.register(); + razorCodeActionRunner.register(); }); const onStopRegistration = languageServerClient.onStop(() => { diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndpointTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndpointTest.cs new file mode 100644 index 00000000000..d4377c66615 --- /dev/null +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndpointTest.cs @@ -0,0 +1,233 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using System.Threading; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.LanguageServer.Common; +using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; +using Microsoft.AspNetCore.Razor.LanguageServer.ProjectSystem; +using Microsoft.AspNetCore.Razor.Test.Common; +using Microsoft.CodeAnalysis.Text; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; +using Moq; +using Xunit; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; + +namespace Microsoft.AspNetCore.Razor.LanguageServer.Test.CodeActions +{ + public class CodeActionEndpointTest : LanguageServerTestBase + { + private readonly DocumentResolver EmptyDocumentResolver = Mock.Of(); + + [Fact] + public async Task Handle_NoDocument() + { + // Arrange + var documentPath = "C:/path/to/Page.razor"; + var codeActionEndpoint = new CodeActionEndpoint(new RazorCodeActionProvider[] { }, Dispatcher, EmptyDocumentResolver, LoggerFactory); + var request = new CodeActionParams() + { + TextDocument = new TextDocumentIdentifier(new Uri(documentPath)), + Range = new Range(new Position(0, 1), new Position(0, 1)), + }; + + // Act + var commandOrCodeActionContainer = await codeActionEndpoint.Handle(request, default); + + // Assert + Assert.Null(commandOrCodeActionContainer); + } + + [Fact] + public async Task Handle_UnsupportedDocument() + { + // Arrange + var documentPath = "C:/path/to/Page.razor"; + var codeDocument = CreateCodeDocument("@code {}"); + var documentResolver = CreateDocumentResolver(documentPath, codeDocument); + codeDocument.SetUnsupported(); + var codeActionEndpoint = new CodeActionEndpoint(new RazorCodeActionProvider[] { }, Dispatcher, documentResolver, LoggerFactory); + var request = new CodeActionParams() + { + TextDocument = new TextDocumentIdentifier(new Uri(documentPath)), + Range = new Range(new Position(0, 1), new Position(0, 1)), + }; + + // Act + var commandOrCodeActionContainer = await codeActionEndpoint.Handle(request, default); + + // Assert + Assert.Null(commandOrCodeActionContainer); + } + + [Fact] + public async Task Handle_NoProviders() + { + // Arrange + var documentPath = "C:/path/to/Page.razor"; + var codeDocument = CreateCodeDocument("@code {}"); + var documentResolver = CreateDocumentResolver(documentPath, codeDocument); + var codeActionEndpoint = new CodeActionEndpoint(new RazorCodeActionProvider[] { }, Dispatcher, documentResolver, LoggerFactory); + var request = new CodeActionParams() + { + TextDocument = new TextDocumentIdentifier(new Uri(documentPath)), + Range = new Range(new Position(0, 1), new Position(0, 1)), + }; + + // Act + var commandOrCodeActionContainer = await codeActionEndpoint.Handle(request, default); + + // Assert + Assert.Empty(commandOrCodeActionContainer); + } + + [Fact] + public async Task Handle_OneProvider() + { + // Arrange + var documentPath = "C:/path/to/Page.razor"; + var codeDocument = CreateCodeDocument("@code {}"); + var documentResolver = CreateDocumentResolver(documentPath, codeDocument); + var codeActionEndpoint = new CodeActionEndpoint(new RazorCodeActionProvider[] { + new MockCodeActionProvider() + }, Dispatcher, documentResolver, LoggerFactory); + + var request = new CodeActionParams() + { + TextDocument = new TextDocumentIdentifier(new Uri(documentPath)), + Range = new Range(new Position(0, 1), new Position(0, 1)), + }; + + // Act + var commandOrCodeActionContainer = await codeActionEndpoint.Handle(request, default); + + // Assert + Assert.Single(commandOrCodeActionContainer); + } + + + [Fact] + public async Task Handle_MultipleProviders() + { + // Arrange + var documentPath = "C:/path/to/Page.razor"; + var codeDocument = CreateCodeDocument("@code {}"); + var documentResolver = CreateDocumentResolver(documentPath, codeDocument); + var codeActionEndpoint = new CodeActionEndpoint(new RazorCodeActionProvider[] { + new MockCodeActionProvider(), + new MockCodeActionProvider(), + new MockCodeActionProvider(), + }, Dispatcher, documentResolver, LoggerFactory); + + var request = new CodeActionParams() + { + TextDocument = new TextDocumentIdentifier(new Uri(documentPath)), + Range = new Range(new Position(0, 1), new Position(0, 1)), + }; + + // Act + var commandOrCodeActionContainer = await codeActionEndpoint.Handle(request, default); + + // Assert + Assert.Equal(3, commandOrCodeActionContainer.Count()); + } + + [Fact] + public async Task Handle_OneNullReturningProvider() + { + // Arrange + var documentPath = "C:/path/to/Page.razor"; + var codeDocument = CreateCodeDocument("@code {}"); + var documentResolver = CreateDocumentResolver(documentPath, codeDocument); + var codeActionEndpoint = new CodeActionEndpoint(new RazorCodeActionProvider[] { + new NullMockCodeActionProvider() + }, Dispatcher, documentResolver, LoggerFactory); + + var request = new CodeActionParams() + { + TextDocument = new TextDocumentIdentifier(new Uri(documentPath)), + Range = new Range(new Position(0, 1), new Position(0, 1)), + }; + + // Act + var commandOrCodeActionContainer = await codeActionEndpoint.Handle(request, default); + + // Assert + Assert.Empty(commandOrCodeActionContainer); + } + + [Fact] + public async Task Handle_MultipleMixedProvider() + { + // Arrange + var documentPath = "C:/path/to/Page.razor"; + var codeDocument = CreateCodeDocument("@code {}"); + var documentResolver = CreateDocumentResolver(documentPath, codeDocument); + var codeActionEndpoint = new CodeActionEndpoint(new RazorCodeActionProvider[] { + new MockCodeActionProvider(), + new NullMockCodeActionProvider(), + new MockCodeActionProvider(), + new NullMockCodeActionProvider(), + }, Dispatcher, documentResolver, LoggerFactory); + + var request = new CodeActionParams() + { + TextDocument = new TextDocumentIdentifier(new Uri(documentPath)), + Range = new Range(new Position(0, 1), new Position(0, 1)), + }; + + // Act + var commandOrCodeActionContainer = await codeActionEndpoint.Handle(request, default); + + // Assert + Assert.Equal(2, commandOrCodeActionContainer.Count()); + } + + private class MockCodeActionProvider : RazorCodeActionProvider + { + public override Task ProvideAsync(RazorCodeActionContext context, CancellationToken cancellationToken) + { + return Task.FromResult(new CommandOrCodeActionContainer(new List() { + new CommandOrCodeAction(new CodeAction()), + })); + } + } + + private class NullMockCodeActionProvider : RazorCodeActionProvider + { + public override Task ProvideAsync(RazorCodeActionContext context, CancellationToken cancellationToken) + { + return null; + } + } + + private static DocumentResolver CreateDocumentResolver(string documentPath, RazorCodeDocument codeDocument) + { + var sourceTextChars = new char[codeDocument.Source.Length]; + codeDocument.Source.CopyTo(0, sourceTextChars, 0, codeDocument.Source.Length); + var sourceText = SourceText.From(new string(sourceTextChars)); + var documentSnapshot = Mock.Of(document => + document.GetGeneratedOutputAsync() == Task.FromResult(codeDocument) && + document.GetTextAsync() == Task.FromResult(sourceText)); + var documentResolver = new Mock(); + documentResolver + .Setup(resolver => resolver.TryResolveDocument(documentPath, out documentSnapshot)) + .Returns(true); + return documentResolver.Object; + } + + private static RazorCodeDocument CreateCodeDocument(string text) + { + var codeDocument = TestRazorCodeDocument.CreateEmpty(); + var sourceDocument = TestRazorSourceDocument.Create(text); + var syntaxTree = RazorSyntaxTree.Parse(sourceDocument); + codeDocument.SetSyntaxTree(syntaxTree); + return codeDocument; + } + } +} diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionResolutionEndpointTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionResolutionEndpointTest.cs new file mode 100644 index 00000000000..a7d62313786 --- /dev/null +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionResolutionEndpointTest.cs @@ -0,0 +1,109 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; +using Microsoft.AspNetCore.Razor.Test.Common; +using Newtonsoft.Json.Linq; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using Xunit; + +namespace Microsoft.AspNetCore.Razor.LanguageServer.Test.CodeActions +{ + public class CodeActionResolutionEndpointTest : LanguageServerTestBase + { + [Fact] + public async Task Handle_Resolve() + { + // Arrange + var codeActionEndpoint = new CodeActionResolutionEndpoint(new RazorCodeActionResolver[] { + new MockCodeActionResolver("Test"), + }, LoggerFactory); + var request = new RazorCodeActionResolutionParams() + { + Action = "Test", + Data = null + }; + + // Act + var workspaceEdit = await codeActionEndpoint.Handle(request, default); + + // Assert + Assert.NotNull(workspaceEdit); + } + + [Fact] + public async Task Handle_ResolveMultipleProviders_FirstMatches() + { + // Arrange + var codeActionEndpoint = new CodeActionResolutionEndpoint(new RazorCodeActionResolver[] { + new MockCodeActionResolver("A"), + new NullMockCodeActionResolver("B"), + }, LoggerFactory); + var request = new RazorCodeActionResolutionParams() + { + Action = "A", + Data = null + }; + + // Act + var workspaceEdit = await codeActionEndpoint.Handle(request, default); + + // Assert + Assert.NotNull(workspaceEdit); + } + + [Fact] + public async Task Handle_ResolveMultipleProviders_SecondMatches() + { + // Arrange + var codeActionEndpoint = new CodeActionResolutionEndpoint(new RazorCodeActionResolver[] { + new NullMockCodeActionResolver("A"), + new MockCodeActionResolver("B"), + }, LoggerFactory); + var request = new RazorCodeActionResolutionParams() + { + Action = "B", + Data = null + }; + + // Act + var workspaceEdit = await codeActionEndpoint.Handle(request, default); + + // Assert + Assert.NotNull(workspaceEdit); + } + + + private class MockCodeActionResolver : RazorCodeActionResolver + { + public override string Action { get; } + + internal MockCodeActionResolver(string action) + { + Action = action; + } + + public override Task ResolveAsync(JObject data, CancellationToken cancellationToken) + { + return Task.FromResult(new WorkspaceEdit()); + } + } + + private class NullMockCodeActionResolver : RazorCodeActionResolver + { + public override string Action { get; } + + internal NullMockCodeActionResolver(string action) + { + Action = action; + } + + public override Task ResolveAsync(JObject data, CancellationToken cancellationToken) + { + return null; + } + } + } +} diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/ExtractToCodeBehindCodeActionProviderTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/ExtractToCodeBehindCodeActionProviderTest.cs new file mode 100644 index 00000000000..2fae506ab87 --- /dev/null +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/ExtractToCodeBehindCodeActionProviderTest.cs @@ -0,0 +1,229 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Test.Common; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Components; +using Microsoft.AspNetCore.Razor.Language.Extensions; +using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using Xunit; +using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; + +namespace Microsoft.AspNetCore.Razor.LanguageServer.Test.CodeActions +{ + public class ExtractToCodeBehindCodeActionProviderTest : LanguageServerTestBase + { + [Fact] + public async Task Handle_InvalidFileKind() + { + // Arrange + var documentPath = "c:/Test.razor"; + var contents = "@page \"/test\"\n@code {}"; + var codeDocument = CreateCodeDocument(contents); + codeDocument.SetFileKind(FileKinds.Legacy); + + var request = new CodeActionParams() + { + TextDocument = new TextDocumentIdentifier(new Uri(documentPath)), + Range = new Range(), + }; + + var location = new SourceLocation(contents.IndexOf("code"), -1, -1); + var provider = new ExtractToCodeBehindCodeActionProvider(); + var context = new RazorCodeActionContext(request, codeDocument, location); + + // Act + var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); + + // Assert + Assert.Null(commandOrCodeActionContainer); + } + + [Fact] + public async Task Handle_OutsideCodeDirective() + { + // Arrange + var documentPath = "c:/Test.razor"; + var contents = "@page \"/test\"\n@code {}"; + var codeDocument = CreateCodeDocument(contents); + + var request = new CodeActionParams() + { + TextDocument = new TextDocumentIdentifier(new Uri(documentPath)), + Range = new Range(), + }; + + var location = new SourceLocation(contents.IndexOf("test"), -1, -1); + var provider = new ExtractToCodeBehindCodeActionProvider(); + var context = new RazorCodeActionContext(request, codeDocument, location); + + // Act + var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); + + // Assert + Assert.Null(commandOrCodeActionContainer); + } + + [Fact] + public async Task Handle_InCodeDirectiveBlock() + { + // Arrange + var documentPath = "c:/Test.razor"; + var contents = "@page \"/test\"\n@code {}"; + var codeDocument = CreateCodeDocument(contents); + + var request = new CodeActionParams() + { + TextDocument = new TextDocumentIdentifier(new Uri(documentPath)), + Range = new Range(), + }; + + var location = new SourceLocation(contents.IndexOf("code") + 6, -1, -1); + var provider = new ExtractToCodeBehindCodeActionProvider(); + var context = new RazorCodeActionContext(request, codeDocument, location); + + // Act + var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); + + // Assert + Assert.Null(commandOrCodeActionContainer); + } + + [Fact] + public async Task Handle_InCodeDirectiveMalformed() + { + // Arrange + var documentPath = "c:/Test.razor"; + var contents = "@page \"/test\"\n@code"; + var codeDocument = CreateCodeDocument(contents); + + var request = new CodeActionParams() + { + TextDocument = new TextDocumentIdentifier(new Uri(documentPath)), + Range = new Range(), + }; + + var location = new SourceLocation(contents.IndexOf("code"), -1, -1); + var provider = new ExtractToCodeBehindCodeActionProvider(); + var context = new RazorCodeActionContext(request, codeDocument, location); + + // Act + var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); + + // Assert + Assert.Null(commandOrCodeActionContainer); + } + + [Fact] + public async Task Handle_InCodeDirectiveWithMarkup() + { + // Arrange + var documentPath = "c:/Test.razor"; + var contents = "@page \"/test\"\n@code { void Test() {

Hello, world!

} }"; + var codeDocument = CreateCodeDocument(contents); + + var request = new CodeActionParams() + { + TextDocument = new TextDocumentIdentifier(new Uri(documentPath)), + Range = new Range(), + }; + + var location = new SourceLocation(contents.IndexOf("code"), -1, -1); + var provider = new ExtractToCodeBehindCodeActionProvider(); + var context = new RazorCodeActionContext(request, codeDocument, location); + + // Act + var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); + + // Assert + Assert.Null(commandOrCodeActionContainer); + } + + [Fact] + public async Task Handle_InCodeDirective() + { + // Arrange + var documentPath = "c:/Test.razor"; + var contents = "@page \"/test\"\n@code { private var x = 1; }"; + var codeDocument = CreateCodeDocument(contents); + + var request = new CodeActionParams() + { + TextDocument = new TextDocumentIdentifier(new Uri(documentPath)), + Range = new Range(), + }; + + var location = new SourceLocation(contents.IndexOf("code"), -1, -1); + var provider = new ExtractToCodeBehindCodeActionProvider(); + var context = new RazorCodeActionContext(request, codeDocument, location); + + // Act + var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); + + // Assert + Assert.Single(commandOrCodeActionContainer); + var actionParams = commandOrCodeActionContainer + .First().Command.Arguments[0] + .ToObject().Data + .ToObject(); + Assert.Equal(14, actionParams.RemoveStart); + Assert.Equal(19, actionParams.ExtractStart); + Assert.Equal(42, actionParams.ExtractEnd); + Assert.Equal(42, actionParams.RemoveEnd); + } + + [Fact] + public async Task Handle_InFunctionsDirective() + { + // Arrange + var documentPath = "c:/Test.razor"; + var contents = "@page \"/test\"\n@functions { private var x = 1; }"; + var codeDocument = CreateCodeDocument(contents); + + var request = new CodeActionParams() + { + TextDocument = new TextDocumentIdentifier(new Uri(documentPath)), + Range = new Range(), + }; + + var location = new SourceLocation(contents.IndexOf("functions"), -1, -1); + var provider = new ExtractToCodeBehindCodeActionProvider(); + var context = new RazorCodeActionContext(request, codeDocument, location); + + // Act + var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); + + // Assert + Assert.Single(commandOrCodeActionContainer); + var actionParams = commandOrCodeActionContainer + .First().Command.Arguments[0] + .ToObject().Data + .ToObject(); + Assert.Equal(14, actionParams.RemoveStart); + Assert.Equal(24, actionParams.ExtractStart); + Assert.Equal(47, actionParams.ExtractEnd); + Assert.Equal(47, actionParams.RemoveEnd); + } + + private static RazorCodeDocument CreateCodeDocument(string text) + { + var codeDocument = TestRazorCodeDocument.CreateEmpty(); + codeDocument.SetFileKind(FileKinds.Component); + + var sourceDocument = TestRazorSourceDocument.Create(text, filePath: "c:/Test.razor", relativePath: "c:/Test.razor"); + var options = RazorParserOptions.Create(o => + { + o.Directives.Add(ComponentCodeDirective.Directive); + o.Directives.Add(FunctionsDirective.Directive); + }); + var syntaxTree = RazorSyntaxTree.Parse(sourceDocument, options); + codeDocument.SetSyntaxTree(syntaxTree); + + return codeDocument; + } + } +} diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/ExtractToCodeBehindCodeActionResolverTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/ExtractToCodeBehindCodeActionResolverTest.cs new file mode 100644 index 00000000000..09dc56b865a --- /dev/null +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/ExtractToCodeBehindCodeActionResolverTest.cs @@ -0,0 +1,260 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.LanguageServer.Common; +using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; +using Microsoft.AspNetCore.Razor.LanguageServer.ProjectSystem; +using Microsoft.AspNetCore.Razor.Test.Common; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; +using Microsoft.CodeAnalysis.Text; +using Moq; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Microsoft.AspNetCore.Razor.LanguageServer.Test.CodeActions +{ + public class ExtractToCodeBehindCodeActionResolverTest : LanguageServerTestBase + { + private readonly DocumentResolver EmptyDocumentResolver = Mock.Of(); + + [Fact] + public async Task Handle_MissingFile() + { + // Arrange + var resolver = new ExtractToCodeBehindCodeActionResolver(new DefaultForegroundDispatcher(), EmptyDocumentResolver, FilePathNormalizer); + var data = JObject.FromObject(new ExtractToCodeBehindParams() + { + Uri = new Uri("c:/Test.razor"), + RemoveStart = 14, + ExtractStart = 19, + ExtractEnd = 41, + RemoveEnd = 41, + }); + + // Act + var workspaceEdit = await resolver.ResolveAsync(data, default); + + // Assert + Assert.Null(workspaceEdit); + } + + [Fact] + public async Task Handle_Unsupported() + { + // Arrange + var documentPath = "c:\\Test.razor"; + var contents = $"@page \"/test\"{Environment.NewLine}@code {{ private var x = 1; }}"; + var codeDocument = CreateCodeDocument(contents); + codeDocument.SetUnsupported(); + + var resolver = new ExtractToCodeBehindCodeActionResolver(new DefaultForegroundDispatcher(), CreateDocumentResolver(documentPath, codeDocument), FilePathNormalizer); + var data = JObject.FromObject(new ExtractToCodeBehindParams() + { + Uri = new Uri("c:/Test.razor"), + RemoveStart = 14, + ExtractStart = 20, + ExtractEnd = 41, + RemoveEnd = 41, + }); + + // Act + var workspaceEdit = await resolver.ResolveAsync(data, default); + + // Assert + Assert.Null(workspaceEdit); + } + + [Fact] + public async Task Handle_InvalidFileKind() + { + // Arrange + var documentPath = "c:\\Test.razor"; + var contents = $"@page \"/test\"{Environment.NewLine}@code {{ private var x = 1; }}"; + var codeDocument = CreateCodeDocument(contents); + codeDocument.SetFileKind(FileKinds.Legacy); + + var resolver = new ExtractToCodeBehindCodeActionResolver(new DefaultForegroundDispatcher(), CreateDocumentResolver(documentPath, codeDocument), FilePathNormalizer); + var data = JObject.FromObject(new ExtractToCodeBehindParams() + { + Uri = new Uri("c:/Test.razor"), + RemoveStart = 14, + ExtractStart = 20, + ExtractEnd = 41, + RemoveEnd = 41, + }); + + // Act + var workspaceEdit = await resolver.ResolveAsync(data, default); + + // Assert + Assert.Null(workspaceEdit); + } + + [Fact] + public async Task Handle_ExtractCodeBlock() + { + // Arrange + var documentPath = "c:/Test.razor"; + var documentUri = new Uri(documentPath); + var contents = $"@page \"/test\"{Environment.NewLine}@code {{ private var x = 1; }}"; + var codeDocument = CreateCodeDocument(contents); + + var resolver = new ExtractToCodeBehindCodeActionResolver(new DefaultForegroundDispatcher(), CreateDocumentResolver(documentPath, codeDocument), FilePathNormalizer); + var actionParams = new ExtractToCodeBehindParams + { + Uri = documentUri, + RemoveStart = contents.IndexOf("@code"), + ExtractStart = contents.IndexOf("{"), + ExtractEnd = contents.IndexOf("}"), + RemoveEnd = contents.IndexOf("}"), + }; + 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.Count()); + + var documentChanges = workspaceEdit.DocumentChanges.ToArray(); + var createFileChange = documentChanges[0]; + Assert.True(createFileChange.IsCreateFile); + + var editCodeDocumentChange = documentChanges[1]; + var editCodeDocumentEdit = editCodeDocumentChange.TextDocumentEdit.Edits.First(); + Assert.Equal(actionParams.RemoveStart, editCodeDocumentEdit.Range.Start.GetAbsoluteIndex(codeDocument.GetSourceText())); + Assert.Equal(actionParams.RemoveEnd, editCodeDocumentEdit.Range.End.GetAbsoluteIndex(codeDocument.GetSourceText())); + + var editCodeBehindChange = documentChanges[2]; + var editCodeBehindEdit = editCodeBehindChange.TextDocumentEdit.Edits.First(); + Assert.Contains("public partial class Test", editCodeBehindEdit.NewText); + Assert.Contains("private var x = 1", editCodeBehindEdit.NewText); + Assert.Contains("namespace test.Pages", editCodeBehindEdit.NewText); + } + + [Fact] + public async Task Handle_ExtractFunctionsBlock() + { + // Arrange + var documentPath = "c:/Test.razor"; + var documentUri = new Uri(documentPath); + var contents = $"@page \"/test\"{Environment.NewLine}@functions {{ private var x = 1; }}"; + var codeDocument = CreateCodeDocument(contents); + + var resolver = new ExtractToCodeBehindCodeActionResolver(new DefaultForegroundDispatcher(), CreateDocumentResolver(documentPath, codeDocument), FilePathNormalizer); + var actionParams = new ExtractToCodeBehindParams + { + Uri = documentUri, + RemoveStart = contents.IndexOf("@functions"), + ExtractStart = contents.IndexOf("{"), + ExtractEnd = contents.IndexOf("}"), + RemoveEnd = contents.IndexOf("}"), + }; + 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.Count()); + + var documentChanges = workspaceEdit.DocumentChanges.ToArray(); + var createFileChange = documentChanges[0]; + Assert.True(createFileChange.IsCreateFile); + + var editCodeDocumentChange = documentChanges[1]; + var editCodeDocumentEdit = editCodeDocumentChange.TextDocumentEdit.Edits.First(); + Assert.Equal(actionParams.RemoveStart, editCodeDocumentEdit.Range.Start.GetAbsoluteIndex(codeDocument.GetSourceText())); + Assert.Equal(actionParams.RemoveEnd, editCodeDocumentEdit.Range.End.GetAbsoluteIndex(codeDocument.GetSourceText())); + + var editCodeBehindChange = documentChanges[2]; + var editCodeBehindEdit = editCodeBehindChange.TextDocumentEdit.Edits.First(); + Assert.Contains("public partial class Test", editCodeBehindEdit.NewText); + Assert.Contains("private var x = 1", editCodeBehindEdit.NewText); + Assert.Contains("namespace test.Pages", editCodeBehindEdit.NewText); + } + + [Fact] + public async Task Handle_ExtractCodeBlockWithUsing() + { + // Arrange + var documentPath = "c:/Test.razor"; + var documentUri = new Uri(documentPath); + var contents = $"@page \"/test\"\n@using System.Diagnostics{Environment.NewLine}@code {{ private var x = 1; }}"; + var codeDocument = CreateCodeDocument(contents); + + var resolver = new ExtractToCodeBehindCodeActionResolver(new DefaultForegroundDispatcher(), CreateDocumentResolver(documentPath, codeDocument), FilePathNormalizer); + var actionParams = new ExtractToCodeBehindParams + { + Uri = documentUri, + RemoveStart = contents.IndexOf("@code"), + ExtractStart = contents.IndexOf("{"), + ExtractEnd = contents.IndexOf("}"), + RemoveEnd = contents.IndexOf("}"), + }; + 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.Count()); + + var documentChanges = workspaceEdit.DocumentChanges.ToArray(); + var createFileChange = documentChanges[0]; + Assert.True(createFileChange.IsCreateFile); + + var editCodeDocumentChange = documentChanges[1]; + var editCodeDocumentEdit = editCodeDocumentChange.TextDocumentEdit.Edits.First(); + Assert.Equal(actionParams.RemoveStart, editCodeDocumentEdit.Range.Start.GetAbsoluteIndex(codeDocument.GetSourceText())); + Assert.Equal(actionParams.RemoveEnd, editCodeDocumentEdit.Range.End.GetAbsoluteIndex(codeDocument.GetSourceText())); + + var editCodeBehindChange = documentChanges[2]; + var editCodeBehindEdit = editCodeBehindChange.TextDocumentEdit.Edits.First(); + Assert.Contains("using System.Diagnostics", editCodeBehindEdit.NewText); + Assert.Contains("public partial class Test", editCodeBehindEdit.NewText); + Assert.Contains("private var x = 1", editCodeBehindEdit.NewText); + Assert.Contains("namespace test.Pages", editCodeBehindEdit.NewText); + } + + private static DocumentResolver CreateDocumentResolver(string documentPath, RazorCodeDocument codeDocument) + { + var sourceTextChars = new char[codeDocument.Source.Length]; + codeDocument.Source.CopyTo(0, sourceTextChars, 0, codeDocument.Source.Length); + var sourceText = SourceText.From(new string(sourceTextChars)); + var documentSnapshot = Mock.Of(document => + document.GetGeneratedOutputAsync() == Task.FromResult(codeDocument) && + document.GetTextAsync() == Task.FromResult(sourceText)); + var documentResolver = new Mock(); + documentResolver + .Setup(resolver => resolver.TryResolveDocument(documentPath, out documentSnapshot)) + .Returns(true); + return documentResolver.Object; + } + + private static RazorCodeDocument CreateCodeDocument(string text) + { + var projectItem = new TestRazorProjectItem("c:/Test.razor", "c:/Test.razor", "Test.razor") { Content = text }; + var projectEngine = RazorProjectEngine.Create(RazorConfiguration.Default, TestRazorProjectFileSystem.Empty, (builder) => + { + builder.SetRootNamespace("test.Pages"); + }); + + var codeDocument = projectEngine.Process(projectItem); + codeDocument.SetFileKind(FileKinds.Component); + + return codeDocument; + } + } +}