From 9394732e7e87c101280c01adfcb19b239f19c1e5 Mon Sep 17 00:00:00 2001 From: Engin Polat <118744+polatengin@users.noreply.github.com> Date: Mon, 5 Jan 2026 23:25:39 +0000 Subject: [PATCH 1/3] feat: add Extract to Module functionality --- .../ExtractToModuleCommandTests.cs | 91 +++++ .../Handlers/ExtractToModuleHandler.cs | 357 ++++++++++++++++++ src/Bicep.LangServer/Server.cs | 1 + .../Telemetry/BicepTelemetryEvent.cs | 19 + .../Telemetry/TelemetryConstants.cs | 3 + src/vscode-bicep/package.json | 25 ++ .../src/commands/extractToModule.ts | 115 ++++++ src/vscode-bicep/src/extension.ts | 2 + src/vscode-bicep/src/language/protocol.ts | 21 ++ .../test/unit/extractToModuleCommand.test.ts | 123 ++++++ 10 files changed, 757 insertions(+) create mode 100644 src/Bicep.LangServer.IntegrationTests/ExtractToModuleCommandTests.cs create mode 100644 src/Bicep.LangServer/Handlers/ExtractToModuleHandler.cs create mode 100644 src/vscode-bicep/src/commands/extractToModule.ts create mode 100644 src/vscode-bicep/src/test/unit/extractToModuleCommand.test.ts diff --git a/src/Bicep.LangServer.IntegrationTests/ExtractToModuleCommandTests.cs b/src/Bicep.LangServer.IntegrationTests/ExtractToModuleCommandTests.cs new file mode 100644 index 00000000000..37e6fb2351e --- /dev/null +++ b/src/Bicep.LangServer.IntegrationTests/ExtractToModuleCommandTests.cs @@ -0,0 +1,91 @@ +// 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.Handlers; +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 (fileText, selection) = ParserHelper.GetFileWithSingleSelection(@""" +param namePrefix string + +<> +"""); + + var testOutputPath = FileHelper.GetUniqueTestOutputPath(TestContext); + var filePath = FileHelper.SaveResultFile(TestContext, "main.bicep", fileText, testOutputPath); + var modulePath = 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); + + var response = await helper.Client.SendRequest(new ExtractToModuleParams + { + TextDocument = documentUri, + Range = range, + ModuleFilePath = modulePath, + }, default); + + 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 (fileText, selection) = ParserHelper.GetFileWithSingleSelection(@""" +param namePrefix string + +resource stg 'Microsoft.Storage/storageAccounts@2023-04-01' = { + <> + location: resourceGroup().location +} +"""); + + var testOutputPath = FileHelper.GetUniqueTestOutputPath(TestContext); + var filePath = FileHelper.SaveResultFile(TestContext, "main.bicep", fileText, testOutputPath); + var modulePath = 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); + + var response = await helper.Client.SendRequest(new ExtractToModuleParams + { + TextDocument = documentUri, + Range = range, + ModuleFilePath = modulePath, + }, default); + + response.ModuleFileContents.Should().BeEmpty(); + response.ReplacementText.Should().BeEmpty(); + } +} diff --git a/src/Bicep.LangServer/Handlers/ExtractToModuleHandler.cs b/src/Bicep.LangServer/Handlers/ExtractToModuleHandler.cs new file mode 100644 index 00000000000..6937216a3f6 --- /dev/null +++ b/src/Bicep.LangServer/Handlers/ExtractToModuleHandler.cs @@ -0,0 +1,357 @@ +// 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.CompilationManager; +using Bicep.LanguageServer.Extensions; +using Bicep.LanguageServer.Refactor; +using Bicep.LanguageServer.Telemetry; +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.Handlers; + +[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 ILanguageServerFacade server; + private readonly TelemetryAndErrorHandlingHelper helper; + + public ExtractToModuleHandler(ICompilationManager compilationManager, ILanguageServerFacade server, ITelemetryProvider telemetryProvider) + { + this.compilationManager = compilationManager; + this.server = server; + this.helper = new TelemetryAndErrorHandlingHelper(server.Window, telemetryProvider); + } + + public Task Handle(ExtractToModuleParams request, CancellationToken cancellationToken) + => helper.ExecuteWithTelemetryAndErrorHandling(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.", + BicepTelemetryEvent.ExtractToModuleFailure("MissingCompilation"), + 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.", + BicepTelemetryEvent.ExtractToModuleFailure("EmptySelection"), + 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.", + BicepTelemetryEvent.ExtractToModuleFailure("PartialStatementSelection"), + CreateEmptyResponse(request.Range)); + } + + if (selectedStatements.Any(s => s is not ResourceDeclarationSyntax)) + { + throw helper.CreateException( + "Extract to module currently supports resource declarations only.", + BicepTelemetryEvent.ExtractToModuleFailure("UnsupportedDeclarationType"), + 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.", + BicepTelemetryEvent.ExtractToModuleFailure("ExternalReference"), + 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, + }, BicepTelemetryEvent.ExtractToModuleSuccess(moduleParams.Count, selectedResources.Length)); + }); + + 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 8f41682582e..1b09e0eaccf 100644 --- a/src/Bicep.LangServer/Server.cs +++ b/src/Bicep.LangServer/Server.cs @@ -69,6 +69,7 @@ public Server(BicepLangServerOptions bicepLangServerOptions, Action() .WithHandler() .WithHandler() + .WithHandler() .WithHandler() .WithHandler() .WithHandler() diff --git a/src/Bicep.LangServer/Telemetry/BicepTelemetryEvent.cs b/src/Bicep.LangServer/Telemetry/BicepTelemetryEvent.cs index 0d6eeb8c194..b5761aeeff9 100644 --- a/src/Bicep.LangServer/Telemetry/BicepTelemetryEvent.cs +++ b/src/Bicep.LangServer/Telemetry/BicepTelemetryEvent.cs @@ -350,6 +350,25 @@ public static BicepTelemetryEvent ExternalSourceDocLinkClickFailure(string failu } ); + public static BicepTelemetryEvent ExtractToModuleSuccess(int parameterCount, int resourceCount) + => new( + eventName: TelemetryConstants.EventNames.ExtractToModuleSuccess, + properties: new() + { + ["parameterCount"] = parameterCount.ToString(), + ["resourceCount"] = resourceCount.ToString(), + } + ); + + public static BicepTelemetryEvent ExtractToModuleFailure(string failureType) + => new( + eventName: TelemetryConstants.EventNames.ExtractToModuleFailure, + properties: new() + { + ["failureType"] = failureType, + } + ); + public enum ExtractionKind { Variable, diff --git a/src/Bicep.LangServer/Telemetry/TelemetryConstants.cs b/src/Bicep.LangServer/Telemetry/TelemetryConstants.cs index af4b3c8e3d3..16ec4b5bd95 100644 --- a/src/Bicep.LangServer/Telemetry/TelemetryConstants.cs +++ b/src/Bicep.LangServer/Telemetry/TelemetryConstants.cs @@ -54,6 +54,9 @@ public static class EventNames public const string ExternalSourceDocLinkClickFailure = "ExternalSourceDocLinkClick/failure"; public const string ExtractionRefactoring = "refactoring/extraction"; + + public const string ExtractToModuleSuccess = "ExtractToModule/success"; + public const string ExtractToModuleFailure = "ExtractToModule/failure"; } } } diff --git a/src/vscode-bicep/package.json b/src/vscode-bicep/package.json index 7b448d9088b..eb4dc8b615c 100644 --- a/src/vscode-bicep/package.json +++ b/src/vscode-bicep/package.json @@ -248,6 +248,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)", @@ -385,6 +391,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", @@ -452,6 +463,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", @@ -514,6 +530,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", @@ -581,6 +602,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/commands/extractToModule.ts b/src/vscode-bicep/src/commands/extractToModule.ts new file mode 100644 index 00000000000..72a4eafb847 --- /dev/null +++ b/src/vscode-bicep/src/commands/extractToModule.ts @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import path from "path"; +import fse from "fs-extra"; +import { IActionContext } from "@microsoft/vscode-azext-utils"; +import { commands, Uri, window, workspace } from "vscode"; +import type { LanguageClient } from "vscode-languageclient/node"; +import { extractToModuleRequestType, ExtractToModuleParams, ExtractToModuleResult } from "../language/protocol"; +import { bicepLanguageId } from "../language/constants"; +import { Command } from "./types"; + +export class ExtractToModuleCommand implements Command { + public readonly id = "bicep.extractToModule"; + + public constructor(private readonly client: LanguageClient) {} + + public async execute(_: IActionContext, 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 fse.ensureDir(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/extension.ts b/src/vscode-bicep/src/extension.ts index 45f09a59b61..e8f836bae70 100644 --- a/src/vscode-bicep/src/extension.ts +++ b/src/vscode-bicep/src/extension.ts @@ -29,6 +29,7 @@ import { WalkthroughCopyToClipboardCommand } from "./commands/gettingStarted/Wal import { WalkthroughCreateBicepFileCommand } from "./commands/gettingStarted/WalkthroughCreateBicepFileCommand"; import { WalkthroughOpenBicepFileCommand } from "./commands/gettingStarted/WalkthroughOpenBicepFileCommand"; import { ImportKubernetesManifestCommand } from "./commands/importKubernetesManifest"; +import { ExtractToModuleCommand } from "./commands/extractToModule"; import { InsertResourceCommand } from "./commands/insertResource"; import { PasteAsBicepCommand } from "./commands/pasteAsBicep"; import { PostExtractionCommand } from "./commands/PostExtractionCommand"; @@ -152,6 +153,7 @@ export async function activate(extensionContext: ExtensionContext): Promise("bicep/importKubernetesManifest"); +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"); + export interface CreateBicepConfigParams { destinationPath: string; } diff --git a/src/vscode-bicep/src/test/unit/extractToModuleCommand.test.ts b/src/vscode-bicep/src/test/unit/extractToModuleCommand.test.ts new file mode 100644 index 00000000000..009b543a644 --- /dev/null +++ b/src/vscode-bicep/src/test/unit/extractToModuleCommand.test.ts @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { commands, Uri, window, workspace } from "vscode"; +import type { Position, Range, TextEditor, TextLine } from "vscode"; +import { ExtractToModuleCommand } from "../../commands/extractToModule"; +import { LanguageClient } from "vscode-languageclient/node"; +import { ExtractToModuleResult } from "../../language"; + +const mockClient = (result: ExtractToModuleResult): LanguageClient => { + return { + sendRequest: jest.fn().mockResolvedValue(result), + code2ProtocolConverter: { + asTextDocumentIdentifier: jest.fn().mockReturnValue({ uri: "doc" }), + asRange: jest.fn().mockReturnValue({ start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }), + }, + protocol2CodeConverter: { + asWorkspaceEdit: jest.fn().mockReturnValue({}), + asPosition: jest.fn().mockReturnValue({ line: 0, character: 0 }), + }, + } as unknown as LanguageClient; +}; + +describe("ExtractToModuleCommand", () => { + beforeEach(() => { + window.showInputBox = jest.fn(); + window.showErrorMessage = jest.fn(); + window.showWarningMessage = jest.fn(); + workspace.applyEdit = jest.fn(); + (workspace as unknown as { fs: { writeFile: jest.Mock; stat: jest.Mock } }).fs = { + writeFile: jest.fn().mockResolvedValue(undefined), + stat: jest.fn().mockRejectedValue(new Error("missing")), + }; + commands.executeCommand = jest.fn(); + (Uri as unknown as { file: jest.Mock }).file = jest.fn((p: string) => ({ + fsPath: p, + toString: () => p, + } as unknown as Uri)); + }); + + 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("/workspaces/bicep/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; + + window.showInputBox = jest.fn().mockResolvedValue("module.bicep"); + + await command.execute({ telemetry: { properties: {} } } as never, undefined); + + expect(client.sendRequest).toHaveBeenCalled(); + expect((workspace.fs.writeFile as jest.Mock).mock.calls).toHaveLength(1); + expect((workspace.applyEdit as jest.Mock).mock.calls).toHaveLength(1); + }); +}); From 083b6866e4b44fd9e2fd2d40231d7584dc731e97 Mon Sep 17 00:00:00 2001 From: Engin Polat <118744+polatengin@users.noreply.github.com> Date: Mon, 5 Jan 2026 23:49:00 +0000 Subject: [PATCH 2/3] fix: update test document URI to use temporary path --- src/vscode-bicep/src/test/unit/extractToModuleCommand.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vscode-bicep/src/test/unit/extractToModuleCommand.test.ts b/src/vscode-bicep/src/test/unit/extractToModuleCommand.test.ts index 009b543a644..ac7b61d8af4 100644 --- a/src/vscode-bicep/src/test/unit/extractToModuleCommand.test.ts +++ b/src/vscode-bicep/src/test/unit/extractToModuleCommand.test.ts @@ -49,7 +49,7 @@ describe("ExtractToModuleCommand", () => { const client = mockClient(response); const command = new ExtractToModuleCommand(client); - const documentUri = Uri.file("/workspaces/bicep/main.bicep"); + const documentUri = Uri.file("/tmp/main.bicep"); window.activeTextEditor = { document: { uri: documentUri, languageId: "bicep", From fa95be48cc0be87d3dbc6cd127433d540ec9919c Mon Sep 17 00:00:00 2001 From: Engin Polat <118744+polatengin@users.noreply.github.com> Date: Mon, 18 May 2026 18:43:34 +0000 Subject: [PATCH 3/3] test: enhance ExtractToModuleCommandTests with additional scenarios and assertions --- .../ExtractToModuleCommandTests.cs | 227 ++++++++++++++++-- 1 file changed, 204 insertions(+), 23 deletions(-) diff --git a/src/Bicep.LangServer.IntegrationTests/ExtractToModuleCommandTests.cs b/src/Bicep.LangServer.IntegrationTests/ExtractToModuleCommandTests.cs index 37e6fb2351e..6ab71ffc03a 100644 --- a/src/Bicep.LangServer.IntegrationTests/ExtractToModuleCommandTests.cs +++ b/src/Bicep.LangServer.IntegrationTests/ExtractToModuleCommandTests.cs @@ -23,31 +23,14 @@ public class ExtractToModuleCommandTests [TestMethod] public async Task ExtractToModule_should_return_module_contents_and_replacement() { - var (fileText, selection) = ParserHelper.GetFileWithSingleSelection(@""" + var response = await ExtractToModuleAsync(@" param namePrefix string <> -"""); - - var testOutputPath = FileHelper.GetUniqueTestOutputPath(TestContext); - var filePath = FileHelper.SaveResultFile(TestContext, "main.bicep", fileText, testOutputPath); - var modulePath = 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); - - var response = await helper.Client.SendRequest(new ExtractToModuleParams - { - TextDocument = documentUri, - Range = range, - ModuleFilePath = modulePath, - }, default); +"); response.Should().NotBeNull(); response.ModuleFileContents.Should().Contain("param namePrefix string"); @@ -59,18 +42,197 @@ param namePrefix string [TestMethod] public async Task ExtractToModule_should_fail_for_partial_selection() { - var (fileText, selection) = ParserHelper.GetFileWithSingleSelection(@""" + 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 = Path.Combine(testOutputPath, "storage.bicep"); + var modulePath = getModulePath?.Invoke(testOutputPath) ?? Path.Combine(testOutputPath, "storage.bicep"); var documentUri = DocumentUri.FromFileSystemPath(filePath); var lineStarts = TextCoordinateConverter.GetLineStarts(fileText); @@ -78,14 +240,33 @@ param namePrefix string using var helper = await LanguageServerHelper.StartServerWithText(TestContext, fileText, documentUri); - var response = await helper.Client.SendRequest(new ExtractToModuleParams + 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; } }