diff --git a/src/Bicep.LangServer.IntegrationTests/ExtractToModuleCommandTests.cs b/src/Bicep.LangServer.IntegrationTests/ExtractToModuleCommandTests.cs new file mode 100644 index 00000000000..4893e95605e --- /dev/null +++ b/src/Bicep.LangServer.IntegrationTests/ExtractToModuleCommandTests.cs @@ -0,0 +1,272 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +using System.IO; +using System.Diagnostics.CodeAnalysis; +using Bicep.Core.Text; +using Bicep.Core.UnitTests.Utils; +using Bicep.LangServer.IntegrationTests.Helpers; +using Bicep.LanguageServer.Features.Custom.Refactoring; +using Bicep.LanguageServer.Utils; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Bicep.LangServer.IntegrationTests; + +[TestClass] +public class ExtractToModuleCommandTests +{ + [NotNull] + public TestContext? TestContext { get; set; } + + [TestMethod] + public async Task ExtractToModule_should_return_module_contents_and_replacement() + { + var response = await ExtractToModuleAsync(@" +param namePrefix string + +<> +"); + + response.Should().NotBeNull(); + response.ModuleFileContents.Should().Contain("param namePrefix string"); + response.ModuleFileContents.Should().Contain("resource stg"); + response.ReplacementText.Should().Contain("module"); + response.RenamePosition.Should().NotBeNull(); + } + + [TestMethod] + public async Task ExtractToModule_should_fail_for_partial_selection() + { + var response = await ExtractToModuleAsync(@" +param namePrefix string + +resource stg 'Microsoft.Storage/storageAccounts@2023-04-01' = { + <> + location: resourceGroup().location +} +"); + + AssertEmptyResponse(response); + } + + [TestMethod] + public async Task ExtractToModule_should_fail_for_empty_selection() + { + var response = await ExtractToModuleAsync(@" +param namePrefix string +| +resource stg 'Microsoft.Storage/storageAccounts@2023-04-01' = { + name: '${namePrefix}${uniqueString(resourceGroup().id)}' + location: resourceGroup().location +} +"); + + AssertEmptyResponse(response); + } + + [TestMethod] + public async Task ExtractToModule_should_fail_for_variable_selection() + { + var response = await ExtractToModuleAsync(@" +<> + +resource stg 'Microsoft.Storage/storageAccounts@2023-04-01' = { + name: accountName + location: resourceGroup().location +} +"); + + AssertEmptyResponse(response); + } + + [TestMethod] + public async Task ExtractToModule_should_fail_when_selection_contains_non_resource_declaration() + { + var response = await ExtractToModuleAsync(@" +<> +"); + + AssertEmptyResponse(response); + } + + [TestMethod] + public async Task ExtractToModule_should_fail_when_selected_resource_is_referenced_outside_selection() + { + var response = await ExtractToModuleAsync(@" +<> + +output storageId string = stg.id +"); + + AssertEmptyResponse(response); + } + + [TestMethod] + public async Task ExtractToModule_should_extract_multiple_resources_with_internal_references() + { + var response = await ExtractToModuleAsync(@" +<> +"); + + response.ModuleFileContents.Should().Contain("resource plan"); + response.ModuleFileContents.Should().Contain("resource site"); + response.ModuleFileContents.Should().Contain("serverFarmId: plan.id"); + response.ReplacementText.Should().Be("module storage './storage.bicep' = {\n name: 'storage'\n}\n"); + response.RenamePosition.Should().NotBeNull(); + } + + [TestMethod] + public async Task ExtractToModule_should_create_params_for_external_param_and_variable_dependencies() + { + var response = await ExtractToModuleAsync(@" +param prefix string +param location string +var accountName = '${prefix}${uniqueString(resourceGroup().id)}' + +<> +"); + + response.ModuleFileContents.Should().Contain("param location string\nparam accountName string\n\nresource stg"); + response.ModuleFileContents.Should().NotContain("param prefix string"); + response.ReplacementText.Should().Be("module storage './storage.bicep' = {\n name: 'storage'\n params: {\n location: location\n accountName: accountName\n }\n}\n"); + } + + [TestMethod] + public async Task ExtractToModule_should_emit_each_external_dependency_once() + { + var response = await ExtractToModuleAsync(@" +param tags object + +<> +"); + + CountOccurrences(response.ModuleFileContents, "param tags object").Should().Be(1); + CountOccurrences(response.ReplacementText, "tags: tags").Should().Be(1); + } + + [TestMethod] + public async Task ExtractToModule_should_avoid_existing_declaration_name_for_module_symbol() + { + var response = await ExtractToModuleAsync(@" +var storage = 'alreadyUsed' + +<> +"); + + response.ReplacementText.Should().Be("module storage1 './storage.bicep' = {\n name: 'storage1'\n}\n"); + response.RenamePosition.Should().NotBeNull(); + } + + [TestMethod] + public async Task ExtractToModule_should_sanitize_module_symbol_name_and_preserve_nested_relative_path() + { + var response = await ExtractToModuleAsync(@" +<> +", outputPath => Path.Combine(outputPath, "modules", "web-app.bicep")); + + response.ReplacementText.Should().Be("module web_app './modules/web-app.bicep' = {\n name: 'web_app'\n}\n"); + response.RenamePosition.Should().NotBeNull(); + } + + [TestMethod] + public async Task ExtractToModule_should_fall_back_to_default_symbol_name_for_invalid_module_file_name() + { + var response = await ExtractToModuleAsync(@" +<> +", outputPath => Path.Combine(outputPath, "123-storage.bicep")); + + response.ReplacementText.Should().Be("module extractedModule './123-storage.bicep' = {\n name: 'extractedModule'\n}\n"); + response.RenamePosition.Should().NotBeNull(); + } + + private async Task ExtractToModuleAsync(string fileWithSelection, Func? getModulePath = null) + { + var (fileText, selection) = ParserHelper.GetFileWithSingleSelection(fileWithSelection); + + var testOutputPath = FileHelper.GetUniqueTestOutputPath(TestContext); + var filePath = FileHelper.SaveResultFile(TestContext, "main.bicep", fileText, testOutputPath); + var modulePath = getModulePath?.Invoke(testOutputPath) ?? Path.Combine(testOutputPath, "storage.bicep"); + var documentUri = DocumentUri.FromFileSystemPath(filePath); + + var lineStarts = TextCoordinateConverter.GetLineStarts(fileText); + var range = PositionHelper.GetRange(lineStarts, selection.Position, selection.Position + selection.Length); + + using var helper = await LanguageServerHelper.StartServerWithText(TestContext, fileText, documentUri); + + return await helper.Client.SendRequest(new ExtractToModuleParams + { + TextDocument = documentUri, + Range = range, + ModuleFilePath = modulePath, + }, default); + } + + private static void AssertEmptyResponse(ExtractToModuleResponse response) + { + response.Should().NotBeNull(); + response.ModuleFileContents.Should().BeEmpty(); + response.ReplacementText.Should().BeEmpty(); + response.RenamePosition.Should().BeNull(); + } + + private static int CountOccurrences(string text, string value) + { + var count = 0; + var index = 0; + + while ((index = text.IndexOf(value, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += value.Length; + } + + return count; + } +} diff --git a/src/Bicep.LangServer/Features/Custom/Refactoring/ExtractToModuleHandler.cs b/src/Bicep.LangServer/Features/Custom/Refactoring/ExtractToModuleHandler.cs new file mode 100644 index 00000000000..4431b846c21 --- /dev/null +++ b/src/Bicep.LangServer/Features/Custom/Refactoring/ExtractToModuleHandler.cs @@ -0,0 +1,350 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; +using Bicep.Core; +using Bicep.Core.CodeAction; +using Bicep.Core.Diagnostics; +using Bicep.Core.Extensions; +using Bicep.Core.Navigation; +using Bicep.Core.Parsing; +using Bicep.Core.Semantics; +using Bicep.Core.Syntax; +using Bicep.Core.Syntax.Visitors; +using Bicep.Core.Text; +using Bicep.Core.PrettyPrintV2; +using Bicep.LanguageServer.Compilation; +using Bicep.LanguageServer.Extensions; +using Bicep.LanguageServer.Features.Custom; +using Bicep.LanguageServer.Features.Language.CodeAction; +using Bicep.LanguageServer.Utils; +using MediatR; +using OmniSharp.Extensions.JsonRpc; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; +using OmniSharp.Extensions.LanguageServer.Protocol.Window; +using OmniSharp.Extensions.LanguageServer.Protocol.Workspace; +using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; + +namespace Bicep.LanguageServer.Features.Custom.Refactoring; + +[Method("bicep/extractToModule", Direction.ClientToServer)] +public record ExtractToModuleParams : IRequest +{ + public required TextDocumentIdentifier TextDocument { get; init; } + + public required Range Range { get; init; } + + public required string ModuleFilePath { get; init; } +} + +public record ExtractToModuleResponse +{ + public required Range ReplacementRange { get; init; } + + public required string ReplacementText { get; init; } + + public required string ModuleFileContents { get; init; } + + public Position? RenamePosition { get; init; } +} + +public class ExtractToModuleHandler : IJsonRpcRequestHandler +{ + private readonly ICompilationManager compilationManager; + private readonly ErrorHandlingHelper helper; + + public ExtractToModuleHandler(ICompilationManager compilationManager, ILanguageServerFacade server) + { + this.compilationManager = compilationManager; + this.helper = new ErrorHandlingHelper(server.Window); + } + + public Task Handle(ExtractToModuleParams request, CancellationToken cancellationToken) + => helper.ExecuteWithErrorHandling(async () => + { + var context = compilationManager.GetCompilation(request.TextDocument.Uri); + if (context is null) + { + throw helper.CreateException( + "Unable to locate an active compilation for the current document.", + CreateEmptyResponse(request.Range)); + } + + var semanticModel = context.Compilation.GetEntrypointSemanticModel(); + var selectionSpan = GetSelectionSpan(request, context.LineStarts); + + var selectedStatements = context.ProgramSyntax.Children.OfType() + .Where(statement => ContainsSpan(selectionSpan, statement.Span)) + .OrderBy(statement => statement.Span.Position) + .ToList(); + + if (selectedStatements.Count == 0) + { + throw helper.CreateException( + "Selection must include at least one complete top-level declaration.", + CreateEmptyResponse(request.Range)); + } + + if (context.ProgramSyntax.Children.OfType().Any(statement => + TextSpan.AreOverlapping(selectionSpan, statement.Span) && !ContainsSpan(selectionSpan, statement.Span))) + { + throw helper.CreateException( + "Selection must fully cover one or more top-level declarations.", + CreateEmptyResponse(request.Range)); + } + + if (selectedStatements.Any(s => s is not ResourceDeclarationSyntax)) + { + throw helper.CreateException( + "Extract to module currently supports resource declarations only.", + CreateEmptyResponse(request.Range)); + } + + var selectedResources = selectedStatements.Cast().ToImmutableArray(); + var selectedSymbols = selectedResources + .Select(resource => semanticModel.GetSymbolInfo(resource)) + .OfType() + .ToImmutableArray(); + + foreach (var symbol in selectedSymbols) + { + var externalReference = semanticModel.FindReferences(symbol) + .FirstOrDefault(reference => !ContainsSpan(selectionSpan, reference.Span)); + + if (externalReference is not null) + { + throw helper.CreateException( + "Selected resources are referenced outside the selection.", + CreateEmptyResponse(request.Range)); + } + } + + var declaredInsideSelection = selectedStatements + .Select(syntax => semanticModel.Root.DeclarationsBySyntax.TryGetValue(syntax, out var symbol) ? symbol : null) + .OfType() + .ToHashSet(); + + var externalDependencies = CollectExternalDependencies(selectedStatements, semanticModel, declaredInsideSelection); + var newline = semanticModel.Configuration.Formatting.Data.NewlineKind.ToEscapeSequence(); + + var moduleParams = externalDependencies + .OrderBy(dep => dep.DeclaringSyntax.Span.Position) + .Select(dep => CreateModuleParam(dep, semanticModel)) + .ToList(); + + var moduleFileContents = BuildModuleFileContents(moduleParams, selectedResources, semanticModel.SourceFile.Text, newline); + + var documentDirectory = Path.GetDirectoryName(request.TextDocument.Uri.GetFileSystemPath()) ?? string.Empty; + var relativeModulePath = GetModuleRelativePath(request.ModuleFilePath, documentDirectory); + var moduleSymbolName = FindModuleSymbolName(relativeModulePath, semanticModel); + + var replacementText = BuildModuleDeclarationText(moduleSymbolName, relativeModulePath, moduleParams, newline); + var renamePosition = CalculateRenamePosition(request.Range, replacementText, moduleSymbolName); + + return new ExtractToModuleResponse + { + ReplacementRange = request.Range, + ReplacementText = replacementText, + ModuleFileContents = moduleFileContents, + RenamePosition = renamePosition, + }; + }); + + private static ExtractToModuleResponse CreateEmptyResponse(Range range) => new() + { + ReplacementRange = range, + ReplacementText = string.Empty, + ModuleFileContents = string.Empty, + RenamePosition = null, + }; + + private static TextSpan GetSelectionSpan(ExtractToModuleParams request, ImmutableArray lineStarts) + { + var startOffset = PositionHelper.GetOffset(lineStarts, request.Range.Start); + var endOffset = PositionHelper.GetOffset(lineStarts, request.Range.End); + + return new TextSpan(startOffset, Math.Max(0, endOffset - startOffset)); + } + + private static bool ContainsSpan(TextSpan outer, TextSpan inner) + { + var outerEnd = outer.Position + outer.Length; + var innerEnd = inner.Position + inner.Length; + return outer.Position <= inner.Position && outerEnd >= innerEnd; + } + + private static IEnumerable CollectExternalDependencies(IEnumerable selectedStatements, SemanticModel semanticModel, HashSet declaredInsideSelection) + { + var collector = new DependencyCollector(semanticModel); + foreach (var statement in selectedStatements) + { + collector.Visit(statement); + } + + return collector.ReferencedSymbols + .Where(symbol => symbol is ParameterSymbol or VariableSymbol) + .Where(symbol => !declaredInsideSelection.Contains(symbol)) + .Cast() + .Distinct(); + } + + private static string CreateModuleParam(DeclaredSymbol symbol, SemanticModel semanticModel) + { + var type = semanticModel.GetTypeInfo(symbol.DeclaringSyntax); + var typeString = TypeStringifier.Stringify(type, null, TypeStringifier.Strictness.Medium, removeTopLevelNullability: true); + return $"{LanguageConstants.ParameterKeyword} {symbol.Name} {typeString}"; + } + + private static string BuildModuleFileContents(IEnumerable moduleParams, IEnumerable resources, string sourceText, string newline) + { + var builder = new StringBuilder(); + + var paramList = moduleParams.ToList(); + foreach (var param in paramList) + { + builder.Append(param); + builder.Append(newline); + } + + if (paramList.Any()) + { + builder.Append(newline); + } + + var resourceList = resources.ToList(); + var firstResource = resourceList.First(); + var lastResource = resourceList.Last(); + var start = firstResource.Span.Position; + var end = lastResource.GetEndPosition(); + var length = end - start; + var selectedText = sourceText.Substring(start, length); + + builder.Append(selectedText.TrimEnd('\r', '\n')); + builder.Append(newline); + + return builder.ToString(); + } + + private static string BuildModuleDeclarationText(string moduleSymbolName, string relativeModulePath, IEnumerable moduleParams, string newline) + { + var paramList = moduleParams.ToList(); + var builder = new StringBuilder(); + builder.Append($"{LanguageConstants.ModuleKeyword} "); + builder.Append(moduleSymbolName); + builder.Append(" '"); + builder.Append(relativeModulePath); + builder.Append("' = {"); + builder.Append(newline); + builder.Append(" name: '"); + builder.Append(moduleSymbolName); + builder.Append("'"); + + if (paramList.Any()) + { + builder.Append(newline); + builder.Append($" {LanguageConstants.ModuleParamsPropertyName}: {{"); + builder.Append(newline); + foreach (var param in paramList) + { + var name = param.Split(' ', StringSplitOptions.RemoveEmptyEntries)[1]; + builder.Append(" "); + builder.Append(name); + builder.Append(": "); + builder.Append(name); + builder.Append(newline); + } + + builder.Append(" }"); + } + + builder.Append(newline); + builder.Append("}"); + builder.Append(newline); + + return builder.ToString(); + } + + private static Position? CalculateRenamePosition(Range replacementRange, string replacementText, string moduleSymbolName) + { + var lineStarts = TextCoordinateConverter.GetLineStarts(replacementText); + var offset = replacementText.IndexOf(moduleSymbolName, StringComparison.Ordinal); + if (offset < 0) + { + return null; + } + + var relativePosition = TextCoordinateConverter.GetPosition(lineStarts, offset); + + var absoluteLine = replacementRange.Start.Line + relativePosition.line; + var absoluteCharacter = relativePosition.line == 0 + ? replacementRange.Start.Character + relativePosition.character + : relativePosition.character; + + return new Position(absoluteLine, absoluteCharacter); + } + + private static string GetModuleRelativePath(string moduleFilePath, string documentDirectory) + { + var relative = Path.GetRelativePath(documentDirectory, moduleFilePath); + relative = relative.Replace(Path.DirectorySeparatorChar, '/'); + if (!relative.StartsWith('.' ) && !relative.StartsWith('/')) + { + relative = "./" + relative; + } + + return relative; + } + + private static string FindModuleSymbolName(string relativeModulePath, SemanticModel semanticModel) + { + var baseName = Path.GetFileNameWithoutExtension(relativeModulePath); + if (string.IsNullOrWhiteSpace(baseName)) + { + baseName = "extractedModule"; + } + + baseName = Regex.Replace(baseName, "[^A-Za-z0-9_]", "_"); + if (!Lexer.IsValidIdentifier(baseName)) + { + baseName = "extractedModule"; + } + + var candidate = baseName; + var index = 1; + while (semanticModel.Root.GetDeclarationsByName(candidate).Any()) + { + candidate = $"{baseName}{index}"; + index++; + } + + return candidate; + } + + private sealed class DependencyCollector : CstVisitor + { + private readonly SemanticModel semanticModel; + + public DependencyCollector(SemanticModel semanticModel) + { + this.semanticModel = semanticModel; + this.ReferencedSymbols = new HashSet(); + } + + public HashSet ReferencedSymbols { get; } + + public override void VisitVariableAccessSyntax(VariableAccessSyntax syntax) + { + if (semanticModel.GetSymbolInfo(syntax) is { } symbol) + { + ReferencedSymbols.Add(symbol); + } + + base.VisitVariableAccessSyntax(syntax); + } + } +} diff --git a/src/Bicep.LangServer/Server.cs b/src/Bicep.LangServer/Server.cs index 26808bf9308..2e49f896ffb 100644 --- a/src/Bicep.LangServer/Server.cs +++ b/src/Bicep.LangServer/Server.cs @@ -18,6 +18,7 @@ using Bicep.LanguageServer.Features.Custom.LocalDeploy; using Bicep.LanguageServer.Features.Custom.ModuleRestore; using Bicep.LanguageServer.Features.Custom.Parameters; +using Bicep.LanguageServer.Features.Custom.Refactoring; using Bicep.LanguageServer.Features.Custom.Visualization; using Bicep.LanguageServer.Features.Language.CodeAction; using Bicep.LanguageServer.Features.Language.CodeLens; @@ -96,6 +97,7 @@ public Server(BicepLangServerOptions bicepLangServerOptions, Action() .WithHandler() .WithHandler() + .WithHandler() .WithHandler() .WithHandler() .WithHandler() diff --git a/src/vscode-bicep/package.json b/src/vscode-bicep/package.json index dfe489f65b4..1cca8e0be5f 100644 --- a/src/vscode-bicep/package.json +++ b/src/vscode-bicep/package.json @@ -254,6 +254,12 @@ "category": "Bicep", "icon": "$(cloud-download)" }, + { + "command": "bicep.extractToModule", + "title": "Extract to Module...", + "category": "Bicep", + "icon": "$(export)" + }, { "command": "bicep.importKubernetesManifest", "title": "Import Kubernetes Manifest (EXPERIMENTAL)", @@ -391,6 +397,11 @@ "when": "resourceLangId == bicep", "group": "2_bicep_1_edit" }, + { + "command": "bicep.extractToModule", + "when": "resourceLangId == bicep", + "group": "2_bicep_1_edit" + }, { "command": "bicep.pasteAsBicep", "when": "resourceLangId == bicep", @@ -458,6 +469,11 @@ "when": "resourceLangId == bicep", "group": "2_bicep_1_edit" }, + { + "command": "bicep.extractToModule", + "when": "resourceLangId == bicep", + "group": "2_bicep_1_edit" + }, { "command": "bicep.deploy", "when": "resourceLangId == bicep", @@ -520,6 +536,11 @@ "when": "resourceLangId == bicep", "group": "2_bicep_1_edit" }, + { + "command": "bicep.extractToModule", + "when": "resourceLangId == bicep", + "group": "2_bicep_1_edit" + }, { "command": "bicep.pasteAsBicep", "when": "resourceLangId == bicep", @@ -587,6 +608,10 @@ "command": "bicep.insertResource", "group": "0_bicep" }, + { + "command": "bicep.extractToModule", + "group": "0_bicep" + }, { "command": "bicep.showDeployPane", "group": "0_bicep" diff --git a/src/vscode-bicep/src/extension.ts b/src/vscode-bicep/src/extension.ts index 17d66a20f8c..2d9ae509b7c 100644 --- a/src/vscode-bicep/src/extension.ts +++ b/src/vscode-bicep/src/extension.ts @@ -112,7 +112,7 @@ export async function activate(extensionContext: ExtensionContext): Promise ({ + commands: { + executeCommand: vi.fn(), + }, + Uri: { + file: vi.fn((filePath: string) => ({ + fsPath: filePath, + toString: () => filePath, + })), + }, + window: { + showErrorMessage: vi.fn(), + showInputBox: vi.fn(), + showWarningMessage: vi.fn(), + }, + workspace: { + applyEdit: vi.fn(), + fs: { + createDirectory: vi.fn(), + stat: vi.fn(), + writeFile: vi.fn(), + }, + }, +})); + +const mockClient = (result: ExtractToModuleResult): LanguageClient => { + return { + sendRequest: vi.fn().mockResolvedValue(result), + code2ProtocolConverter: { + asTextDocumentIdentifier: vi.fn().mockReturnValue({ uri: "doc" }), + asRange: vi.fn().mockReturnValue({ start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }), + }, + protocol2CodeConverter: { + asWorkspaceEdit: vi.fn().mockReturnValue({}), + asPosition: vi.fn().mockReturnValue({ line: 0, character: 0 }), + }, + } as unknown as LanguageClient; +}; + +describe("ExtractToModuleCommand", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(workspace.fs.stat).mockRejectedValue(new Error("missing")); + }); + + it("sends request and writes module", async () => { + const response: ExtractToModuleResult = { + replacementRange: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, + replacementText: "module mod './mod.bicep' = {}\n", + moduleFileContents: "param p string\n", + renamePosition: { line: 0, character: 7 }, + }; + + const client = mockClient(response); + const command = new ExtractToModuleCommand(client); + + const documentUri = Uri.file("/tmp/main.bicep"); + window.activeTextEditor = { + document: { + uri: documentUri, + languageId: "bicep", + fileName: "", + isUntitled: false, + encoding: "", + version: 0, + isDirty: false, + isClosed: false, + save: function (): Thenable { + throw new Error("Function not implemented."); + }, + eol: 1 as unknown as import("vscode").EndOfLine, + lineCount: 0, + lineAt: function (): TextLine { + throw new Error("Function not implemented."); + }, + offsetAt: function (): number { + throw new Error("Function not implemented."); + }, + positionAt: function (): Position { + throw new Error("Function not implemented."); + }, + getText: function (): string { + throw new Error("Function not implemented."); + }, + getWordRangeAtPosition: function (): Range | undefined { + throw new Error("Function not implemented."); + }, + validateRange: function (): Range { + throw new Error("Function not implemented."); + }, + validatePosition: function (): Position { + throw new Error("Function not implemented."); + }, + }, + selection: { + isEmpty: false, + anchor: { line: 0, character: 0 } as Position, + active: { line: 0, character: 0 } as Position, + isReversed: false, + start: { line: 0, character: 0 } as Position, + end: { line: 0, character: 0 } as Position, + isSingleLine: false, + contains: function (): boolean { + throw new Error("Function not implemented."); + }, + isEqual: function (): boolean { + throw new Error("Function not implemented."); + }, + intersection: function (): Range | undefined { + throw new Error("Function not implemented."); + }, + union: function (): Range { + throw new Error("Function not implemented."); + }, + with: function (): Range { + throw new Error("Function not implemented."); + }, + }, + } as unknown as TextEditor; + + vi.mocked(window.showInputBox).mockResolvedValue("module.bicep"); + + await command.execute(undefined); + + expect(client.sendRequest).toHaveBeenCalled(); + expect(workspace.fs.writeFile).toHaveBeenCalledOnce(); + expect(workspace.applyEdit).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/vscode-bicep/src/features/refactoring/extract-to-module.ts b/src/vscode-bicep/src/features/refactoring/extract-to-module.ts new file mode 100644 index 00000000000..77e0dc84ce5 --- /dev/null +++ b/src/vscode-bicep/src/features/refactoring/extract-to-module.ts @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { LanguageClient } from "vscode-languageclient/node"; + +import path from "path"; +import { commands, Uri, window, workspace } from "vscode"; +import { Command } from "../../infrastructure/commands"; +import { bicepLanguageId } from "../../infrastructure/editor"; +import { ExtractToModuleParams, extractToModuleRequestType, ExtractToModuleResult } from "./protocol"; + +export class ExtractToModuleCommand implements Command { + public readonly id = "bicep.extractToModule"; + + public constructor(private readonly client: LanguageClient) {} + + public async execute(documentUri?: Uri): Promise { + const editor = window.activeTextEditor; + if (!editor || editor.document.languageId !== bicepLanguageId) { + await window.showErrorMessage("Extract to module is only available for Bicep files."); + return; + } + + const selection = editor.selection; + if (selection.isEmpty) { + await window.showWarningMessage("Select one or more top-level resources to extract."); + return; + } + + const targetUri = this.getTargetUri(documentUri ?? editor.document.uri); + const targetPath = await window.showInputBox({ + prompt: "Enter a path for the new module file", + value: targetUri.fsPath, + }); + + if (!targetPath) { + return; + } + + const resolvedTarget = this.resolveTargetUri(targetPath, editor.document.uri); + + if (await this.fileExists(resolvedTarget)) { + const choice = await window.showWarningMessage( + `File ${resolvedTarget.fsPath} already exists. Overwrite?`, + { modal: true }, + "Overwrite", + ); + + if (choice !== "Overwrite") { + return; + } + } + + const params: ExtractToModuleParams = { + textDocument: this.client.code2ProtocolConverter.asTextDocumentIdentifier(editor.document), + range: this.client.code2ProtocolConverter.asRange(selection), + moduleFilePath: resolvedTarget.fsPath, + }; + + const result = await this.client.sendRequest(extractToModuleRequestType.method, params); + + if (!result || !result.moduleFileContents || !result.replacementText) { + await window.showErrorMessage("Extract to module failed. See language server output for details."); + return; + } + + await workspace.fs.createDirectory(Uri.file(path.dirname(resolvedTarget.fsPath))); + await workspace.fs.writeFile(resolvedTarget, Buffer.from(result.moduleFileContents, "utf8")); + + const workspaceEdit = await this.client.protocol2CodeConverter.asWorkspaceEdit({ + changes: { + [editor.document.uri.toString()]: [ + { + range: result.replacementRange, + newText: result.replacementText, + }, + ], + }, + }); + + if (workspaceEdit) { + await workspace.applyEdit(workspaceEdit); + } + + if (result.renamePosition) { + const position = this.client.protocol2CodeConverter.asPosition(result.renamePosition); + await commands.executeCommand("editor.action.rename", [editor.document.uri, position]); + } + } + + private getTargetUri(documentUri: Uri): Uri { + const defaultFileName = "extractedModule.bicep"; + const folder = path.dirname(documentUri.fsPath); + return Uri.file(path.join(folder, defaultFileName)); + } + + private resolveTargetUri(inputPath: string, documentUri: Uri): Uri { + if (path.isAbsolute(inputPath)) { + return Uri.file(inputPath); + } + + const folder = path.dirname(documentUri.fsPath); + return Uri.file(path.resolve(folder, inputPath)); + } + + private async fileExists(uri: Uri): Promise { + try { + await workspace.fs.stat(uri); + return true; + } catch { + return false; + } + } +} diff --git a/src/vscode-bicep/src/features/refactoring/index.ts b/src/vscode-bicep/src/features/refactoring/index.ts index e93fda1f229..dd47af8f7b2 100644 --- a/src/vscode-bicep/src/features/refactoring/index.ts +++ b/src/vscode-bicep/src/features/refactoring/index.ts @@ -1,4 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +export { ExtractToModuleCommand } from "./extract-to-module"; export { activateRefactoringFeature, PostExtractionCommand } from "./post-extraction"; +export { extractToModuleRequestType, type ExtractToModuleParams, type ExtractToModuleResult } from "./protocol"; diff --git a/src/vscode-bicep/src/features/refactoring/post-extraction.ts b/src/vscode-bicep/src/features/refactoring/post-extraction.ts index 41ea1ea5418..bf49a4a3927 100644 --- a/src/vscode-bicep/src/features/refactoring/post-extraction.ts +++ b/src/vscode-bicep/src/features/refactoring/post-extraction.ts @@ -3,7 +3,9 @@ import { commands, Position, Uri } from "vscode"; import { integer } from "vscode-languageclient"; +import { LanguageClient } from "vscode-languageclient/node"; import { Command, CommandManager } from "../../infrastructure/commands"; +import { ExtractToModuleCommand } from "./extract-to-module"; export class PostExtractionCommand implements Command { public readonly id = "bicep.internal.postExtraction"; @@ -18,6 +20,9 @@ export class PostExtractionCommand implements Command { } } -export async function activateRefactoringFeature(commandManager: CommandManager): Promise { - await commandManager.registerCommands(new PostExtractionCommand()); +export async function activateRefactoringFeature( + commandManager: CommandManager, + client: LanguageClient, +): Promise { + await commandManager.registerCommands(new ExtractToModuleCommand(client), new PostExtractionCommand()); } diff --git a/src/vscode-bicep/src/features/refactoring/protocol.ts b/src/vscode-bicep/src/features/refactoring/protocol.ts new file mode 100644 index 00000000000..dc690b09d44 --- /dev/null +++ b/src/vscode-bicep/src/features/refactoring/protocol.ts @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Position, ProtocolRequestType, Range, TextDocumentIdentifier } from "vscode-languageserver-protocol"; + +export interface ExtractToModuleParams { + textDocument: TextDocumentIdentifier; + range: Range; + moduleFilePath: string; +} + +export interface ExtractToModuleResult { + replacementRange: Range; + replacementText: string; + moduleFileContents: string; + renamePosition?: Position; +} + +export const extractToModuleRequestType = new ProtocolRequestType< + ExtractToModuleParams, + ExtractToModuleResult, + never, + void, + void +>("bicep/extractToModule");