From 87577c6ee13690cbfb7f4f9cebb110af46a912be Mon Sep 17 00:00:00 2001 From: Anthony Martin <38542602+anthony-c-martin@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:36:00 -0400 Subject: [PATCH 1/8] Initial commit with boilerplate --- src/Bicep.Cli/Bicep.Cli.csproj | 1 + src/Bicep.Cli/Commands/RootCommand.cs | 2 +- src/Bicep.Cli/Program.cs | 261 ++++++++++++++---- .../Bicep.RegistryModuleTool.csproj | 4 +- .../Commands/BaseCommandHandler.cs | 13 +- .../Commands/GenerateCommand.cs | 6 +- .../Commands/ValidateCommand.cs | 17 +- .../Extensions/BicepCompilerExtensions.cs | 1 - .../Extensions/CommandExtensions.cs | 2 +- .../CommandLineBuilderExtensions.cs | 13 +- .../Extensions/ConsoleExtensions.cs | 36 +-- .../Extensions/HostBuilderExtensions.cs | 21 +- .../ModuleFileValidators/TestValidator.cs | 1 - .../ModuleFiles/MainBicepFile.cs | 1 - src/Bicep.RegistryModuleTool/Program.cs | 69 ++--- src/Directory.Packages.props | 5 +- 16 files changed, 299 insertions(+), 154 deletions(-) diff --git a/src/Bicep.Cli/Bicep.Cli.csproj b/src/Bicep.Cli/Bicep.Cli.csproj index 009a3e3c215..237221b0a40 100644 --- a/src/Bicep.Cli/Bicep.Cli.csproj +++ b/src/Bicep.Cli/Bicep.Cli.csproj @@ -28,6 +28,7 @@ + diff --git a/src/Bicep.Cli/Commands/RootCommand.cs b/src/Bicep.Cli/Commands/RootCommand.cs index c91fe2fd5f6..5693ce25b1b 100644 --- a/src/Bicep.Cli/Commands/RootCommand.cs +++ b/src/Bicep.Cli/Commands/RootCommand.cs @@ -41,7 +41,7 @@ public int Run(RootArguments args) return 1; } - private void PrintHelp() + internal void PrintHelp() { var exeName = ThisAssembly.AssemblyName; var versionString = environment.GetVersionString(); diff --git a/src/Bicep.Cli/Program.cs b/src/Bicep.Cli/Program.cs index 2f255be248f..f1c05aea4e1 100644 --- a/src/Bicep.Cli/Program.cs +++ b/src/Bicep.Cli/Program.cs @@ -1,8 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.CommandLine; +using System.CommandLine.Help; using System.Diagnostics; -using System.IO.Abstractions; using System.Runtime; using Bicep.Cli.Arguments; using Bicep.Cli.Commands; @@ -20,6 +21,9 @@ using Microsoft.Extensions.Logging; using Spectre.Console; +// Avoid naming conflict with Bicep.Cli.Commands.RootCommand +using SclRootCommand = System.CommandLine.RootCommand; + namespace Bicep.Cli { public record IOContext( @@ -81,77 +85,224 @@ public async Task RunAsync(string[] args, CancellationToken cancellationTok var environment = services.GetRequiredService(); Trace.WriteLine($"Bicep version: {environment.GetVersionString()}, OS: {environment.CurrentPlatform?.ToString() ?? "unknown"}, Architecture: {environment.CurrentArchitecture}, CLI arguments: \"{string.Join(' ', args)}\""); - try - { - switch (ArgumentParser.TryParse(args, services.GetRequiredService())) - { - case BuildArguments buildArguments when buildArguments.CommandName == Constants.Command.Build: // bicep build [options] - return await services.GetRequiredService().RunAsync(buildArguments); - - case TestArguments testArguments when testArguments.CommandName == Constants.Command.Test: // bicep test [options] - return await services.GetRequiredService().RunAsync(testArguments); - - case BuildParamsArguments buildParamsArguments when buildParamsArguments.CommandName == Constants.Command.BuildParams: // bicep build-params [options] - return await services.GetRequiredService().RunAsync(buildParamsArguments); - - case FormatArguments formatArguments when formatArguments.CommandName == Constants.Command.Format: // bicep format [options] - return services.GetRequiredService().Run(formatArguments); + var rootCommand = BuildCommandLine(cancellationToken); + return await rootCommand.Parse(args).InvokeAsync(cancellationToken: cancellationToken); + } - case GenerateParametersFileArguments generateParametersFileArguments when generateParametersFileArguments.CommandName == Constants.Command.GenerateParamsFile: // bicep generate-params [options] - return await services.GetRequiredService().RunAsync(generateParametersFileArguments); + /// + /// Builds the System.CommandLine command hierarchy. Each existing subcommand is currently + /// registered as a legacy pass-through stub via . To migrate a + /// command, replace its LegacyCommand call with a that + /// declares its own and members and + /// invokes the command handler directly. + /// + private SclRootCommand BuildCommandLine(CancellationToken cancellationToken) + { + var rootCommand = new SclRootCommand("Bicep CLI") + { + // Collect unknown tokens so we can produce a custom error message that matches + // the format of the original CLI instead of letting System.CommandLine error out. + TreatUnmatchedTokensAsErrors = false, + }; - case DecompileArguments decompileArguments when decompileArguments.CommandName == Constants.Command.Decompile: // bicep decompile [options] - return await services.GetRequiredService().RunAsync(decompileArguments); + // System.CommandLine adds --version and --help to RootCommand by default. + // Replace both with custom options so their output goes through io.Output.Writer + // (not Console.Out) and --version prints the Bicep-specific version string. + var builtInVersion = rootCommand.Options.FirstOrDefault(o => o.Name == "--version"); + if (builtInVersion is not null) + { + rootCommand.Options.Remove(builtInVersion); + } + var builtInHelp = rootCommand.Options.OfType().FirstOrDefault(); + if (builtInHelp is not null) + { + rootCommand.Options.Remove(builtInHelp); + } - case DecompileParamsArguments decompileParamsArguments when decompileParamsArguments.CommandName == Constants.Command.DecompileParams: - return services.GetRequiredService().Run(decompileParamsArguments); + var helpOption = new Option("--help", "-?", "-h") { Description = "Show help and usage information." }; + var versionOption = new Option("--version", "-v") { Description = "Show version information." }; + var licenseOption = new Option("--license") { Description = "Print license information." }; + var thirdPartyNoticesOption = new Option("--third-party-notices") { Description = "Print third-party notice information." }; - case PublishArguments publishArguments when publishArguments.CommandName == Constants.Command.Publish: // bicep publish [options] - return await services.GetRequiredService().RunAsync(publishArguments); + rootCommand.Add(helpOption); + rootCommand.Add(versionOption); + rootCommand.Add(licenseOption); + rootCommand.Add(thirdPartyNoticesOption); - case PublishExtensionArguments publishProviderArguments when publishProviderArguments.CommandName == Constants.Command.PublishExtension: // bicep publish-extension [options] - return await services.GetRequiredService().RunAsync(publishProviderArguments, cancellationToken); + rootCommand.SetAction(async (ParseResult pr, CancellationToken ct) => + { + var bicepRootCommand = services.GetRequiredService(); - case RestoreArguments restoreArguments when restoreArguments.CommandName == Constants.Command.Restore: // bicep restore - return await services.GetRequiredService().RunAsync(restoreArguments); + var unmatched = pr.UnmatchedTokens; - case LintArguments lintArguments when lintArguments.CommandName == Constants.Command.Lint: // bicep lint [options] - return await services.GetRequiredService().RunAsync(lintArguments); + // Show help when explicitly requested or when called with no arguments at all. + if (pr.GetValue(helpOption) || unmatched.Count == 0) + { + bicepRootCommand.PrintHelp(); + return 0; + } - case JsonRpcArguments jsonRpcArguments when jsonRpcArguments.CommandName == Constants.Command.JsonRpc: // bicep jsonrpc [options] - return await services.GetRequiredService().RunAsync(jsonRpcArguments, cancellationToken); + if (pr.GetValue(versionOption)) + { + return bicepRootCommand.Run(new RootArguments("--version", Constants.Command.Root)); + } - case LocalDeployArguments localDeployArguments when localDeployArguments.CommandName == Constants.Command.LocalDeploy: // bicep local-deploy [options] - return await services.GetRequiredService().RunAsync(localDeployArguments, cancellationToken); + if (pr.GetValue(licenseOption)) + { + return bicepRootCommand.Run(new RootArguments("--license", Constants.Command.Root)); + } - case SnapshotArguments snapshotArguments when snapshotArguments.CommandName == Constants.Command.Snapshot: // bicep snapshot [options] - return await services.GetRequiredService().RunAsync(snapshotArguments, cancellationToken); + if (pr.GetValue(thirdPartyNoticesOption)) + { + return bicepRootCommand.Run(new RootArguments("--third-party-notices", Constants.Command.Root)); + } - case DeployArguments deployArguments when deployArguments.CommandName == Constants.Command.Deploy: // bicep deploy [options] - return await services.GetRequiredService().RunAsync(deployArguments, cancellationToken); + await io.Error.Writer.WriteLineAsync( + string.Format(CliResources.UnrecognizedArgumentsFormat, string.Join(' ', unmatched), ThisAssembly.AssemblyName)); + return 1; + }); - case WhatIfArguments whatIfArguments when whatIfArguments.CommandName == Constants.Command.WhatIf: // bicep what-if [options] - return await services.GetRequiredService().RunAsync(whatIfArguments, cancellationToken); + // Each subcommand below is a legacy pass-through stub. Arguments not recognized by + // System.CommandLine are collected in ParseResult.UnmatchedTokens and forwarded to + // the existing argument class for parsing. Once a command is fully migrated, replace + // the LegacyCommand call with a Command that has explicit Option/Argument + // members and calls the command handler with the bound values directly. + rootCommand.Add(LegacyCommand( + Constants.Command.Build, + "Builds a .bicep file.", + args => services.GetRequiredService().RunAsync(new BuildArguments(args)))); + + rootCommand.Add(LegacyCommand( + Constants.Command.Test, + "Runs tests in a .bicep file.", + args => services.GetRequiredService().RunAsync(new TestArguments(args)))); + + rootCommand.Add(LegacyCommand( + Constants.Command.BuildParams, + "Builds a .bicepparam file.", + args => services.GetRequiredService().RunAsync(new BuildParamsArguments(args)))); + + rootCommand.Add(LegacyCommand( + Constants.Command.Format, + "Formats a .bicep file.", + args => Task.FromResult(services.GetRequiredService().Run(new FormatArguments(args))))); + + rootCommand.Add(LegacyCommand( + Constants.Command.GenerateParamsFile, + "Generates a parameters file for a .bicep file.", + args => services.GetRequiredService().RunAsync(new GenerateParametersFileArguments(args)))); + + rootCommand.Add(LegacyCommand( + Constants.Command.Decompile, + "Attempts to decompile a template .json file to .bicep.", + args => services.GetRequiredService().RunAsync(new DecompileArguments(args)))); + + rootCommand.Add(LegacyCommand( + Constants.Command.DecompileParams, + "Attempts to decompile a parameters .json file to .bicepparam.", + args => Task.FromResult(services.GetRequiredService().Run(new DecompileParamsArguments(args))))); + + rootCommand.Add(LegacyCommand( + Constants.Command.Publish, + "Publishes a .bicep file to a registry.", + args => services.GetRequiredService().RunAsync(new PublishArguments(args)))); + + rootCommand.Add(LegacyCommand( + Constants.Command.PublishExtension, + "Publishes a Bicep extension to a registry.", + args => services.GetRequiredService().RunAsync(new PublishExtensionArguments(args), cancellationToken))); + + rootCommand.Add(LegacyCommand( + Constants.Command.Restore, + "Restores external modules for a .bicep file.", + args => services.GetRequiredService().RunAsync(new RestoreArguments(args)))); + + rootCommand.Add(LegacyCommand( + Constants.Command.Lint, + "Lints a .bicep file.", + args => services.GetRequiredService().RunAsync(new LintArguments(args)))); + + rootCommand.Add(LegacyCommand( + Constants.Command.JsonRpc, + "Starts the Bicep JSON-RPC server.", + args => services.GetRequiredService().RunAsync(new JsonRpcArguments(args), cancellationToken))); + + rootCommand.Add(LegacyCommand( + Constants.Command.LocalDeploy, + "Performs a local deployment.", + args => services.GetRequiredService().RunAsync(new LocalDeployArguments(args), cancellationToken))); + + rootCommand.Add(LegacyCommand( + Constants.Command.Snapshot, + "Creates an extension snapshot.", + args => services.GetRequiredService().RunAsync(new SnapshotArguments(args), cancellationToken))); + + rootCommand.Add(LegacyCommand( + Constants.Command.Deploy, + "Deploys infrastructure using a .bicepparam file.", + args => services.GetRequiredService().RunAsync(new DeployArguments(args), cancellationToken))); + + rootCommand.Add(LegacyCommand( + Constants.Command.WhatIf, + "Previews the changes a deployment would make.", + args => services.GetRequiredService().RunAsync(new WhatIfArguments(args), cancellationToken))); + + rootCommand.Add(LegacyCommand( + Constants.Command.Teardown, + "Tears down resources deployed by a .bicepparam file.", + args => services.GetRequiredService().RunAsync(new TeardownArguments(args), cancellationToken))); + + rootCommand.Add(LegacyCommand( + Constants.Command.Console, + "Opens an interactive Bicep console.", + args => services.GetRequiredService().RunAsync(new ConsoleArguments(args)))); + + return rootCommand; + } - case TeardownArguments teardownArguments when teardownArguments.CommandName == Constants.Command.Teardown: // bicep teardown [options] - return await services.GetRequiredService().RunAsync(teardownArguments, cancellationToken); + /// + /// Creates a pass-through command stub that forwards all unrecognized tokens to an + /// existing argument class and command handler. System.CommandLine's built-in options + /// (e.g. --help) are still handled; everything else lands in + /// and is passed to + /// as a plain string[]. + /// + /// Once a command is fully migrated, replace this stub with a that + /// declares its own and members. + /// + /// + private Command LegacyCommand(string name, string description, Func> handler) + { + var command = new Command(name, description) + { + TreatUnmatchedTokensAsErrors = false, + }; - case ConsoleArguments consoleArguments when consoleArguments.CommandName == Constants.Command.Console: // bicep console - return await services.GetRequiredService().RunAsync(consoleArguments); + // Remove the built-in HelpOption so that --help output goes through io.Output.Writer + // (not Console.Out) and shows the custom Bicep help text. + var builtInHelp = command.Options.OfType().FirstOrDefault(); + if (builtInHelp is not null) + { + command.Options.Remove(builtInHelp); + } - case RootArguments rootArguments when rootArguments.CommandName == Constants.Command.Root: // bicep [options] - return services.GetRequiredService().Run(rootArguments); + var helpOption = new Option("--help", "-?", "-h") { Description = "Show help and usage information." }; + command.Add(helpOption); - default: - await io.Error.Writer.WriteLineAsync(string.Format(CliResources.UnrecognizedArgumentsFormat, string.Join(' ', args), ThisAssembly.AssemblyName)); // should probably print help here?? - return 1; - } - } - catch (BicepException exception) + command.SetAction(async (ParseResult pr, CancellationToken ct) => { - await io.Error.Writer.WriteLineAsync(exception.Message); - return 1; - } + try + { + return await handler(pr.UnmatchedTokens.ToArray()); + } + catch (BicepException exception) + { + await io.Error.Writer.WriteLineAsync(exception.Message); + return 1; + } + }); + + return command; } private static ILoggerFactory CreateLoggerFactory(IOContext io) diff --git a/src/Bicep.RegistryModuleTool/Bicep.RegistryModuleTool.csproj b/src/Bicep.RegistryModuleTool/Bicep.RegistryModuleTool.csproj index e65e284ac11..a1d9a0dcb8a 100644 --- a/src/Bicep.RegistryModuleTool/Bicep.RegistryModuleTool.csproj +++ b/src/Bicep.RegistryModuleTool/Bicep.RegistryModuleTool.csproj @@ -23,14 +23,12 @@ + - - - diff --git a/src/Bicep.RegistryModuleTool/Commands/BaseCommandHandler.cs b/src/Bicep.RegistryModuleTool/Commands/BaseCommandHandler.cs index f391753d43d..3b8a181ba63 100644 --- a/src/Bicep.RegistryModuleTool/Commands/BaseCommandHandler.cs +++ b/src/Bicep.RegistryModuleTool/Commands/BaseCommandHandler.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.CommandLine.Invocation; using System.IO.Abstractions; using System.Security; using Bicep.Core.Exceptions; @@ -10,7 +9,7 @@ namespace Bicep.RegistryModuleTool.Commands { - public abstract class BaseCommandHandler : ICommandHandler + public abstract class BaseCommandHandler { protected BaseCommandHandler(IFileSystem fileSystem, ILogger logger) { @@ -22,7 +21,7 @@ protected BaseCommandHandler(IFileSystem fileSystem, ILogger logger) protected ILogger Logger { get; } - public async Task InvokeAsync(InvocationContext context) + public async Task InvokeAsync(IConsole console, CancellationToken cancellationToken = default) { try { @@ -34,7 +33,7 @@ public async Task InvokeAsync(InvocationContext context) // module folder structure. this.ValidateWorkingDirectoryPath(); - return await this.InvokeInternalAsync(context); + return await this.InvokeInternalAsync(console, cancellationToken); } catch (Exception exception) { @@ -44,7 +43,7 @@ public async Task InvokeAsync(InvocationContext context) case IOException: case UnauthorizedAccessException: this.Logger.LogDebug(exception, "Command failure."); - context.Console.WriteError(exception.Message); + console.WriteError(exception.Message); break; @@ -57,9 +56,7 @@ public async Task InvokeAsync(InvocationContext context) } } - public int Invoke(InvocationContext context) => throw new NotImplementedException(); - - protected abstract Task InvokeInternalAsync(InvocationContext context); + protected abstract Task InvokeInternalAsync(IConsole console, CancellationToken cancellationToken); private void ValidateWorkingDirectoryPath() { diff --git a/src/Bicep.RegistryModuleTool/Commands/GenerateCommand.cs b/src/Bicep.RegistryModuleTool/Commands/GenerateCommand.cs index 9d8327b5afb..c7d515062c0 100644 --- a/src/Bicep.RegistryModuleTool/Commands/GenerateCommand.cs +++ b/src/Bicep.RegistryModuleTool/Commands/GenerateCommand.cs @@ -2,9 +2,9 @@ // Licensed under the MIT License. using System.CommandLine; -using System.CommandLine.Invocation; using System.IO.Abstractions; using Bicep.Core; +using Bicep.RegistryModuleTool.Extensions; using Bicep.RegistryModuleTool.ModuleFiles; using Microsoft.Extensions.Logging; @@ -27,10 +27,10 @@ public CommandHandler(IFileSystem fileSystem, ILogger logger, B this.compiler = compiler; } - protected override async Task InvokeInternalAsync(InvocationContext context) + protected override async Task InvokeInternalAsync(IConsole console, CancellationToken cancellationToken) { var mainBicepFile = await this.GenerateAndLogAsync( - MainBicepFile.FileName, () => MainBicepFile.GenerateAsync(this.FileSystem, this.compiler, context.Console)); + MainBicepFile.FileName, () => MainBicepFile.GenerateAsync(this.FileSystem, this.compiler, console)); await this.GenerateAndLogAsync( MainArmTemplateFile.FileName, () => MainArmTemplateFile.GenerateAsync(this.FileSystem, mainBicepFile)); diff --git a/src/Bicep.RegistryModuleTool/Commands/ValidateCommand.cs b/src/Bicep.RegistryModuleTool/Commands/ValidateCommand.cs index ba1b4ff1a41..8120181f429 100644 --- a/src/Bicep.RegistryModuleTool/Commands/ValidateCommand.cs +++ b/src/Bicep.RegistryModuleTool/Commands/ValidateCommand.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. using System.CommandLine; -using System.CommandLine.Invocation; using System.IO.Abstractions; using Bicep.Core; using Bicep.RegistryModuleTool.Exceptions; @@ -30,39 +29,39 @@ public CommandHandler(IFileSystem fileSystem, BicepCompiler compiler, ILogger InvokeInternalAsync(InvocationContext context) + protected override async Task InvokeInternalAsync(IConsole console, CancellationToken cancellationToken) { var valid = true; this.Logger.LogInformation("Validating main Bicep file..."); - var mainBicepFile = await MainBicepFile.OpenAsync(this.FileSystem, this.compiler, context.Console); + var mainBicepFile = await MainBicepFile.OpenAsync(this.FileSystem, this.compiler, console); var descriptionsValidator = new DescriptionsValidator(this.Logger); var metadataValidator = new BicepMetadataValidator(this.Logger); - valid &= await ValidateFileAsync(context.Console, () => mainBicepFile.ValidatedByAsync(descriptionsValidator, metadataValidator)); + valid &= await ValidateFileAsync(console, () => mainBicepFile.ValidatedByAsync(descriptionsValidator, metadataValidator)); this.Logger.LogInformation("Validating main Bicep test file..."); var mainBicepTestFile = MainBicepTestFile.Open(this.FileSystem); - var testValidator = new TestValidator(this.Logger, context.Console, this.compiler, mainBicepFile); - valid &= await ValidateFileAsync(context.Console, () => mainBicepTestFile.ValidatedByAsync(testValidator)); + var testValidator = new TestValidator(this.Logger, console, this.compiler, mainBicepFile); + valid &= await ValidateFileAsync(console, () => mainBicepTestFile.ValidatedByAsync(testValidator)); this.Logger.LogInformation("Validating main ARM template file..."); var diffValidator = new DiffValidator(this.FileSystem, this.Logger, mainBicepFile); var mainArmTemplateFile = await MainArmTemplateFile.OpenAsync(this.FileSystem); - valid &= await ValidateFileAsync(context.Console, () => mainArmTemplateFile.ValidatedByAsync(diffValidator)); + valid &= await ValidateFileAsync(console, () => mainArmTemplateFile.ValidatedByAsync(diffValidator)); this.Logger.LogInformation("Validating README file..."); var readmeFile = await ReadmeFile.OpenAsync(this.FileSystem); - valid &= await ValidateFileAsync(context.Console, () => readmeFile.ValidatedByAsync(diffValidator)); + valid &= await ValidateFileAsync(console, () => readmeFile.ValidatedByAsync(diffValidator)); this.Logger.LogInformation("Validating version file..."); var versionFile = await VersionFile.OpenAsync(this.FileSystem); var jsonSchemaValidator = new JsonSchemaValidator(this.Logger); - valid &= await ValidateFileAsync(context.Console, () => versionFile.ValidatedByAsync(jsonSchemaValidator, diffValidator)); + valid &= await ValidateFileAsync(console, () => versionFile.ValidatedByAsync(jsonSchemaValidator, diffValidator)); return valid ? 0 : 1; } diff --git a/src/Bicep.RegistryModuleTool/Extensions/BicepCompilerExtensions.cs b/src/Bicep.RegistryModuleTool/Extensions/BicepCompilerExtensions.cs index 1fa11dbd0f8..60e4068f2b5 100644 --- a/src/Bicep.RegistryModuleTool/Extensions/BicepCompilerExtensions.cs +++ b/src/Bicep.RegistryModuleTool/Extensions/BicepCompilerExtensions.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.CommandLine; using Bicep.Core; using Bicep.Core.Diagnostics; using Bicep.Core.Exceptions; diff --git a/src/Bicep.RegistryModuleTool/Extensions/CommandExtensions.cs b/src/Bicep.RegistryModuleTool/Extensions/CommandExtensions.cs index f89c96e9e92..2a92e615987 100644 --- a/src/Bicep.RegistryModuleTool/Extensions/CommandExtensions.cs +++ b/src/Bicep.RegistryModuleTool/Extensions/CommandExtensions.cs @@ -9,7 +9,7 @@ public static class CommandExtensions { public static Command AddSubcommand(this Command command, Command subcommand) { - command.AddCommand(subcommand); + command.Add(subcommand); return command; } diff --git a/src/Bicep.RegistryModuleTool/Extensions/CommandLineBuilderExtensions.cs b/src/Bicep.RegistryModuleTool/Extensions/CommandLineBuilderExtensions.cs index a1f701b66ef..cc72e1b9f7c 100644 --- a/src/Bicep.RegistryModuleTool/Extensions/CommandLineBuilderExtensions.cs +++ b/src/Bicep.RegistryModuleTool/Extensions/CommandLineBuilderExtensions.cs @@ -1,23 +1,22 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.CommandLine.Builder; +using System.CommandLine; using Bicep.RegistryModuleTool.Options; namespace Bicep.RegistryModuleTool.Extensions { public static class CommandLineBuilderExtensions { - public static CommandLineBuilder UseVerboseOption(this CommandLineBuilder builder) + /// Adds as a global option on the root command. + public static Command UseVerboseOption(this Command command) { - if (builder.Command.Children.Any(x => x is VerboseOption)) + if (!command.Options.Any(x => x is VerboseOption)) { - return builder; + command.Add(GlobalOptions.Verbose); } - builder.Command.AddGlobalOption(GlobalOptions.Verbose); - - return builder; + return command; } } } diff --git a/src/Bicep.RegistryModuleTool/Extensions/ConsoleExtensions.cs b/src/Bicep.RegistryModuleTool/Extensions/ConsoleExtensions.cs index eac558265a8..9b0b36abc85 100644 --- a/src/Bicep.RegistryModuleTool/Extensions/ConsoleExtensions.cs +++ b/src/Bicep.RegistryModuleTool/Extensions/ConsoleExtensions.cs @@ -1,15 +1,19 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.CommandLine; -using System.CommandLine.IO; -using System.CommandLine.Rendering; using Bicep.Core.Diagnostics; using Bicep.Core.SourceGraph; using Bicep.Core.Text; namespace Bicep.RegistryModuleTool.Extensions { + /// Local replacement for the removed System.CommandLine.IConsole interface. + public interface IConsole + { + TextWriter Out { get; } + TextWriter Error { get; } + } + public static class ConsoleExtensions { public static void WriteDiagnostic(this IConsole console, BicepSourceFile file, IDiagnostic diagnostic) @@ -23,7 +27,7 @@ public static void WriteDiagnostic(this IConsole console, BicepSourceFile file, case DiagnosticLevel.Off: break; case DiagnosticLevel.Info: - console.WriteLine(message); + console.Out.WriteLine(message); break; case DiagnosticLevel.Warning: console.WriteWarning(message); @@ -36,25 +40,23 @@ public static void WriteDiagnostic(this IConsole console, BicepSourceFile file, } } - public static void WriteWarning(this IConsole console, string warning) => console.WriteMessage(console.Out, ConsoleColor.Yellow, warning); + public static void WriteWarning(this IConsole console, string warning) => WriteMessage(console.Error, ConsoleColor.Yellow, warning); - public static void WriteError(this IConsole console, string error) => console.WriteMessage(console.Error, ConsoleColor.Red, error); + public static void WriteError(this IConsole console, string error) => WriteMessage(console.Error, ConsoleColor.Red, error); - private static void WriteMessage(this IConsole console, IStandardStreamWriter writer, ConsoleColor color, string message) + private static void WriteMessage(TextWriter writer, ConsoleColor color, string message) { - var terminal = console.GetTerminal(preferVirtualTerminal: false); - var originalForegroundColor = terminal?.ForegroundColor ?? Console.ForegroundColor; - - if (terminal is not null) + // Only apply color when writing to a real console stream. + if (writer == Console.Out || writer == Console.Error) { - terminal.ForegroundColor = color; + var previous = Console.ForegroundColor; + Console.ForegroundColor = color; + writer.WriteLine(message); + Console.ForegroundColor = previous; } - - writer.WriteLine(message); - - if (terminal is not null) + else { - terminal.ForegroundColor = originalForegroundColor; + writer.WriteLine(message); } } } diff --git a/src/Bicep.RegistryModuleTool/Extensions/HostBuilderExtensions.cs b/src/Bicep.RegistryModuleTool/Extensions/HostBuilderExtensions.cs index 8797877316a..4577690783b 100644 --- a/src/Bicep.RegistryModuleTool/Extensions/HostBuilderExtensions.cs +++ b/src/Bicep.RegistryModuleTool/Extensions/HostBuilderExtensions.cs @@ -1,10 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.CommandLine; -using System.CommandLine.Hosting; using System.Reflection; using Bicep.RegistryModuleTool.Commands; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace Bicep.RegistryModuleTool.Extensions @@ -13,20 +12,18 @@ public static class HostBuilderExtensions { public static IHostBuilder UseCommandHandlers(this IHostBuilder builder) { - var baseCommandType = typeof(Command); var baseCommandHandlerType = typeof(BaseCommandHandler); - var commandTypes = Assembly.GetExecutingAssembly().GetTypes() - .Where(t => t.Namespace == baseCommandHandlerType.Namespace && baseCommandType.IsAssignableFrom(t)); + var commandHandlerTypes = Assembly.GetExecutingAssembly().GetTypes() + .Where(t => baseCommandHandlerType.IsAssignableFrom(t) && !t.IsAbstract); - foreach (var commandType in commandTypes) + return builder.ConfigureServices(services => { - var commandHandlerType = commandType.GetNestedTypes().First(t => baseCommandHandlerType.IsAssignableFrom(t)); - - builder.UseCommandHandler(commandType, commandHandlerType); - } - - return builder; + foreach (var handlerType in commandHandlerTypes) + { + services.AddScoped(handlerType); + } + }); } } } diff --git a/src/Bicep.RegistryModuleTool/ModuleFileValidators/TestValidator.cs b/src/Bicep.RegistryModuleTool/ModuleFileValidators/TestValidator.cs index e19bd0b8cb9..3aa2ceb9919 100644 --- a/src/Bicep.RegistryModuleTool/ModuleFileValidators/TestValidator.cs +++ b/src/Bicep.RegistryModuleTool/ModuleFileValidators/TestValidator.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.CommandLine; using Bicep.Core; using Bicep.RegistryModuleTool.Extensions; using Bicep.RegistryModuleTool.ModuleFiles; diff --git a/src/Bicep.RegistryModuleTool/ModuleFiles/MainBicepFile.cs b/src/Bicep.RegistryModuleTool/ModuleFiles/MainBicepFile.cs index b70a3b91b57..d105248c2aa 100644 --- a/src/Bicep.RegistryModuleTool/ModuleFiles/MainBicepFile.cs +++ b/src/Bicep.RegistryModuleTool/ModuleFiles/MainBicepFile.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.CommandLine; using System.IO.Abstractions; using System.Text; using Bicep.Core; diff --git a/src/Bicep.RegistryModuleTool/Program.cs b/src/Bicep.RegistryModuleTool/Program.cs index edc77e9afbb..e41c88c0eb4 100644 --- a/src/Bicep.RegistryModuleTool/Program.cs +++ b/src/Bicep.RegistryModuleTool/Program.cs @@ -2,11 +2,6 @@ // Licensed under the MIT License. using System.CommandLine; -using System.CommandLine.Builder; -using System.CommandLine.Hosting; -using System.CommandLine.Invocation; -using System.CommandLine.NamingConventionBinder; -using System.CommandLine.Parsing; using Bicep.RegistryModuleTool.Commands; using Bicep.RegistryModuleTool.Extensions; using Bicep.RegistryModuleTool.Options; @@ -19,44 +14,56 @@ namespace Bicep.RegistryModuleTool { public class Program { - public static Task Main(string[] args) => CreateParser().InvokeAsync(args); - - private static Parser CreateParser() + public static async Task Main(string[] args) { - var rootCommand = new RootCommand("Bicep registry module tool") - .AddSubcommand(new ValidateCommand()) - .AddSubcommand(new GenerateCommand()); - - var parser = new CommandLineBuilder(rootCommand) - .UseHost(Host.CreateDefaultBuilder, ConfigureHost) - .UseDefaults() - .UseVerboseOption() - .Build(); + var host = CreateHost(args); + await host.StartAsync(); - // Have to use parser.Invoke instead of rootCommand.Invoke due to the - // System.CommandLine bug: https://github.com/dotnet/command-line-api/issues/1691. - rootCommand.Handler = CommandHandler.Create(() => parser.Invoke("-h")); + var rootCommand = BuildRootCommand(host.Services); + var exitCode = await rootCommand.Parse(args).InvokeAsync(); - return parser; + await host.StopAsync(); + return exitCode; } - private static void ConfigureHost(IHostBuilder builder) => builder + private static IHost CreateHost(string[] args) => Host.CreateDefaultBuilder() .ConfigureServices(services => services.AddBicepCore()) - .UseSerilog((context, logging) => logging - .MinimumLevel.Is(GetMinimumLogEventLevel(context)) + .UseSerilog((_, logging) => logging + .MinimumLevel.Is(GetMinimumLogEventLevel(args)) .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) .MinimumLevel.Override("System", LogEventLevel.Warning) .WriteTo.Console()) - .UseCommandHandlers(); + .UseCommandHandlers() + .Build(); - private static LogEventLevel GetMinimumLogEventLevel(HostBuilderContext context) + private static RootCommand BuildRootCommand(IServiceProvider services) { - var verboseSpecified = - context.Properties.TryGetValue(typeof(InvocationContext), out var value) && - value is InvocationContext invocationContext && - invocationContext.ParseResult.FindResultFor(GlobalOptions.Verbose) is not null; + var rootCommand = new RootCommand("Bicep registry module tool"); + rootCommand.UseVerboseOption(); + + var console = new SystemConsole(); + + var generateCmd = new GenerateCommand(); + generateCmd.SetAction(async (ParseResult _, CancellationToken ct) => + await services.GetRequiredService().InvokeAsync(console, ct)); + + var validateCmd = new ValidateCommand(); + validateCmd.SetAction(async (ParseResult _, CancellationToken ct) => + await services.GetRequiredService().InvokeAsync(console, ct)); - return verboseSpecified ? LogEventLevel.Debug : LogEventLevel.Fatal; + rootCommand.Add(generateCmd); + rootCommand.Add(validateCmd); + + return rootCommand; + } + + private static LogEventLevel GetMinimumLogEventLevel(string[] args) => + args.Contains("--verbose") ? LogEventLevel.Debug : LogEventLevel.Fatal; + + private sealed class SystemConsole : IConsole + { + public TextWriter Out => Console.Out; + public TextWriter Error => Console.Error; } } } diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 309edde41f5..50994ccb67e 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -95,10 +95,7 @@ - - - - + From 84c254ea5bfb5639b8f9b608b944a4f64d332ef2 Mon Sep 17 00:00:00 2001 From: Anthony Martin <38542602+anthony-c-martin@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:19:59 -0400 Subject: [PATCH 2/8] Implement jsonrpc command --- src/Bicep.Cli/Arguments/JsonRpcArguments.cs | 58 ++----------- src/Bicep.Cli/Program.cs | 92 ++++++++++++++------- src/Bicep.Cli/Services/ArgumentParser.cs | 1 - 3 files changed, 65 insertions(+), 86 deletions(-) diff --git a/src/Bicep.Cli/Arguments/JsonRpcArguments.cs b/src/Bicep.Cli/Arguments/JsonRpcArguments.cs index 44097d00feb..4b9d6026e51 100644 --- a/src/Bicep.Cli/Arguments/JsonRpcArguments.cs +++ b/src/Bicep.Cli/Arguments/JsonRpcArguments.cs @@ -3,59 +3,11 @@ namespace Bicep.Cli.Arguments; -public class JsonRpcArguments : ArgumentsBase +public record JsonRpcArguments { - public JsonRpcArguments(string[] args) : base(Constants.Command.JsonRpc) - { - for (var i = 0; i < args.Length; i++) - { - switch (args[i].ToLowerInvariant()) - { - case "--pipe": - if (args.Length == i + 1) - { - throw new CommandLineException($"The --pipe parameter expects an argument"); - } - if (Pipe is not null) - { - throw new CommandLineException($"The --pipe parameter cannot be specified twice"); - } - Pipe = args[i + 1]; - i++; - break; - case "--socket": - if (args.Length == i + 1) - { - throw new CommandLineException($"The --socket parameter expects an argument"); - } - if (Socket is not null) - { - throw new CommandLineException($"The --socket parameter cannot be specified twice"); - } - if (!int.TryParse(args[i + 1], out var socket)) - { - throw new CommandLineException($"The --socket parameter only accepts integer values"); - } - Socket = socket; - i++; - break; + public string? Pipe { get; init; } - case "--stdio": - if (Stdio is not null) - { - throw new CommandLineException($"The --stdio parameter cannot be specified twice"); - } - Stdio = true; - break; - default: - throw new CommandLineException($"Unrecognized parameter \"{args[i]}\""); - } - } - } + public int? Socket { get; init; } - public string? Pipe { get; set; } - - public int? Socket { get; set; } - - public bool? Stdio { get; set; } -} + public bool? Stdio { get; init; } +} \ No newline at end of file diff --git a/src/Bicep.Cli/Program.cs b/src/Bicep.Cli/Program.cs index f1c05aea4e1..066fc0157a8 100644 --- a/src/Bicep.Cli/Program.cs +++ b/src/Bicep.Cli/Program.cs @@ -3,6 +3,7 @@ using System.CommandLine; using System.CommandLine.Help; +using System.CommandLine.Invocation; using System.Diagnostics; using System.Runtime; using Bicep.Cli.Arguments; @@ -24,6 +25,14 @@ // Avoid naming conflict with Bicep.Cli.Commands.RootCommand using SclRootCommand = System.CommandLine.RootCommand; +public class HelpExamplesAction : SynchronousCommandLineAction +{ + public override int Invoke(ParseResult parseResult) + { + throw new NotImplementedException(); + } +} + namespace Bicep.Cli { public record IOContext( @@ -100,9 +109,7 @@ private SclRootCommand BuildCommandLine(CancellationToken cancellationToken) { var rootCommand = new SclRootCommand("Bicep CLI") { - // Collect unknown tokens so we can produce a custom error message that matches - // the format of the original CLI instead of letting System.CommandLine error out. - TreatUnmatchedTokensAsErrors = false, + TreatUnmatchedTokensAsErrors = true, }; // System.CommandLine adds --version and --help to RootCommand by default. @@ -113,18 +120,12 @@ private SclRootCommand BuildCommandLine(CancellationToken cancellationToken) { rootCommand.Options.Remove(builtInVersion); } - var builtInHelp = rootCommand.Options.OfType().FirstOrDefault(); - if (builtInHelp is not null) - { - rootCommand.Options.Remove(builtInHelp); - } - var helpOption = new Option("--help", "-?", "-h") { Description = "Show help and usage information." }; var versionOption = new Option("--version", "-v") { Description = "Show version information." }; var licenseOption = new Option("--license") { Description = "Print license information." }; var thirdPartyNoticesOption = new Option("--third-party-notices") { Description = "Print third-party notice information." }; - rootCommand.Add(helpOption); + // rootCommand.Add(helpOption); rootCommand.Add(versionOption); rootCommand.Add(licenseOption); rootCommand.Add(thirdPartyNoticesOption); @@ -135,13 +136,6 @@ private SclRootCommand BuildCommandLine(CancellationToken cancellationToken) var unmatched = pr.UnmatchedTokens; - // Show help when explicitly requested or when called with no arguments at all. - if (pr.GetValue(helpOption) || unmatched.Count == 0) - { - bicepRootCommand.PrintHelp(); - return 0; - } - if (pr.GetValue(versionOption)) { return bicepRootCommand.Run(new RootArguments("--version", Constants.Command.Root)); @@ -222,10 +216,7 @@ await io.Error.Writer.WriteLineAsync( "Lints a .bicep file.", args => services.GetRequiredService().RunAsync(new LintArguments(args)))); - rootCommand.Add(LegacyCommand( - Constants.Command.JsonRpc, - "Starts the Bicep JSON-RPC server.", - args => services.GetRequiredService().RunAsync(new JsonRpcArguments(args), cancellationToken))); + rootCommand.Add(CreateJsonRpcCommand()); rootCommand.Add(LegacyCommand( Constants.Command.LocalDeploy, @@ -260,6 +251,54 @@ await io.Error.Writer.WriteLineAsync( return rootCommand; } + private Command CreateJsonRpcCommand() + { + var command = new Command(Constants.Command.JsonRpc, "Starts the Bicep JSON-RPC server."); + + var pipeOption = new Option("--pipe") + { + Description = "Connect via a named pipe with the given name.", + }; + var socketOption = new Option("--socket") + { + Description = "Connect via a TCP socket on the specified port.", + }; + var stdioOption = new Option("--stdio") + { + Description = "Use standard input/output for communication (default when no transport is specified).", + }; + + command.Add(pipeOption); + command.Add(socketOption); + command.Add(stdioOption); + + command.Validators.Add(result => + { + var hasPipe = result.GetResult(pipeOption) is { Implicit: false }; + var hasSocket = result.GetResult(socketOption) is { Implicit: false }; + var hasStdio = result.GetResult(stdioOption) is { Implicit: false }; + + if ((hasPipe ? 1 : 0) + (hasSocket ? 1 : 0) + (hasStdio ? 1 : 0) > 1) + { + result.AddError("Only one of --pipe, --socket, or --stdio may be specified."); + } + }); + + command.SetAction(async (result, cancellationToken) => + { + JsonRpcArguments args = new() + { + Pipe = result.GetValue(pipeOption), + Socket = result.GetValue(socketOption), + Stdio = result.GetValue(stdioOption) + }; + + return await services.GetRequiredService().RunAsync(args, cancellationToken); + }); + + return command; + } + /// /// Creates a pass-through command stub that forwards all unrecognized tokens to an /// existing argument class and command handler. System.CommandLine's built-in options @@ -278,17 +317,6 @@ private Command LegacyCommand(string name, string description, Func().FirstOrDefault(); - if (builtInHelp is not null) - { - command.Options.Remove(builtInHelp); - } - - var helpOption = new Option("--help", "-?", "-h") { Description = "Show help and usage information." }; - command.Add(helpOption); - command.SetAction(async (ParseResult pr, CancellationToken ct) => { try diff --git a/src/Bicep.Cli/Services/ArgumentParser.cs b/src/Bicep.Cli/Services/ArgumentParser.cs index 7d8b464c536..de67be2b218 100644 --- a/src/Bicep.Cli/Services/ArgumentParser.cs +++ b/src/Bicep.Cli/Services/ArgumentParser.cs @@ -39,7 +39,6 @@ public static class ArgumentParser Constants.Command.Publish => new PublishArguments(args[1..]), Constants.Command.Restore => new RestoreArguments(args[1..]), Constants.Command.Lint => new LintArguments(args[1..]), - Constants.Command.JsonRpc => new JsonRpcArguments(args[1..]), Constants.Command.LocalDeploy => new LocalDeployArguments(args[1..]), Constants.Command.Snapshot => new SnapshotArguments(args[1..]), Constants.Command.Deploy => new DeployArguments(args[1..]), From 8f48119c9e8f4bbf8de8255b7048786b73b8c14c Mon Sep 17 00:00:00 2001 From: Anthony Martin <38542602+anthony-c-martin@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:38:43 -0400 Subject: [PATCH 3/8] More commands --- src/Bicep.Cli/Arguments/DeployArguments.cs | 29 +-- .../Arguments/DeployArgumentsBase.cs | 62 +----- src/Bicep.Cli/Arguments/JsonRpcArguments.cs | 12 +- src/Bicep.Cli/Arguments/TeardownArguments.cs | 12 +- src/Bicep.Cli/Arguments/WhatIfArguments.cs | 12 +- .../Commands/DeploymentsCommandsBase.cs | 4 +- src/Bicep.Cli/Program.cs | 186 +++++++++++++++--- src/Bicep.Cli/Services/ArgumentParser.cs | 3 - 8 files changed, 186 insertions(+), 134 deletions(-) diff --git a/src/Bicep.Cli/Arguments/DeployArguments.cs b/src/Bicep.Cli/Arguments/DeployArguments.cs index e9378582d4b..78042d9ae38 100644 --- a/src/Bicep.Cli/Arguments/DeployArguments.cs +++ b/src/Bicep.Cli/Arguments/DeployArguments.cs @@ -1,30 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections.Immutable; using Bicep.Cli.Helpers; namespace Bicep.Cli.Arguments; -public class DeployArguments : DeployArgumentsBase -{ - public DeployArguments(string[] args) : base(args, Constants.Command.Deploy) - { - } - - protected override void ParseAdditionalArgument(string[] args, ref int i) - { - switch (args[i].ToLowerInvariant()) - { - case ArgumentConstants.OutputFormat: - ArgumentHelper.ValidateNotAlreadySet(ArgumentConstants.OutputFormat, OutputFormat); - OutputFormat = ArgumentHelper.GetEnumValueWithValidation(ArgumentConstants.OutputFormat, args, i); - i++; - break; - default: - base.ParseAdditionalArgument(args, ref i); - break; - } - } - - public DeploymentOutputFormat? OutputFormat { get; private set; } -} +public record DeployArguments( + string InputFile, + bool NoRestore, + ImmutableDictionary AdditionalArguments, + DeploymentOutputFormat? OutputFormat) : DeployArgumentsBase(InputFile, NoRestore, AdditionalArguments); \ No newline at end of file diff --git a/src/Bicep.Cli/Arguments/DeployArgumentsBase.cs b/src/Bicep.Cli/Arguments/DeployArgumentsBase.cs index b199cb20486..b3f180725c0 100644 --- a/src/Bicep.Cli/Arguments/DeployArgumentsBase.cs +++ b/src/Bicep.Cli/Arguments/DeployArgumentsBase.cs @@ -1,61 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace Bicep.Cli.Arguments; - -public abstract class DeployArgumentsBase : ArgumentsBase, IInputArguments -{ - protected virtual void ParseAdditionalArgument(string[] args, ref int i) - { - throw new CommandLineException($"Unrecognized parameter \"{args[i]}\""); - } - - public DeployArgumentsBase(string[] args, string commandName) : base(commandName) - { - for (var i = 0; i < args.Length; i++) - { - switch (args[i].ToLowerInvariant()) - { - case "--no-restore": - NoRestore = true; - break; - - case { } when args[i].StartsWith(ArgumentConstants.CliArgPrefix): - var key = args[i][ArgumentConstants.CliArgPrefix.Length..]; - - if (AdditionalArguments.ContainsKey(key)) - { - throw new CommandLineException($"Parameter \"{args[i]}\" cannot be specified multiple times."); - } +using System.Collections.Immutable; - AdditionalArguments[key] = args[i + 1]; - i++; - break; - - default: - if (args[i].StartsWith("--")) - { - ParseAdditionalArgument(args, ref i); - break; - } - if (InputFile is not null) - { - throw new CommandLineException($"The parameters file path cannot be specified multiple times"); - } - InputFile = args[i]; - break; - } - } - - if (InputFile is null) - { - throw new CommandLineException($"The parameters file path was not specified"); - } - } - - public string InputFile { get; } - - public bool NoRestore { get; } +namespace Bicep.Cli.Arguments; - public Dictionary AdditionalArguments { get; } = []; -} +public abstract record DeployArgumentsBase( + string InputFile, + bool NoRestore, + ImmutableDictionary AdditionalArguments) : IInputArguments; diff --git a/src/Bicep.Cli/Arguments/JsonRpcArguments.cs b/src/Bicep.Cli/Arguments/JsonRpcArguments.cs index 4b9d6026e51..9e52f3aa6cc 100644 --- a/src/Bicep.Cli/Arguments/JsonRpcArguments.cs +++ b/src/Bicep.Cli/Arguments/JsonRpcArguments.cs @@ -3,11 +3,7 @@ namespace Bicep.Cli.Arguments; -public record JsonRpcArguments -{ - public string? Pipe { get; init; } - - public int? Socket { get; init; } - - public bool? Stdio { get; init; } -} \ No newline at end of file +public record JsonRpcArguments( + string? Pipe, + int? Socket, + bool? Stdio); \ No newline at end of file diff --git a/src/Bicep.Cli/Arguments/TeardownArguments.cs b/src/Bicep.Cli/Arguments/TeardownArguments.cs index 51f23ca677b..5368c7fa269 100644 --- a/src/Bicep.Cli/Arguments/TeardownArguments.cs +++ b/src/Bicep.Cli/Arguments/TeardownArguments.cs @@ -1,11 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections.Immutable; + namespace Bicep.Cli.Arguments; -public class TeardownArguments : DeployArgumentsBase -{ - public TeardownArguments(string[] args) : base(args, Constants.Command.Teardown) - { - } -} +public record TeardownArguments( + string InputFile, + bool NoRestore, + ImmutableDictionary AdditionalArguments) : DeployArgumentsBase(InputFile, NoRestore, AdditionalArguments); \ No newline at end of file diff --git a/src/Bicep.Cli/Arguments/WhatIfArguments.cs b/src/Bicep.Cli/Arguments/WhatIfArguments.cs index 2a3ed520efe..40405b81d81 100644 --- a/src/Bicep.Cli/Arguments/WhatIfArguments.cs +++ b/src/Bicep.Cli/Arguments/WhatIfArguments.cs @@ -1,11 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections.Immutable; + namespace Bicep.Cli.Arguments; -public class WhatIfArguments : DeployArgumentsBase -{ - public WhatIfArguments(string[] args) : base(args, Constants.Command.WhatIf) - { - } -} +public record WhatIfArguments( + string InputFile, + bool NoRestore, + ImmutableDictionary AdditionalArguments) : DeployArgumentsBase(InputFile, NoRestore, AdditionalArguments); \ No newline at end of file diff --git a/src/Bicep.Cli/Commands/DeploymentsCommandsBase.cs b/src/Bicep.Cli/Commands/DeploymentsCommandsBase.cs index fb6e4541729..35d56dadd6a 100644 --- a/src/Bicep.Cli/Commands/DeploymentsCommandsBase.cs +++ b/src/Bicep.Cli/Commands/DeploymentsCommandsBase.cs @@ -29,12 +29,12 @@ public async Task RunAsync(TArgs args, CancellationToken cancellationToken) if (!model.Features.DeployCommandsEnabled) { - throw new CommandLineException($"The '{nameof(ExperimentalFeaturesEnabled.DeployCommands)}' experimental feature must be enabled to use the '{args.CommandName}' command."); + throw new CommandLineException($"The '{nameof(ExperimentalFeaturesEnabled.DeployCommands)}' experimental feature must be enabled."); } if (!model.HasAzureTargetScope()) { - throw new CommandLineException($"The '{args.CommandName}' command only supports Bicep files with an Azure target scope."); + throw new CommandLineException($"Only Bicep files with an Azure target scope are supported."); } CommandHelper.LogExperimentalWarning(logger, compilation); diff --git a/src/Bicep.Cli/Program.cs b/src/Bicep.Cli/Program.cs index 066fc0157a8..9f8a3cf2efa 100644 --- a/src/Bicep.Cli/Program.cs +++ b/src/Bicep.Cli/Program.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections.Immutable; using System.CommandLine; using System.CommandLine.Help; using System.CommandLine.Invocation; @@ -228,20 +229,11 @@ await io.Error.Writer.WriteLineAsync( "Creates an extension snapshot.", args => services.GetRequiredService().RunAsync(new SnapshotArguments(args), cancellationToken))); - rootCommand.Add(LegacyCommand( - Constants.Command.Deploy, - "Deploys infrastructure using a .bicepparam file.", - args => services.GetRequiredService().RunAsync(new DeployArguments(args), cancellationToken))); + rootCommand.Add(CreateDeployCommand()); - rootCommand.Add(LegacyCommand( - Constants.Command.WhatIf, - "Previews the changes a deployment would make.", - args => services.GetRequiredService().RunAsync(new WhatIfArguments(args), cancellationToken))); + rootCommand.Add(CreateWhatIfCommand()); - rootCommand.Add(LegacyCommand( - Constants.Command.Teardown, - "Tears down resources deployed by a .bicepparam file.", - args => services.GetRequiredService().RunAsync(new TeardownArguments(args), cancellationToken))); + rootCommand.Add(CreateTeardownCommand()); rootCommand.Add(LegacyCommand( Constants.Command.Console, @@ -286,12 +278,10 @@ private Command CreateJsonRpcCommand() command.SetAction(async (result, cancellationToken) => { - JsonRpcArguments args = new() - { - Pipe = result.GetValue(pipeOption), - Socket = result.GetValue(socketOption), - Stdio = result.GetValue(stdioOption) - }; + JsonRpcArguments args = new( + Pipe: result.GetValue(pipeOption), + Socket: result.GetValue(socketOption), + Stdio: result.GetValue(stdioOption)); return await services.GetRequiredService().RunAsync(args, cancellationToken); }); @@ -299,6 +289,152 @@ private Command CreateJsonRpcCommand() return command; } + private Command CreateDeployCommand() + { + var command = new Command(Constants.Command.Deploy, "Deploys infrastructure using a .bicepparam file.") + { + TreatUnmatchedTokensAsErrors = false, + }; + + var parametersFileArgument = new Argument("parameters-file") + { + Description = "The path to the .bicepparam file.", + }; + var noRestoreOption = new Option("--no-restore") + { + Description = "Do not restore modules prior to deploying.", + }; + var formatOption = new Option("--format") + { + Description = "Output format for deployment results (Default, Json).", + }; + + command.Add(parametersFileArgument); + command.Add(noRestoreOption); + command.Add(formatOption); + + command.SetAction((result, ct) => RunCommandAsync(async () => + { + var additionalArguments = ParseAdditionalArguments(result.UnmatchedTokens); + var args = new DeployArguments( + result.GetValue(parametersFileArgument)!, + result.GetValue(noRestoreOption), + additionalArguments, + result.GetValue(formatOption)); + + return await services.GetRequiredService().RunAsync(args, ct); + })); + + return command; + } + + private Command CreateWhatIfCommand() + { + var command = new Command(Constants.Command.WhatIf, "Previews the changes a deployment would make.") + { + TreatUnmatchedTokensAsErrors = false, + }; + + var parametersFileArgument = new Argument("parameters-file") + { + Description = "The path to the .bicepparam file.", + }; + var noRestoreOption = new Option("--no-restore") + { + Description = "Do not restore modules prior to running what-if.", + }; + + command.Add(parametersFileArgument); + command.Add(noRestoreOption); + + command.SetAction((result, ct) => RunCommandAsync(async () => + { + var additionalArguments = ParseAdditionalArguments(result.UnmatchedTokens); + var args = new WhatIfArguments( + result.GetValue(parametersFileArgument)!, + result.GetValue(noRestoreOption), + additionalArguments); + + return await services.GetRequiredService().RunAsync(args, ct); + })); + + return command; + } + + private Command CreateTeardownCommand() + { + var command = new Command(Constants.Command.Teardown, "Tears down resources deployed by a .bicepparam file.") + { + TreatUnmatchedTokensAsErrors = false, + }; + + var parametersFileArgument = new Argument("parameters-file") + { + Description = "The path to the .bicepparam file.", + }; + var noRestoreOption = new Option("--no-restore") + { + Description = "Do not restore modules prior to tearing down.", + }; + + command.Add(parametersFileArgument); + command.Add(noRestoreOption); + + command.SetAction((result, ct) => RunCommandAsync(async () => + { + var additionalArguments = ParseAdditionalArguments(result.UnmatchedTokens); + var args = new TeardownArguments( + result.GetValue(parametersFileArgument)!, + result.GetValue(noRestoreOption), + additionalArguments); + + return await services.GetRequiredService().RunAsync(args, ct); + })); + + return command; + } + + private async Task RunCommandAsync(Func> action) + { + try + { + return await action(); + } + catch (BicepException exception) + { + await io.Error.Writer.WriteLineAsync(exception.Message); + return 1; + } + } + + private static ImmutableDictionary ParseAdditionalArguments(IReadOnlyList unmatchedTokens) + { + var additionalArguments = new Dictionary(); + for (var i = 0; i < unmatchedTokens.Count; i++) + { + var token = unmatchedTokens[i]; + if (token.StartsWith(ArgumentConstants.CliArgPrefix, StringComparison.OrdinalIgnoreCase)) + { + var key = token[ArgumentConstants.CliArgPrefix.Length..]; + if (additionalArguments.ContainsKey(key)) + { + throw new CommandLineException($"Parameter \"{token}\" cannot be specified multiple times."); + } + if (i + 1 >= unmatchedTokens.Count) + { + throw new CommandLineException($"Parameter \"{token}\" requires a value."); + } + additionalArguments[key] = unmatchedTokens[++i]; + } + else + { + throw new CommandLineException($"Unrecognized parameter \"{token}\""); + } + } + + return additionalArguments.ToImmutableDictionary(); + } + /// /// Creates a pass-through command stub that forwards all unrecognized tokens to an /// existing argument class and command handler. System.CommandLine's built-in options @@ -317,18 +453,8 @@ private Command LegacyCommand(string name, string description, Func - { - try - { - return await handler(pr.UnmatchedTokens.ToArray()); - } - catch (BicepException exception) - { - await io.Error.Writer.WriteLineAsync(exception.Message); - return 1; - } - }); + command.SetAction((ParseResult pr, CancellationToken ct) => + RunCommandAsync(() => handler(pr.UnmatchedTokens.ToArray()))); return command; } diff --git a/src/Bicep.Cli/Services/ArgumentParser.cs b/src/Bicep.Cli/Services/ArgumentParser.cs index de67be2b218..2067a94c85f 100644 --- a/src/Bicep.Cli/Services/ArgumentParser.cs +++ b/src/Bicep.Cli/Services/ArgumentParser.cs @@ -41,9 +41,6 @@ public static class ArgumentParser Constants.Command.Lint => new LintArguments(args[1..]), Constants.Command.LocalDeploy => new LocalDeployArguments(args[1..]), Constants.Command.Snapshot => new SnapshotArguments(args[1..]), - Constants.Command.Deploy => new DeployArguments(args[1..]), - Constants.Command.WhatIf => new WhatIfArguments(args[1..]), - Constants.Command.Teardown => new TeardownArguments(args[1..]), Constants.Command.Console => new ConsoleArguments(args[1..]), _ => null, }; From 848e70309207333a7ab8af41fa96e5379b05978a Mon Sep 17 00:00:00 2001 From: Anthony Martin <38542602+anthony-c-martin@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:49:30 -0400 Subject: [PATCH 4/8] More --- src/Bicep.Cli/Arguments/ConsoleArguments.cs | 8 +- .../Arguments/LocalDeployArguments.cs | 53 +------- src/Bicep.Cli/Arguments/SnapshotArguments.cs | 94 ++----------- src/Bicep.Cli/Program.cs | 123 +++++++++++++++--- src/Bicep.Cli/Services/ArgumentParser.cs | 3 - 5 files changed, 122 insertions(+), 159 deletions(-) diff --git a/src/Bicep.Cli/Arguments/ConsoleArguments.cs b/src/Bicep.Cli/Arguments/ConsoleArguments.cs index d6dfe50da2b..e24cdecb3e0 100644 --- a/src/Bicep.Cli/Arguments/ConsoleArguments.cs +++ b/src/Bicep.Cli/Arguments/ConsoleArguments.cs @@ -3,10 +3,4 @@ namespace Bicep.Cli.Arguments; -public class ConsoleArguments : ArgumentsBase -{ - public ConsoleArguments(string[] args) : base(Constants.Command.Console) - { - // Currently no options. Future flags (e.g. --subscription, --resource-group) can be added. - } -} +public record ConsoleArguments(); diff --git a/src/Bicep.Cli/Arguments/LocalDeployArguments.cs b/src/Bicep.Cli/Arguments/LocalDeployArguments.cs index 3c3ca58f90c..82f94359cfb 100644 --- a/src/Bicep.Cli/Arguments/LocalDeployArguments.cs +++ b/src/Bicep.Cli/Arguments/LocalDeployArguments.cs @@ -1,52 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Bicep.Cli.Helpers; +namespace Bicep.Cli.Arguments; -namespace Bicep.Cli.Arguments -{ - public class LocalDeployArguments : ArgumentsBase - { - public LocalDeployArguments(string[] args) : base(Constants.Command.LocalDeploy) - { - for (var i = 0; i < args.Length; i++) - { - switch (args[i].ToLowerInvariant()) - { - case "--no-restore": - NoRestore = true; - break; - - case ArgumentConstants.OutputFormat: - ArgumentHelper.ValidateNotAlreadySet(ArgumentConstants.OutputFormat, OutputFormat); - OutputFormat = ArgumentHelper.GetEnumValueWithValidation(ArgumentConstants.OutputFormat, args, i); - i++; - break; - - default: - if (args[i].StartsWith("--")) - { - throw new CommandLineException($"Unrecognized parameter \"{args[i]}\""); - } - if (ParamsFile is not null) - { - throw new CommandLineException($"The parameters file path cannot be specified multiple times"); - } - ParamsFile = args[i]; - break; - } - } - - if (ParamsFile is null) - { - throw new CommandLineException($"The parameters file path was not specified"); - } - } - - public string ParamsFile { get; } - - public bool NoRestore { get; } - - public DeploymentOutputFormat? OutputFormat { get; } - } -} +public record LocalDeployArguments( + string ParamsFile, + bool NoRestore, + DeploymentOutputFormat? OutputFormat); diff --git a/src/Bicep.Cli/Arguments/SnapshotArguments.cs b/src/Bicep.Cli/Arguments/SnapshotArguments.cs index fc08bfb892e..fda42c28dc4 100644 --- a/src/Bicep.Cli/Arguments/SnapshotArguments.cs +++ b/src/Bicep.Cli/Arguments/SnapshotArguments.cs @@ -1,98 +1,20 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Collections.Immutable; -using Bicep.Cli.Helpers; - namespace Bicep.Cli.Arguments; -public class SnapshotArguments : ArgumentsBase, IInputArguments +public record SnapshotArguments( + string InputFile, + SnapshotArguments.SnapshotMode? Mode, + string? TenantId, + string? SubscriptionId, + string? Location, + string? ResourceGroup, + string? DeploymentName) : IInputArguments { - private const string TenantIdArgument = "--tenant-id"; - private const string SubscriptionIdArgument = "--subscription-id"; - private const string LocationArgument = "--location"; - private const string ResourceGroupArgument = "--resource-group"; - private const string DeploymentNameArgument = "--deployment-name"; - public enum SnapshotMode { Overwrite, Validate, } - - public SnapshotArguments(string[] args) : base(Constants.Command.Snapshot) - { - for (var i = 0; i < args.Length; i++) - { - switch (args[i].ToLowerInvariant()) - { - case ArgumentConstants.Mode: - ArgumentHelper.ValidateNotAlreadySet(ArgumentConstants.Mode, Mode); - Mode = ArgumentHelper.GetEnumValueWithValidation(ArgumentConstants.Mode, args, i); - i++; - break; - - case TenantIdArgument: - ArgumentHelper.ValidateNotAlreadySet(TenantIdArgument, TenantId); - TenantId = ArgumentHelper.GetValueWithValidation(TenantIdArgument, args, i); - i++; - break; - - case SubscriptionIdArgument: - ArgumentHelper.ValidateNotAlreadySet(SubscriptionIdArgument, SubscriptionId); - SubscriptionId = ArgumentHelper.GetValueWithValidation(SubscriptionIdArgument, args, i); - i++; - break; - - case ResourceGroupArgument: - ArgumentHelper.ValidateNotAlreadySet(ResourceGroupArgument, ResourceGroup); - ResourceGroup = ArgumentHelper.GetValueWithValidation(ResourceGroupArgument, args, i); - i++; - break; - - case LocationArgument: - ArgumentHelper.ValidateNotAlreadySet(LocationArgument, Location); - Location = ArgumentHelper.GetValueWithValidation(LocationArgument, args, i); - i++; - break; - - case DeploymentNameArgument: - ArgumentHelper.ValidateNotAlreadySet(DeploymentNameArgument, DeploymentName); - DeploymentName = ArgumentHelper.GetValueWithValidation(DeploymentNameArgument, args, i); - i++; - break; - - default: - if (args[i].StartsWith("--")) - { - throw new CommandLineException($"Unrecognized parameter \"{args[i]}\""); - } - if (InputFile is not null) - { - throw new CommandLineException($"The input file path cannot be specified multiple times"); - } - InputFile = args[i]; - break; - } - } - - if (InputFile is null) - { - throw new CommandLineException($"The input file path was not specified"); - } - } - - public string InputFile { get; } - - public SnapshotMode? Mode { get; } - - public string? TenantId { get; } - - public string? SubscriptionId { get; } - - public string? Location { get; } - - public string? ResourceGroup { get; } - - public string? DeploymentName { get; } } diff --git a/src/Bicep.Cli/Program.cs b/src/Bicep.Cli/Program.cs index 9f8a3cf2efa..3a4b808abc0 100644 --- a/src/Bicep.Cli/Program.cs +++ b/src/Bicep.Cli/Program.cs @@ -219,15 +219,9 @@ await io.Error.Writer.WriteLineAsync( rootCommand.Add(CreateJsonRpcCommand()); - rootCommand.Add(LegacyCommand( - Constants.Command.LocalDeploy, - "Performs a local deployment.", - args => services.GetRequiredService().RunAsync(new LocalDeployArguments(args), cancellationToken))); + rootCommand.Add(CreateLocalDeployCommand()); - rootCommand.Add(LegacyCommand( - Constants.Command.Snapshot, - "Creates an extension snapshot.", - args => services.GetRequiredService().RunAsync(new SnapshotArguments(args), cancellationToken))); + rootCommand.Add(CreateSnapshotCommand()); rootCommand.Add(CreateDeployCommand()); @@ -235,10 +229,7 @@ await io.Error.Writer.WriteLineAsync( rootCommand.Add(CreateTeardownCommand()); - rootCommand.Add(LegacyCommand( - Constants.Command.Console, - "Opens an interactive Bicep console.", - args => services.GetRequiredService().RunAsync(new ConsoleArguments(args)))); + rootCommand.Add(CreateConsoleCommand()); return rootCommand; } @@ -317,7 +308,7 @@ private Command CreateDeployCommand() { var additionalArguments = ParseAdditionalArguments(result.UnmatchedTokens); var args = new DeployArguments( - result.GetValue(parametersFileArgument)!, + result.GetRequiredValue(parametersFileArgument), result.GetValue(noRestoreOption), additionalArguments, result.GetValue(formatOption)); @@ -351,7 +342,7 @@ private Command CreateWhatIfCommand() { var additionalArguments = ParseAdditionalArguments(result.UnmatchedTokens); var args = new WhatIfArguments( - result.GetValue(parametersFileArgument)!, + result.GetRequiredValue(parametersFileArgument), result.GetValue(noRestoreOption), additionalArguments); @@ -384,7 +375,7 @@ private Command CreateTeardownCommand() { var additionalArguments = ParseAdditionalArguments(result.UnmatchedTokens); var args = new TeardownArguments( - result.GetValue(parametersFileArgument)!, + result.GetRequiredValue(parametersFileArgument), result.GetValue(noRestoreOption), additionalArguments); @@ -394,6 +385,108 @@ private Command CreateTeardownCommand() return command; } + private Command CreateLocalDeployCommand() + { + var command = new Command(Constants.Command.LocalDeploy, "Performs a local deployment."); + + var paramsFileArgument = new Argument("parameters-file") + { + Description = "The path to the .bicepparam file.", + }; + var noRestoreOption = new Option("--no-restore") + { + Description = "Do not restore modules prior to deploying.", + }; + var formatOption = new Option("--format") + { + Description = "Output format for deployment results (Default, Json).", + }; + + command.Add(paramsFileArgument); + command.Add(noRestoreOption); + command.Add(formatOption); + + command.SetAction((result, ct) => RunCommandAsync(async () => + { + var args = new LocalDeployArguments( + result.GetRequiredValue(paramsFileArgument), + result.GetValue(noRestoreOption), + result.GetValue(formatOption)); + + return await services.GetRequiredService().RunAsync(args, ct); + })); + + return command; + } + + private Command CreateSnapshotCommand() + { + var command = new Command(Constants.Command.Snapshot, "Creates an extension snapshot."); + + var inputFileArgument = new Argument("input-file") + { + Description = "The path to the .bicepparam file.", + }; + var modeOption = new Option("--mode") + { + Description = "Snapshot mode (Overwrite, Validate).", + }; + var tenantIdOption = new Option("--tenant-id") + { + Description = "The tenant ID.", + }; + var subscriptionIdOption = new Option("--subscription-id") + { + Description = "The subscription ID.", + }; + var locationOption = new Option("--location") + { + Description = "The location.", + }; + var resourceGroupOption = new Option("--resource-group") + { + Description = "The resource group.", + }; + var deploymentNameOption = new Option("--deployment-name") + { + Description = "The deployment name.", + }; + + command.Add(inputFileArgument); + command.Add(modeOption); + command.Add(tenantIdOption); + command.Add(subscriptionIdOption); + command.Add(locationOption); + command.Add(resourceGroupOption); + command.Add(deploymentNameOption); + + command.SetAction((result, ct) => RunCommandAsync(async () => + { + var args = new SnapshotArguments( + result.GetRequiredValue(inputFileArgument), + result.GetValue(modeOption), + result.GetValue(tenantIdOption), + result.GetValue(subscriptionIdOption), + result.GetValue(locationOption), + result.GetValue(resourceGroupOption), + result.GetValue(deploymentNameOption)); + + return await services.GetRequiredService().RunAsync(args, ct); + })); + + return command; + } + + private Command CreateConsoleCommand() + { + var command = new Command(Constants.Command.Console, "Opens an interactive Bicep console."); + + command.SetAction((result, ct) => RunCommandAsync( + () => services.GetRequiredService().RunAsync(new ConsoleArguments()))); + + return command; + } + private async Task RunCommandAsync(Func> action) { try diff --git a/src/Bicep.Cli/Services/ArgumentParser.cs b/src/Bicep.Cli/Services/ArgumentParser.cs index 2067a94c85f..743f69ccbf6 100644 --- a/src/Bicep.Cli/Services/ArgumentParser.cs +++ b/src/Bicep.Cli/Services/ArgumentParser.cs @@ -39,9 +39,6 @@ public static class ArgumentParser Constants.Command.Publish => new PublishArguments(args[1..]), Constants.Command.Restore => new RestoreArguments(args[1..]), Constants.Command.Lint => new LintArguments(args[1..]), - Constants.Command.LocalDeploy => new LocalDeployArguments(args[1..]), - Constants.Command.Snapshot => new SnapshotArguments(args[1..]), - Constants.Command.Console => new ConsoleArguments(args[1..]), _ => null, }; } From e55d65dfb3fe4592b90b26f56b81c7e73a90a7be Mon Sep 17 00:00:00 2001 From: Anthony Martin <38542602+anthony-c-martin@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:06:28 -0400 Subject: [PATCH 5/8] More --- .../ArgumentParserTests.cs | 222 ----------- src/Bicep.Cli/Arguments/BuildArguments.cs | 118 +----- .../Arguments/BuildParamsArguments.cs | 142 +------ src/Bicep.Cli/Arguments/DecompileArguments.cs | 82 +--- .../Arguments/DecompileParamsArguments.cs | 98 +---- .../GenerateParametersFileArguments.cs | 126 +----- src/Bicep.Cli/Arguments/TestArguments.cs | 58 +-- src/Bicep.Cli/Program.cs | 363 ++++++++++++++++-- src/Bicep.Cli/Services/ArgumentParser.cs | 6 - 9 files changed, 395 insertions(+), 820 deletions(-) diff --git a/src/Bicep.Cli.UnitTests/ArgumentParserTests.cs b/src/Bicep.Cli.UnitTests/ArgumentParserTests.cs index 93694e9b596..0d09d88502c 100644 --- a/src/Bicep.Cli.UnitTests/ArgumentParserTests.cs +++ b/src/Bicep.Cli.UnitTests/ArgumentParserTests.cs @@ -28,50 +28,6 @@ public void Wrong_command_should_return_null() } [DataTestMethod] - // build - [DataRow(new[] { "build" }, "Either the input file path or the --pattern parameter must be specified")] - [DataRow(new[] { "build", "foo.bicep", "--pattern", "*.bicep" }, "The input file path and the --pattern parameter cannot both be specified")] - [DataRow(new[] { "build", "--pattern" }, "The --pattern parameter expects an argument")] - [DataRow(new[] { "build", "--pattern", "*.bicep", "--stdout" }, "The --stdout parameter cannot be used with the --pattern parameter")] - [DataRow(new[] { "build", "--pattern", "*.bicep", "--outfile", "foo" }, "The --outfile parameter cannot be used with the --pattern parameter")] - [DataRow(new[] { "build", "--stdout" }, "Either the input file path or the --pattern parameter must be specified")] - [DataRow(new[] { "build", "file1", "file2" }, "The input file path cannot be specified multiple times")] - [DataRow(new[] { "build", "--wibble" }, "Unrecognized parameter \"--wibble\"")] - [DataRow(new[] { "build", "--outdir" }, "The --outdir parameter expects an argument")] - [DataRow(new[] { "build", "--outdir", "dir1", "--outdir", "dir2" }, "The --outdir parameter cannot be specified twice")] - [DataRow(new[] { "build", "--outfile" }, "The --outfile parameter expects an argument")] - [DataRow(new[] { "build", "--outfile", "dir1", "--outfile", "dir2" }, "The --outfile parameter cannot be specified twice")] - [DataRow(new[] { "build", "--stdout", "--outfile", "dir1", "file1" }, "The --outfile and --stdout parameters cannot both be used")] - [DataRow(new[] { "build", "--stdout", "--outdir", "dir1", "file1" }, "The --outdir and --stdout parameters cannot both be used")] - [DataRow(new[] { "build", "--outfile", "dir1", "--outdir", "dir2", "file1" }, "The --outdir and --outfile parameters cannot both be used")] - // build-params - [DataRow(new[] { "build-params" }, "Either the input file path or the --pattern parameter must be specified")] - [DataRow(new[] { "build-params", "foo.bicepparam", "--pattern", "*.bicepparam" }, "The input file path and the --pattern parameter cannot both be specified")] - [DataRow(new[] { "build-params", "--pattern" }, "The --pattern parameter expects an argument")] - [DataRow(new[] { "build-params", "--pattern", "*.bicepparam", "--stdout" }, "The --stdout parameter cannot be used with the --pattern parameter")] - [DataRow(new[] { "build-params", "--pattern", "*.bicepparam", "--outfile", "foo" }, "The --outfile parameter cannot be used with the --pattern parameter")] - [DataRow(new[] { "build-params", "--stdout" }, "Either the input file path or the --pattern parameter must be specified")] - [DataRow(new[] { "build-params", "file1", "file2" }, "The input file path cannot be specified multiple times")] - [DataRow(new[] { "build-params", "--wibble" }, "Unrecognized parameter \"--wibble\"")] - [DataRow(new[] { "build-params", "--outdir" }, "The --outdir parameter expects an argument")] - [DataRow(new[] { "build-params", "--outdir", "dir1", "--outdir", "dir2" }, "The --outdir parameter cannot be specified twice")] - [DataRow(new[] { "build-params", "--outfile" }, "The --outfile parameter expects an argument")] - [DataRow(new[] { "build-params", "--outfile", "dir1", "--outfile", "dir2" }, "The --outfile parameter cannot be specified twice")] - [DataRow(new[] { "build-params", "--stdout", "--outfile", "dir1", "file1" }, "The --outfile and --stdout parameters cannot both be used")] - [DataRow(new[] { "build-params", "--stdout", "--outdir", "dir1", "file1" }, "The --outdir and --stdout parameters cannot both be used")] - [DataRow(new[] { "build-params", "--outfile", "dir1", "--outdir", "dir2", "file1" }, "The --outdir and --outfile parameters cannot both be used")] - // decompile - [DataRow(new[] { "decompile" }, "The input file path was not specified")] - [DataRow(new[] { "decompile", "--stdout" }, "The input file path was not specified")] - [DataRow(new[] { "decompile", "file1", "file2" }, "The input file path cannot be specified multiple times")] - [DataRow(new[] { "decompile", "--wibble" }, "Unrecognized parameter \"--wibble\"")] - [DataRow(new[] { "decompile", "--outdir" }, "The --outdir parameter expects an argument")] - [DataRow(new[] { "decompile", "--outdir", "dir1", "--outdir", "dir2" }, "The --outdir parameter cannot be specified twice")] - [DataRow(new[] { "decompile", "--outfile" }, "The --outfile parameter expects an argument")] - [DataRow(new[] { "decompile", "--outfile", "dir1", "--outfile", "dir2" }, "The --outfile parameter cannot be specified twice")] - [DataRow(new[] { "decompile", "--stdout", "--outfile", "dir1", "file1" }, "The --outfile and --stdout parameters cannot both be used")] - [DataRow(new[] { "decompile", "--stdout", "--outdir", "dir1", "file1" }, "The --outdir and --stdout parameters cannot both be used")] - [DataRow(new[] { "decompile", "--outfile", "dir1", "--outdir", "dir2", "file1" }, "The --outdir and --outfile parameters cannot both be used")] // publish [DataRow(new[] { "publish" }, "The input file path was not specified.")] [DataRow(new[] { "publish", "--fake" }, "Unrecognized parameter \"--fake\"")] @@ -103,113 +59,6 @@ public void Invalid_args_trigger_validation_exceptions(string[] parameters, stri parseFunc.Should().Throw().WithMessage(expectedException); } - [TestMethod] - public void BuildOneFile_ShouldReturnOneFile() - { - var arguments = ArgumentParser.TryParse(["build", "file1"], FileSystem); - var buildArguments = (BuildArguments?)arguments; - - // using classic assert so R# understands the value is not null - Assert.IsNotNull(arguments); - buildArguments!.InputFile.Should().Be("file1"); - buildArguments!.OutputToStdOut.Should().BeFalse(); - buildArguments!.OutputDir.Should().BeNull(); - buildArguments!.OutputFile.Should().BeNull(); - buildArguments!.NoRestore.Should().BeFalse(); - } - - [TestMethod] - public void BuildOneFileStdOut_ShouldReturnOneFileAndStdout() - { - var arguments = ArgumentParser.TryParse(["build", "--stdout", "file1"], FileSystem); - var buildArguments = (BuildArguments?)arguments; - - // using classic assert so R# understands the value is not null - Assert.IsNotNull(arguments); - buildArguments!.InputFile.Should().Be("file1"); - buildArguments!.OutputToStdOut.Should().BeTrue(); - buildArguments!.OutputDir.Should().BeNull(); - buildArguments!.OutputFile.Should().BeNull(); - buildArguments!.NoRestore.Should().BeFalse(); - } - - [TestMethod] - public void BuildOneFileStdOut_and_no_restore_ShouldReturnOneFileAndStdout() - { - var arguments = ArgumentParser.TryParse(["build", "--stdout", "--no-restore", "file1"], FileSystem); - var buildArguments = (BuildArguments?)arguments; - - // using classic assert so R# understands the value is not null - Assert.IsNotNull(arguments); - buildArguments!.InputFile.Should().Be("file1"); - buildArguments!.OutputToStdOut.Should().BeTrue(); - buildArguments!.OutputDir.Should().BeNull(); - buildArguments!.OutputFile.Should().BeNull(); - buildArguments!.NoRestore.Should().BeTrue(); - } - - [TestMethod] - public void BuildOneFileStdOutAllCaps_ShouldReturnOneFileAndStdout() - { - var arguments = ArgumentParser.TryParse(["build", "--STDOUT", "file1"], FileSystem); - var buildArguments = (BuildArguments?)arguments; - - // using classic assert so R# understands the value is not null - Assert.IsNotNull(arguments); - buildArguments!.InputFile.Should().Be("file1"); - buildArguments!.OutputToStdOut.Should().BeTrue(); - buildArguments!.OutputDir.Should().BeNull(); - buildArguments!.OutputFile.Should().BeNull(); - buildArguments!.NoRestore.Should().BeFalse(); - } - - [TestMethod] - public void Build_with_outputdir_parameter_should_parse_correctly() - { - // Use relative . to ensure directory exists else the parser will throw. - var arguments = ArgumentParser.TryParse(["build", "--outdir", ".", "file1"], FileSystem); - var buildArguments = (BuildArguments?)arguments; - - // using classic assert so R# understands the value is not null - Assert.IsNotNull(arguments); - buildArguments!.InputFile.Should().Be("file1"); - buildArguments!.OutputToStdOut.Should().BeFalse(); - buildArguments!.OutputDir.Should().Be("."); - buildArguments!.OutputFile.Should().BeNull(); - buildArguments!.NoRestore.Should().BeFalse(); - } - - - [TestMethod] - public void Build_with_outputfile_parameter_should_parse_correctly() - { - var arguments = ArgumentParser.TryParse(["build", "--outfile", "jsonFile", "file1"], FileSystem); - var buildArguments = (BuildArguments?)arguments; - - // using classic assert so R# understands the value is not null - Assert.IsNotNull(arguments); - buildArguments!.InputFile.Should().Be("file1"); - buildArguments!.OutputToStdOut.Should().BeFalse(); - buildArguments!.OutputDir.Should().BeNull(); - buildArguments!.OutputFile.Should().Be("jsonFile"); - buildArguments!.NoRestore.Should().BeFalse(); - } - - [TestMethod] - public void Build_with_outputfile_and_no_restore_parameter_should_parse_correctly() - { - var arguments = ArgumentParser.TryParse(["build", "--outfile", "jsonFile", "file1", "--no-restore"], FileSystem); - var buildArguments = (BuildArguments?)arguments; - - // using classic assert so R# understands the value is not null - Assert.IsNotNull(arguments); - buildArguments!.InputFile.Should().Be("file1"); - buildArguments!.OutputToStdOut.Should().BeFalse(); - buildArguments!.OutputDir.Should().BeNull(); - buildArguments!.OutputFile.Should().Be("jsonFile"); - buildArguments!.NoRestore.Should().BeTrue(); - } - [TestMethod] public void License_argument_should_return_appropriate_RootArguments_instance() { @@ -300,77 +149,6 @@ public void Help_argument_should_return_HelpShortArguments_instance() } } - [TestMethod] - public void DecompileOneFile_ShouldReturnOneFile() - { - var arguments = ArgumentParser.TryParse(["decompile", "file1"], FileSystem); - var buildOrDecompileArguments = (DecompileArguments?)arguments; - - // using classic assert so R# understands the value is not null - Assert.IsNotNull(arguments); - buildOrDecompileArguments!.InputFile.Should().Be("file1"); - buildOrDecompileArguments!.OutputToStdOut.Should().BeFalse(); - buildOrDecompileArguments!.OutputDir.Should().BeNull(); - buildOrDecompileArguments!.OutputFile.Should().BeNull(); - } - - [TestMethod] - public void DecompileOneFileStdOut_ShouldReturnOneFileAndStdout() - { - var arguments = ArgumentParser.TryParse(["decompile", "--stdout", "file1"], FileSystem); - var buildOrDecompileArguments = (DecompileArguments?)arguments; - - // using classic assert so R# understands the value is not null - Assert.IsNotNull(arguments); - buildOrDecompileArguments!.InputFile.Should().Be("file1"); - buildOrDecompileArguments!.OutputToStdOut.Should().BeTrue(); - buildOrDecompileArguments!.OutputDir.Should().BeNull(); - buildOrDecompileArguments!.OutputFile.Should().BeNull(); - } - - [TestMethod] - public void DecompileOneFileStdOutAllCaps_ShouldReturnOneFileAndStdout() - { - var arguments = ArgumentParser.TryParse(["decompile", "--STDOUT", "file1"], FileSystem); - var buildOrDecompileArguments = (DecompileArguments?)arguments; - - // using classic assert so R# understands the value is not null - Assert.IsNotNull(arguments); - buildOrDecompileArguments!.InputFile.Should().Be("file1"); - buildOrDecompileArguments!.OutputToStdOut.Should().BeTrue(); - buildOrDecompileArguments!.OutputDir.Should().BeNull(); - buildOrDecompileArguments!.OutputFile.Should().BeNull(); - } - - [TestMethod] - public void Decompile_with_outputdir_parameter_should_parse_correctly() - { - // Use relative . to ensure directory exists else the parser will throw. - var arguments = ArgumentParser.TryParse(["decompile", "--outdir", ".", "file1"], FileSystem); - var buildOrDecompileArguments = (DecompileArguments?)arguments; - - // using classic assert so R# understands the value is not null - Assert.IsNotNull(arguments); - buildOrDecompileArguments!.InputFile.Should().Be("file1"); - buildOrDecompileArguments!.OutputToStdOut.Should().BeFalse(); - buildOrDecompileArguments!.OutputDir.Should().Be("."); - buildOrDecompileArguments!.OutputFile.Should().BeNull(); - } - - [TestMethod] - public void Decompile_with_outputfile_parameter_should_parse_correctly() - { - var arguments = ArgumentParser.TryParse(["decompile", "--outfile", "jsonFile", "file1"], FileSystem); - var buildOrDecompileArguments = (DecompileArguments?)arguments; - - // using classic assert so R# understands the value is not null - Assert.IsNotNull(arguments); - buildOrDecompileArguments!.InputFile.Should().Be("file1"); - buildOrDecompileArguments!.OutputToStdOut.Should().BeFalse(); - buildOrDecompileArguments!.OutputDir.Should().BeNull(); - buildOrDecompileArguments!.OutputFile.Should().Be("jsonFile"); - } - [TestMethod] public void Publish_should_parse_correctly() { diff --git a/src/Bicep.Cli/Arguments/BuildArguments.cs b/src/Bicep.Cli/Arguments/BuildArguments.cs index 2e8927e12bb..dbfafc4f064 100644 --- a/src/Bicep.Cli/Arguments/BuildArguments.cs +++ b/src/Bicep.Cli/Arguments/BuildArguments.cs @@ -1,121 +1,19 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Collections.Immutable; -using Bicep.Cli.Helpers; using Bicep.Core; using Bicep.IO.Abstraction; namespace Bicep.Cli.Arguments; -public class BuildArguments : ArgumentsBase, IFilePatternInputOutputArguments +public record BuildArguments( + string? InputFile, + bool OutputToStdOut, + bool NoRestore, + string? OutputDir, + string? OutputFile, + string? FilePattern, + DiagnosticsFormat? DiagnosticsFormat) : IFilePatternInputOutputArguments { - public BuildArguments(string[] args) : base(Constants.Command.Build) - { - for (var i = 0; i < args.Length; i++) - { - switch (args[i].ToLowerInvariant()) - { - case "--stdout": - OutputToStdOut = true; - break; - - case "--no-restore": - NoRestore = true; - break; - - case ArgumentConstants.OutDir: - ArgumentHelper.ValidateNotAlreadySet(ArgumentConstants.OutDir, OutputDir); - OutputDir = ArgumentHelper.GetValueWithValidation(ArgumentConstants.OutDir, args, i); - i++; - break; - - case ArgumentConstants.OutFile: - ArgumentHelper.ValidateNotAlreadySet(ArgumentConstants.OutFile, OutputFile); - OutputFile = ArgumentHelper.GetValueWithValidation(ArgumentConstants.OutFile, args, i); - i++; - break; - - case ArgumentConstants.DiagnosticsFormat: - ArgumentHelper.ValidateNotAlreadySet(ArgumentConstants.DiagnosticsFormat, DiagnosticsFormat); - DiagnosticsFormat = ArgumentHelper.ToDiagnosticsFormat(ArgumentHelper.GetValueWithValidation(ArgumentConstants.DiagnosticsFormat, args, i)); - i++; - break; - - case ArgumentConstants.FilePattern: - ArgumentHelper.ValidateNotAlreadySet(ArgumentConstants.FilePattern, FilePattern); - FilePattern = ArgumentHelper.GetValueWithValidation(ArgumentConstants.FilePattern, args, i); - i++; - break; - - default: - if (args[i].StartsWith("--")) - { - throw new CommandLineException($"Unrecognized parameter \"{args[i]}\""); - } - if (InputFile is not null) - { - throw new CommandLineException($"The input file path cannot be specified multiple times"); - } - InputFile = args[i]; - break; - } - } - - if (InputFile is null && FilePattern is null) - { - throw new CommandLineException($"Either the input file path or the {ArgumentConstants.FilePattern} parameter must be specified"); - } - - if (FilePattern != null) - { - if (InputFile is not null) - { - throw new CommandLineException($"The input file path and the {ArgumentConstants.FilePattern} parameter cannot both be specified"); - } - - if (OutputToStdOut) - { - throw new CommandLineException($"The --stdout parameter cannot be used with the {ArgumentConstants.FilePattern} parameter"); - } - - if (OutputFile is not null) - { - throw new CommandLineException($"The {ArgumentConstants.OutFile} parameter cannot be used with the {ArgumentConstants.FilePattern} parameter"); - } - } - - if (OutputToStdOut && OutputDir is not null) - { - throw new CommandLineException($"The {ArgumentConstants.OutDir} and --stdout parameters cannot both be used"); - } - - if (OutputToStdOut && OutputFile is not null) - { - throw new CommandLineException($"The {ArgumentConstants.OutFile} and --stdout parameters cannot both be used"); - } - - if (OutputDir is not null && OutputFile is not null) - { - throw new CommandLineException($"The {ArgumentConstants.OutDir} and {ArgumentConstants.OutFile} parameters cannot both be used"); - } - - DiagnosticsFormat ??= Arguments.DiagnosticsFormat.Default; - } - public static Func OutputFileExtensionResolver => (_, _) => LanguageConstants.JsonFileExtension; - - public bool OutputToStdOut { get; } - - public string? InputFile { get; } - - public string? OutputDir { get; } - - public string? OutputFile { get; } - - public string? FilePattern { get; } - - public DiagnosticsFormat? DiagnosticsFormat { get; } - - public bool NoRestore { get; } } diff --git a/src/Bicep.Cli/Arguments/BuildParamsArguments.cs b/src/Bicep.Cli/Arguments/BuildParamsArguments.cs index 2a5546cf97e..b87d63e9c99 100644 --- a/src/Bicep.Cli/Arguments/BuildParamsArguments.cs +++ b/src/Bicep.Cli/Arguments/BuildParamsArguments.cs @@ -1,144 +1,20 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Collections.Immutable; -using Bicep.Cli.Helpers; using Bicep.Core; using Bicep.IO.Abstraction; namespace Bicep.Cli.Arguments; -public class BuildParamsArguments : ArgumentsBase, IFilePatternInputOutputArguments +public record BuildParamsArguments( + string? InputFile, + bool OutputToStdOut, + bool NoRestore, + string? OutputDir, + string? OutputFile, + string? FilePattern, + string? BicepFile, + DiagnosticsFormat? DiagnosticsFormat) : IFilePatternInputOutputArguments { - public BuildParamsArguments(string[] args) : base(Constants.Command.BuildParams) - { - for (var i = 0; i < args.Length; i++) - { - switch (args[i].ToLowerInvariant()) - { - case "--bicep-file": - if (args.Length == i + 1) - { - throw new CommandLineException($"The --bicep-file parameter expects an argument"); - } - if (BicepFile is not null) - { - throw new CommandLineException($"The --bicep-file parameter cannot be specified twice"); - } - BicepFile = args[i + 1]; - i++; - break; - - case ArgumentConstants.OutDir: - ArgumentHelper.ValidateNotAlreadySet(ArgumentConstants.OutDir, OutputDir); - OutputDir = ArgumentHelper.GetValueWithValidation(ArgumentConstants.OutDir, args, i); - i++; - break; - - case ArgumentConstants.OutFile: - ArgumentHelper.ValidateNotAlreadySet(ArgumentConstants.OutFile, OutputFile); - OutputFile = ArgumentHelper.GetValueWithValidation(ArgumentConstants.OutFile, args, i); - i++; - break; - - case ArgumentConstants.DiagnosticsFormat: - ArgumentHelper.ValidateNotAlreadySet(ArgumentConstants.DiagnosticsFormat, DiagnosticsFormat); - DiagnosticsFormat = ArgumentHelper.ToDiagnosticsFormat(ArgumentHelper.GetValueWithValidation(ArgumentConstants.DiagnosticsFormat, args, i)); - i++; - break; - - case ArgumentConstants.FilePattern: - ArgumentHelper.ValidateNotAlreadySet(ArgumentConstants.FilePattern, FilePattern); - FilePattern = ArgumentHelper.GetValueWithValidation(ArgumentConstants.FilePattern, args, i); - i++; - break; - - case "--stdout": - OutputToStdOut = true; - break; - - case "--no-restore": - NoRestore = true; - break; - - default: - if (args[i].StartsWith("--")) - { - throw new CommandLineException($"Unrecognized parameter \"{args[i]}\""); - } - if (InputFile is not null) - { - throw new CommandLineException($"The input file path cannot be specified multiple times"); - } - InputFile = args[i]; - break; - } - } - - if (InputFile is null && FilePattern is null) - { - throw new CommandLineException($"Either the input file path or the {ArgumentConstants.FilePattern} parameter must be specified"); - } - - if (FilePattern != null) - { - if (InputFile is not null) - { - throw new CommandLineException($"The input file path and the {ArgumentConstants.FilePattern} parameter cannot both be specified"); - } - - if (BicepFile is not null) - { - throw new CommandLineException($"The --bicep-file parameter cannot be used with the {ArgumentConstants.FilePattern} parameter"); - } - - if (OutputToStdOut) - { - throw new CommandLineException($"The --stdout parameter cannot be used with the {ArgumentConstants.FilePattern} parameter"); - } - - if (OutputFile is not null) - { - throw new CommandLineException($"The {ArgumentConstants.OutFile} parameter cannot be used with the {ArgumentConstants.FilePattern} parameter"); - } - } - - if (OutputToStdOut && OutputDir is not null) - { - throw new CommandLineException($"The --outdir and --stdout parameters cannot both be used"); - } - - if (OutputToStdOut && OutputFile is not null) - { - throw new CommandLineException($"The --outfile and --stdout parameters cannot both be used"); - } - - if (OutputDir is not null && OutputFile is not null) - { - throw new CommandLineException($"The --outdir and --outfile parameters cannot both be used"); - } - - if (DiagnosticsFormat is null) - { - DiagnosticsFormat = Arguments.DiagnosticsFormat.Default; - } - } - public static Func OutputFileExtensionResolver => (_, _) => LanguageConstants.JsonFileExtension; - - public bool OutputToStdOut { get; } - - public string? InputFile { get; } - - public string? BicepFile { get; } - - public string? OutputDir { get; } - - public string? OutputFile { get; } - - public string? FilePattern { get; } - - public DiagnosticsFormat? DiagnosticsFormat { get; } - - public bool NoRestore { get; } } diff --git a/src/Bicep.Cli/Arguments/DecompileArguments.cs b/src/Bicep.Cli/Arguments/DecompileArguments.cs index 7a53feb698d..d0ec2f6de2c 100644 --- a/src/Bicep.Cli/Arguments/DecompileArguments.cs +++ b/src/Bicep.Cli/Arguments/DecompileArguments.cs @@ -1,81 +1,17 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Bicep.Cli.Helpers; using Bicep.IO.Abstraction; using LanguageConstants = Bicep.Core.LanguageConstants; -namespace Bicep.Cli.Arguments -{ - public class DecompileArguments : ArgumentsBase, IInputOutputArguments - { - public DecompileArguments(string[] args) : base(Constants.Command.Decompile) - { - for (var i = 0; i < args.Length; i++) - { - switch (args[i].ToLowerInvariant()) - { - case "--stdout": - OutputToStdOut = true; - break; - case "--force": - AllowOverwrite = true; - break; - case ArgumentConstants.OutDir: - ArgumentHelper.ValidateNotAlreadySet(ArgumentConstants.OutDir, OutputDir); - OutputDir = ArgumentHelper.GetValueWithValidation(ArgumentConstants.OutDir, args, i); - i++; - break; - - case ArgumentConstants.OutFile: - ArgumentHelper.ValidateNotAlreadySet(ArgumentConstants.OutFile, OutputFile); - OutputFile = ArgumentHelper.GetValueWithValidation(ArgumentConstants.OutFile, args, i); - i++; - break; - default: - if (args[i].StartsWith("--")) - { - throw new CommandLineException($"Unrecognized parameter \"{args[i]}\""); - } - if (InputFile is not null) - { - throw new CommandLineException($"The input file path cannot be specified multiple times"); - } - InputFile = args[i]; - break; - } - } - - if (InputFile is null) - { - throw new CommandLineException($"The input file path was not specified"); - } - - if (OutputToStdOut && OutputDir is not null) - { - throw new CommandLineException($"The --outdir and --stdout parameters cannot both be used"); - } - if (OutputToStdOut && OutputFile is not null) - { - throw new CommandLineException($"The --outfile and --stdout parameters cannot both be used"); - } - - if (OutputDir is not null && OutputFile is not null) - { - throw new CommandLineException($"The --outdir and --outfile parameters cannot both be used"); - } - } - - public static Func OutputFileExtensionResolver { get; } = (_, _) => LanguageConstants.LanguageFileExtension; +namespace Bicep.Cli.Arguments; - public bool OutputToStdOut { get; } - - public string InputFile { get; } - - public string? OutputDir { get; } - - public string? OutputFile { get; } - - public bool AllowOverwrite { get; } - } +public record DecompileArguments( + string InputFile, + bool OutputToStdOut, + bool AllowOverwrite, + string? OutputDir, + string? OutputFile) : IInputOutputArguments +{ + public static Func OutputFileExtensionResolver { get; } = (_, _) => LanguageConstants.LanguageFileExtension; } diff --git a/src/Bicep.Cli/Arguments/DecompileParamsArguments.cs b/src/Bicep.Cli/Arguments/DecompileParamsArguments.cs index e2b5f461be5..25664fbba82 100644 --- a/src/Bicep.Cli/Arguments/DecompileParamsArguments.cs +++ b/src/Bicep.Cli/Arguments/DecompileParamsArguments.cs @@ -1,96 +1,18 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Bicep.Cli.Helpers; using Bicep.IO.Abstraction; using LanguageConstants = Bicep.Core.LanguageConstants; -namespace Bicep.Cli.Arguments -{ - public class DecompileParamsArguments : ArgumentsBase, IInputOutputArguments - { - public DecompileParamsArguments(string[] args) : base(Constants.Command.DecompileParams) - { - for (var i = 0; i < args.Length; i++) - { - switch (args[i].ToLowerInvariant()) - { - case "--stdout": - OutputToStdOut = true; - break; - case "--force": - AllowOverwrite = true; - break; - case ArgumentConstants.OutDir: - ArgumentHelper.ValidateNotAlreadySet(ArgumentConstants.OutDir, OutputDir); - OutputDir = ArgumentHelper.GetValueWithValidation(ArgumentConstants.OutDir, args, i); - i++; - break; - - case ArgumentConstants.OutFile: - ArgumentHelper.ValidateNotAlreadySet(ArgumentConstants.OutFile, OutputFile); - OutputFile = ArgumentHelper.GetValueWithValidation(ArgumentConstants.OutFile, args, i); - i++; - break; - case "--bicep-file": - if (args.Length == i + 1) - { - throw new CommandLineException($"The --bicep-file parameter expects an argument"); - } - if (BicepFilePath is not null) - { - throw new CommandLineException($"The --bicep-file parameter cannot be specified twice"); - } - BicepFilePath = args[i + 1]; - i++; - break; - - default: - if (args[i].StartsWith("--")) - { - throw new CommandLineException($"Unrecognized parameter \"{args[i]}\""); - } - if (InputFile is not null) - { - throw new CommandLineException($"The input file path cannot be specified multiple times"); - } - InputFile = args[i]; - break; - } - } - - if (InputFile is null) - { - throw new CommandLineException($"The input file path was not specified"); - } - - if (OutputToStdOut && OutputDir is not null) - { - throw new CommandLineException($"The --outdir and --stdout parameters cannot both be used"); - } - if (OutputToStdOut && OutputFile is not null) - { - throw new CommandLineException($"The --outfile and --stdout parameters cannot both be used"); - } - - if (OutputDir is not null && OutputFile is not null) - { - throw new CommandLineException($"The --outdir and --outfile parameters cannot both be used"); - } - } - - public static Func OutputFileExtensionResolver { get; } = (_, _) => LanguageConstants.ParamsFileExtension; +namespace Bicep.Cli.Arguments; - public bool OutputToStdOut { get; } - - public string InputFile { get; } - - public string? OutputDir { get; } - - public string? OutputFile { get; } - - public bool AllowOverwrite { get; } - - public string? BicepFilePath { get; } - } +public record DecompileParamsArguments( + string InputFile, + bool OutputToStdOut, + bool AllowOverwrite, + string? OutputDir, + string? OutputFile, + string? BicepFilePath) : IInputOutputArguments +{ + public static Func OutputFileExtensionResolver { get; } = (_, _) => LanguageConstants.ParamsFileExtension; } diff --git a/src/Bicep.Cli/Arguments/GenerateParametersFileArguments.cs b/src/Bicep.Cli/Arguments/GenerateParametersFileArguments.cs index 2991db5eb0f..b1b018601d0 100644 --- a/src/Bicep.Cli/Arguments/GenerateParametersFileArguments.cs +++ b/src/Bicep.Cli/Arguments/GenerateParametersFileArguments.cs @@ -1,121 +1,25 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Bicep.Cli.Helpers; using Bicep.Core; using Bicep.Core.Emit.Options; using Bicep.IO.Abstraction; -namespace Bicep.Cli.Arguments +namespace Bicep.Cli.Arguments; + +public record GenerateParametersFileArguments( + string InputFile, + bool OutputToStdOut, + bool NoRestore, + string? OutputDir, + string? OutputFile, + OutputFormatOption OutputFormat, + IncludeParamsOption IncludeParams) : IInputOutputArguments { - public class GenerateParametersFileArguments : ArgumentsBase, IInputOutputArguments + public static Func OutputFileExtensionResolver { get; } = (args, _) => args.OutputFormat switch { - public GenerateParametersFileArguments(string[] args) : base(Constants.Command.GenerateParamsFile) - { - for (var i = 0; i < args.Length; i++) - { - switch (args[i].ToLowerInvariant()) - { - case "--stdout": - OutputToStdOut = true; - break; - - case "--no-restore": - NoRestore = true; - break; - - case ArgumentConstants.OutDir: - ArgumentHelper.ValidateNotAlreadySet(ArgumentConstants.OutDir, OutputDir); - OutputDir = ArgumentHelper.GetValueWithValidation(ArgumentConstants.OutDir, args, i); - i++; - break; - - case ArgumentConstants.OutFile: - ArgumentHelper.ValidateNotAlreadySet(ArgumentConstants.OutFile, OutputFile); - OutputFile = ArgumentHelper.GetValueWithValidation(ArgumentConstants.OutFile, args, i); - i++; - break; - - case "--output-format": - if (args.Length == i + 1) - { - throw new CommandLineException($"The --output-format parameter expects an argument"); - } - if (!Enum.TryParse(args[i + 1], true, out var outputFormat) || !Enum.IsDefined(outputFormat)) - { - throw new CommandLineException($"The --output-format parameter only accepts values: {string.Join(" | ", Enum.GetNames(typeof(OutputFormatOption)))}"); - } - OutputFormat = outputFormat; - i++; - break; - - case "--include-params": - if (args.Length == i + 1) - { - throw new CommandLineException($"The --include-params parameter expects an argument"); - } - if (!Enum.TryParse(args[i + 1], true, out var includeParams) || !Enum.IsDefined(includeParams)) - { - throw new CommandLineException($"The --include-params parameter only accepts values: {string.Join(" | ", Enum.GetNames(typeof(IncludeParamsOption)))}"); - } - IncludeParams = includeParams; - i++; - break; - - default: - if (args[i].StartsWith("--")) - { - throw new CommandLineException($"Unrecognized parameter \"{args[i]}\""); - } - if (InputFile is not null) - { - throw new CommandLineException($"The input file path cannot be specified multiple times"); - } - InputFile = args[i]; - break; - } - } - - if (InputFile is null) - { - throw new CommandLineException($"The input file path was not specified"); - } - - if (OutputToStdOut && OutputDir is not null) - { - throw new CommandLineException($"The --outdir and --stdout parameters cannot both be used"); - } - - if (OutputToStdOut && OutputFile is not null) - { - throw new CommandLineException($"The --outfile and --stdout parameters cannot both be used"); - } - - if (OutputDir is not null && OutputFile is not null) - { - throw new CommandLineException($"The --outdir and --outfile parameters cannot both be used"); - } - } - - public static Func OutputFileExtensionResolver { get; } = (args, _) => args.OutputFormat switch - { - OutputFormatOption.Json => $".parameters{LanguageConstants.JsonFileExtension}", - OutputFormatOption.BicepParam => LanguageConstants.ParamsFileExtension, - _ => throw new ArgumentOutOfRangeException(nameof(args.OutputFormat), $"Unsupported output format: {args.OutputFormat}") - }; - - public bool OutputToStdOut { get; } - - public string InputFile { get; } - - public string? OutputDir { get; } - - public string? OutputFile { get; } - - public OutputFormatOption OutputFormat { get; } = OutputFormatOption.Json; - - public IncludeParamsOption IncludeParams { get; } = IncludeParamsOption.RequiredOnly; - - public bool NoRestore { get; } - } + OutputFormatOption.Json => $".parameters{LanguageConstants.JsonFileExtension}", + OutputFormatOption.BicepParam => LanguageConstants.ParamsFileExtension, + _ => throw new ArgumentOutOfRangeException(nameof(args.OutputFormat), $"Unsupported output format: {args.OutputFormat}") + }; } diff --git a/src/Bicep.Cli/Arguments/TestArguments.cs b/src/Bicep.Cli/Arguments/TestArguments.cs index 8e3b3ef9c23..e8007a7987d 100644 --- a/src/Bicep.Cli/Arguments/TestArguments.cs +++ b/src/Bicep.Cli/Arguments/TestArguments.cs @@ -1,57 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Bicep.Cli.Helpers; +namespace Bicep.Cli.Arguments; -namespace Bicep.Cli.Arguments -{ - public class TestArguments : ArgumentsBase, IInputArguments - { - public TestArguments(string[] args) : base(Constants.Command.Test) - { - for (var i = 0; i < args.Length; i++) - { - switch (args[i].ToLowerInvariant()) - { - case "--no-restore": - NoRestore = true; - break; - - case ArgumentConstants.DiagnosticsFormat: - ArgumentHelper.ValidateNotAlreadySet(ArgumentConstants.DiagnosticsFormat, DiagnosticsFormat); - DiagnosticsFormat = ArgumentHelper.ToDiagnosticsFormat(ArgumentHelper.GetValueWithValidation(ArgumentConstants.DiagnosticsFormat, args, i)); - i++; - break; - - default: - if (args[i].StartsWith("--")) - { - throw new CommandLineException($"Unrecognized parameter \"{args[i]}\""); - } - if (InputFile is not null) - { - throw new CommandLineException($"The input file path cannot be specified multiple times"); - } - InputFile = args[i]; - break; - } - } - - if (InputFile is null) - { - throw new CommandLineException($"The input file path was not specified"); - } - - if (DiagnosticsFormat is null) - { - DiagnosticsFormat = Arguments.DiagnosticsFormat.Default; - } - } - - public string InputFile { get; } - - public DiagnosticsFormat? DiagnosticsFormat { get; } - - public bool NoRestore { get; } - } -} +public record TestArguments( + string InputFile, + bool NoRestore, + DiagnosticsFormat? DiagnosticsFormat) : IInputArguments; diff --git a/src/Bicep.Cli/Program.cs b/src/Bicep.Cli/Program.cs index 3a4b808abc0..5f9f615fc9e 100644 --- a/src/Bicep.Cli/Program.cs +++ b/src/Bicep.Cli/Program.cs @@ -15,6 +15,7 @@ using Bicep.Cli.Services; using Bicep.Core; using Bicep.Core.Emit; +using Bicep.Core.Emit.Options; using Bicep.Core.Exceptions; using Bicep.Core.Features; using Bicep.Core.Tracing; @@ -162,40 +163,22 @@ await io.Error.Writer.WriteLineAsync( // the existing argument class for parsing. Once a command is fully migrated, replace // the LegacyCommand call with a Command that has explicit Option/Argument // members and calls the command handler with the bound values directly. - rootCommand.Add(LegacyCommand( - Constants.Command.Build, - "Builds a .bicep file.", - args => services.GetRequiredService().RunAsync(new BuildArguments(args)))); + rootCommand.Add(CreateBuildCommand()); - rootCommand.Add(LegacyCommand( - Constants.Command.Test, - "Runs tests in a .bicep file.", - args => services.GetRequiredService().RunAsync(new TestArguments(args)))); + rootCommand.Add(CreateTestCommand()); - rootCommand.Add(LegacyCommand( - Constants.Command.BuildParams, - "Builds a .bicepparam file.", - args => services.GetRequiredService().RunAsync(new BuildParamsArguments(args)))); + rootCommand.Add(CreateBuildParamsCommand()); rootCommand.Add(LegacyCommand( Constants.Command.Format, "Formats a .bicep file.", args => Task.FromResult(services.GetRequiredService().Run(new FormatArguments(args))))); - rootCommand.Add(LegacyCommand( - Constants.Command.GenerateParamsFile, - "Generates a parameters file for a .bicep file.", - args => services.GetRequiredService().RunAsync(new GenerateParametersFileArguments(args)))); + rootCommand.Add(CreateGenerateParamsFileCommand()); - rootCommand.Add(LegacyCommand( - Constants.Command.Decompile, - "Attempts to decompile a template .json file to .bicep.", - args => services.GetRequiredService().RunAsync(new DecompileArguments(args)))); + rootCommand.Add(CreateDecompileCommand()); - rootCommand.Add(LegacyCommand( - Constants.Command.DecompileParams, - "Attempts to decompile a parameters .json file to .bicepparam.", - args => Task.FromResult(services.GetRequiredService().Run(new DecompileParamsArguments(args))))); + rootCommand.Add(CreateDecompileParamsCommand()); rootCommand.Add(LegacyCommand( Constants.Command.Publish, @@ -234,6 +217,338 @@ await io.Error.Writer.WriteLineAsync( return rootCommand; } + private Command CreateBuildCommand() + { + var command = new Command(Constants.Command.Build, "Builds a .bicep file.") + { + TreatUnmatchedTokensAsErrors = true, + }; + + var inputFileArgument = new Argument("input-file") + { + Description = "The path to the input .bicep file.", + Arity = ArgumentArity.ZeroOrOne, + }; + var stdoutOption = new Option("--stdout") + { + Description = "Print output to stdout.", + }; + var noRestoreOption = new Option("--no-restore") + { + Description = "Do not restore modules prior to building.", + }; + var outDirOption = new Option("--outdir") + { + Description = "Save output to the specified directory.", + }; + var outFileOption = new Option("--outfile") + { + Description = "Save output to the specified file path.", + }; + var filePatternOption = new Option("--pattern") + { + Description = "Build all files matching the specified pattern.", + }; + var diagnosticsFormatOption = new Option("--diagnostics-format") + { + Description = "Set the format of diagnostics (Default, SARIF).", + }; + + command.Add(inputFileArgument); + command.Add(stdoutOption); + command.Add(noRestoreOption); + command.Add(outDirOption); + command.Add(outFileOption); + command.Add(filePatternOption); + command.Add(diagnosticsFormatOption); + + command.SetAction((result, ct) => RunCommandAsync(async () => + { + var args = new BuildArguments( + result.GetValue(inputFileArgument), + result.GetValue(stdoutOption), + result.GetValue(noRestoreOption), + result.GetValue(outDirOption), + result.GetValue(outFileOption), + result.GetValue(filePatternOption), + result.GetValue(diagnosticsFormatOption)); + + return await services.GetRequiredService().RunAsync(args); + })); + + return command; + } + + private Command CreateTestCommand() + { + var command = new Command(Constants.Command.Test, "Runs tests in a .bicep file.") + { + TreatUnmatchedTokensAsErrors = true, + }; + + var inputFileArgument = new Argument("input-file") + { + Description = "The path to the input .bicep file.", + }; + var noRestoreOption = new Option("--no-restore") + { + Description = "Do not restore modules prior to running tests.", + }; + var diagnosticsFormatOption = new Option("--diagnostics-format") + { + Description = "Set the format of diagnostics (Default, SARIF).", + }; + + command.Add(inputFileArgument); + command.Add(noRestoreOption); + command.Add(diagnosticsFormatOption); + + command.SetAction((result, ct) => RunCommandAsync(async () => + { + var args = new TestArguments( + result.GetRequiredValue(inputFileArgument), + result.GetValue(noRestoreOption), + result.GetValue(diagnosticsFormatOption)); + + return await services.GetRequiredService().RunAsync(args); + })); + + return command; + } + + private Command CreateBuildParamsCommand() + { + var command = new Command(Constants.Command.BuildParams, "Builds a .bicepparam file.") + { + TreatUnmatchedTokensAsErrors = true, + }; + + var inputFileArgument = new Argument("input-file") + { + Description = "The path to the input .bicepparam file.", + Arity = ArgumentArity.ZeroOrOne, + }; + var stdoutOption = new Option("--stdout") + { + Description = "Print output to stdout.", + }; + var noRestoreOption = new Option("--no-restore") + { + Description = "Do not restore modules prior to building.", + }; + var outDirOption = new Option("--outdir") + { + Description = "Save output to the specified directory.", + }; + var outFileOption = new Option("--outfile") + { + Description = "Save output to the specified file path.", + }; + var filePatternOption = new Option("--pattern") + { + Description = "Build all files matching the specified pattern.", + }; + var bicepFileOption = new Option("--bicep-file") + { + Description = "Path to the .bicep template file that will be used to validate the .bicepparam file.", + }; + var diagnosticsFormatOption = new Option("--diagnostics-format") + { + Description = "Set the format of diagnostics (Default, SARIF).", + }; + + command.Add(inputFileArgument); + command.Add(stdoutOption); + command.Add(noRestoreOption); + command.Add(outDirOption); + command.Add(outFileOption); + command.Add(filePatternOption); + command.Add(bicepFileOption); + command.Add(diagnosticsFormatOption); + + command.SetAction((result, ct) => RunCommandAsync(async () => + { + var args = new BuildParamsArguments( + result.GetValue(inputFileArgument), + result.GetValue(stdoutOption), + result.GetValue(noRestoreOption), + result.GetValue(outDirOption), + result.GetValue(outFileOption), + result.GetValue(filePatternOption), + result.GetValue(bicepFileOption), + result.GetValue(diagnosticsFormatOption)); + + return await services.GetRequiredService().RunAsync(args); + })); + + return command; + } + + private Command CreateGenerateParamsFileCommand() + { + var command = new Command(Constants.Command.GenerateParamsFile, "Generates a parameters file for a .bicep file.") + { + TreatUnmatchedTokensAsErrors = true, + }; + + var inputFileArgument = new Argument("input-file") + { + Description = "The path to the input .bicep file.", + }; + var stdoutOption = new Option("--stdout") + { + Description = "Print output to stdout.", + }; + var noRestoreOption = new Option("--no-restore") + { + Description = "Do not restore modules prior to generating.", + }; + var outDirOption = new Option("--outdir") + { + Description = "Save output to the specified directory.", + }; + var outFileOption = new Option("--outfile") + { + Description = "Save output to the specified file path.", + }; + var outputFormatOption = new Option("--output-format") + { + Description = "Output format (Json, BicepParam).", + }; + var includeParamsOption = new Option("--include-params") + { + Description = "Which parameters to include (RequiredOnly, All).", + }; + + command.Add(inputFileArgument); + command.Add(stdoutOption); + command.Add(noRestoreOption); + command.Add(outDirOption); + command.Add(outFileOption); + command.Add(outputFormatOption); + command.Add(includeParamsOption); + + command.SetAction((result, ct) => RunCommandAsync(async () => + { + var args = new GenerateParametersFileArguments( + result.GetRequiredValue(inputFileArgument), + result.GetValue(stdoutOption), + result.GetValue(noRestoreOption), + result.GetValue(outDirOption), + result.GetValue(outFileOption), + result.GetValue(outputFormatOption), + result.GetValue(includeParamsOption)); + + return await services.GetRequiredService().RunAsync(args); + })); + + return command; + } + + private Command CreateDecompileCommand() + { + var command = new Command(Constants.Command.Decompile, "Attempts to decompile a template .json file to .bicep.") + { + TreatUnmatchedTokensAsErrors = true, + }; + + var inputFileArgument = new Argument("input-file") + { + Description = "The path to the ARM template .json file.", + }; + var stdoutOption = new Option("--stdout") + { + Description = "Print output to stdout.", + }; + var forceOption = new Option("--force") + { + Description = "Allow overwriting existing files.", + }; + var outDirOption = new Option("--outdir") + { + Description = "Save output to the specified directory.", + }; + var outFileOption = new Option("--outfile") + { + Description = "Save output to the specified file path.", + }; + + command.Add(inputFileArgument); + command.Add(stdoutOption); + command.Add(forceOption); + command.Add(outDirOption); + command.Add(outFileOption); + + command.SetAction((result, ct) => RunCommandAsync(async () => + { + var args = new DecompileArguments( + result.GetRequiredValue(inputFileArgument), + result.GetValue(stdoutOption), + result.GetValue(forceOption), + result.GetValue(outDirOption), + result.GetValue(outFileOption)); + + return await services.GetRequiredService().RunAsync(args); + })); + + return command; + } + + private Command CreateDecompileParamsCommand() + { + var command = new Command(Constants.Command.DecompileParams, "Attempts to decompile a parameters .json file to .bicepparam.") + { + TreatUnmatchedTokensAsErrors = true, + }; + + var inputFileArgument = new Argument("input-file") + { + Description = "The path to the parameters .json file.", + }; + var stdoutOption = new Option("--stdout") + { + Description = "Print output to stdout.", + }; + var forceOption = new Option("--force") + { + Description = "Allow overwriting existing files.", + }; + var outDirOption = new Option("--outdir") + { + Description = "Save output to the specified directory.", + }; + var outFileOption = new Option("--outfile") + { + Description = "Save output to the specified file path.", + }; + var bicepFileOption = new Option("--bicep-file") + { + Description = "Path to the .bicep template file associated with the parameters file.", + }; + + command.Add(inputFileArgument); + command.Add(stdoutOption); + command.Add(forceOption); + command.Add(outDirOption); + command.Add(outFileOption); + command.Add(bicepFileOption); + + command.SetAction((result, ct) => RunCommandAsync(async () => + { + var args = new DecompileParamsArguments( + result.GetRequiredValue(inputFileArgument), + result.GetValue(stdoutOption), + result.GetValue(forceOption), + result.GetValue(outDirOption), + result.GetValue(outFileOption), + result.GetValue(bicepFileOption)); + + return await Task.FromResult(services.GetRequiredService().Run(args)); + })); + + return command; + } + private Command CreateJsonRpcCommand() { var command = new Command(Constants.Command.JsonRpc, "Starts the Bicep JSON-RPC server."); diff --git a/src/Bicep.Cli/Services/ArgumentParser.cs b/src/Bicep.Cli/Services/ArgumentParser.cs index 743f69ccbf6..1850e547135 100644 --- a/src/Bicep.Cli/Services/ArgumentParser.cs +++ b/src/Bicep.Cli/Services/ArgumentParser.cs @@ -28,13 +28,7 @@ public static class ArgumentParser // parse verb return (args[0].ToLowerInvariant()) switch { - Constants.Command.Build => new BuildArguments(args[1..]), - Constants.Command.Test => new TestArguments(args[1..]), - Constants.Command.BuildParams => new BuildParamsArguments(args[1..]), Constants.Command.Format => new FormatArguments(args[1..]), - Constants.Command.GenerateParamsFile => new GenerateParametersFileArguments(args[1..]), - Constants.Command.Decompile => new DecompileArguments(args[1..]), - Constants.Command.DecompileParams => new DecompileParamsArguments(args[1..]), Constants.Command.PublishExtension => new PublishExtensionArguments(args[1..]), Constants.Command.Publish => new PublishArguments(args[1..]), Constants.Command.Restore => new RestoreArguments(args[1..]), From 12bf3dc74b959ace9311b8e6a4d75b10278cb345 Mon Sep 17 00:00:00 2001 From: Anthony Martin <38542602+anthony-c-martin@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:01:46 -0400 Subject: [PATCH 6/8] All commands --- .../ArgumentParserTests.cs | 198 ----------- src/Bicep.Cli/Arguments/FormatArguments.cs | 198 +---------- src/Bicep.Cli/Arguments/LintArguments.cs | 71 +--- src/Bicep.Cli/Arguments/PublishArguments.cs | 107 +----- .../Arguments/PublishExtensionArguments.cs | 86 +---- src/Bicep.Cli/Arguments/RestoreArguments.cs | 59 +--- src/Bicep.Cli/Arguments/RootArguments.cs | 38 -- src/Bicep.Cli/Commands/CliInfoPrinter.cs | 45 +++ .../Commands/PublishExtensionCommand.cs | 6 +- src/Bicep.Cli/Commands/RootCommand.cs | 315 ----------------- .../Helpers/IServiceCollectionExtensions.cs | 3 +- src/Bicep.Cli/Program.cs | 334 ++++++++++++++---- src/Bicep.Cli/Services/ArgumentParser.cs | 40 --- 13 files changed, 358 insertions(+), 1142 deletions(-) delete mode 100644 src/Bicep.Cli.UnitTests/ArgumentParserTests.cs delete mode 100644 src/Bicep.Cli/Arguments/RootArguments.cs create mode 100644 src/Bicep.Cli/Commands/CliInfoPrinter.cs delete mode 100644 src/Bicep.Cli/Commands/RootCommand.cs delete mode 100644 src/Bicep.Cli/Services/ArgumentParser.cs diff --git a/src/Bicep.Cli.UnitTests/ArgumentParserTests.cs b/src/Bicep.Cli.UnitTests/ArgumentParserTests.cs deleted file mode 100644 index 0d09d88502c..00000000000 --- a/src/Bicep.Cli.UnitTests/ArgumentParserTests.cs +++ /dev/null @@ -1,198 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -using System.IO.Abstractions; -using Bicep.Cli.Arguments; -using Bicep.Cli.Services; -using FluentAssertions; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace Bicep.Cli.UnitTests -{ - [TestClass] - public class ArgumentParserTests - { - private static readonly IFileSystem FileSystem = new FileSystem(); - - [TestMethod] - public void Empty_parameters_should_return_null() - { - var arguments = ArgumentParser.TryParse([], FileSystem); - arguments.Should().BeNull(); - } - - [TestMethod] - public void Wrong_command_should_return_null() - { - var arguments = ArgumentParser.TryParse(["wrong"], FileSystem); - arguments.Should().BeNull(); - } - - [DataTestMethod] - // publish - [DataRow(new[] { "publish" }, "The input file path was not specified.")] - [DataRow(new[] { "publish", "--fake" }, "Unrecognized parameter \"--fake\"")] - [DataRow(new[] { "publish", "--target" }, "The --target parameter expects an argument.")] - [DataRow(new[] { "publish", "--target", "foo", "--target" }, "The --target parameter expects an argument.")] - [DataRow(new[] { "publish", "--target", "foo", "--target", "foo2" }, "The --target parameter cannot be specified twice.")] - [DataRow(new[] { "publish", "file" }, "The target module was not specified.")] - [DataRow(new[] { "publish", "file", "file2" }, "The input file path cannot be specified multiple times.")] - // restore - [DataRow(new[] { "restore" }, "Either the input file path or the --pattern parameter must be specified")] - [DataRow(new[] { "restore", "foo.bicep", "--pattern", "*.bicep" }, "The input file path and the --pattern parameter cannot both be specified")] - [DataRow(new[] { "restore", "--pattern" }, "The --pattern parameter expects an argument")] - [DataRow(new[] { "restore", "--fake" }, "Unrecognized parameter \"--fake\"")] - [DataRow(new[] { "restore", "file1", "file2" }, "The input file path cannot be specified multiple times")] - // lint - [DataRow(new[] { "lint" }, "Either the input file path or the --pattern parameter must be specified")] - [DataRow(new[] { "lint", "foo.bicep", "--pattern", "*.bicep" }, "The input file path and the --pattern parameter cannot both be specified")] - [DataRow(new[] { "lint", "--pattern" }, "The --pattern parameter expects an argument")] - [DataRow(new[] { "lint", "--fake" }, "Unrecognized parameter \"--fake\"")] - // format - [DataRow(new[] { "format" }, "Either the input file path or the --pattern parameter must be specified")] - [DataRow(new[] { "format", "foo.bicep", "--pattern", "*.bicep" }, "The input file path and the --pattern parameter cannot both be specified")] - [DataRow(new[] { "format", "--pattern" }, "The --pattern parameter expects an argument")] - [DataRow(new[] { "format", "--fake" }, "Unrecognized parameter \"--fake\"")] - public void Invalid_args_trigger_validation_exceptions(string[] parameters, string expectedException) - { - Action parseFunc = () => ArgumentParser.TryParse(parameters, FileSystem); - - parseFunc.Should().Throw().WithMessage(expectedException); - } - - [TestMethod] - public void License_argument_should_return_appropriate_RootArguments_instance() - { - var arguments = ArgumentParser.TryParse(["--license"], FileSystem); - - arguments.Should().BeOfType(); - if (arguments is RootArguments rootArguments) - { - rootArguments.PrintHelp.Should().BeFalse(); - rootArguments.PrintVersion.Should().BeFalse(); - rootArguments.PrintLicense.Should().BeTrue(); - rootArguments.PrintThirdPartyNotices.Should().BeFalse(); - } - } - - [TestMethod] - public void Third_party_notices_argument_should_return_appropriate_RootArguments_instance() - { - var arguments = ArgumentParser.TryParse(["--third-party-notices"], FileSystem); - - arguments.Should().BeOfType(); - if (arguments is RootArguments rootArguments) - { - rootArguments.PrintHelp.Should().BeFalse(); - rootArguments.PrintVersion.Should().BeFalse(); - rootArguments.PrintLicense.Should().BeFalse(); - rootArguments.PrintThirdPartyNotices.Should().BeTrue(); - } - } - - [TestMethod] - public void Version_argument_should_return_VersionArguments_instance() - { - var arguments = ArgumentParser.TryParse(["--version"], FileSystem); - - arguments.Should().BeOfType(); - if (arguments is RootArguments rootArguments) - { - rootArguments.PrintHelp.Should().BeFalse(); - rootArguments.PrintVersion.Should().BeTrue(); - rootArguments.PrintLicense.Should().BeFalse(); - rootArguments.PrintThirdPartyNotices.Should().BeFalse(); - } - } - - [TestMethod] - public void Help_argument_should_return_HelpArguments_instance() - { - var arguments = ArgumentParser.TryParse(["--help"], FileSystem); - - arguments.Should().BeOfType(); - if (arguments is RootArguments rootArguments) - { - rootArguments.PrintHelp.Should().BeTrue(); - rootArguments.PrintVersion.Should().BeFalse(); - rootArguments.PrintLicense.Should().BeFalse(); - rootArguments.PrintThirdPartyNotices.Should().BeFalse(); - } - } - - [TestMethod] - public void Version_argument_should_return_VersionShortArguments_instance() - { - var arguments = ArgumentParser.TryParse(["-v"], FileSystem); - - arguments.Should().BeOfType(); - if (arguments is RootArguments rootArguments) - { - rootArguments.PrintHelp.Should().BeFalse(); - rootArguments.PrintVersion.Should().BeTrue(); - rootArguments.PrintLicense.Should().BeFalse(); - rootArguments.PrintThirdPartyNotices.Should().BeFalse(); - } - } - - [TestMethod] - public void Help_argument_should_return_HelpShortArguments_instance() - { - var arguments = ArgumentParser.TryParse(["-h"], FileSystem); - - arguments.Should().BeOfType(); - if (arguments is RootArguments rootArguments) - { - rootArguments.PrintHelp.Should().BeTrue(); - rootArguments.PrintVersion.Should().BeFalse(); - rootArguments.PrintLicense.Should().BeFalse(); - rootArguments.PrintThirdPartyNotices.Should().BeFalse(); - } - } - - [TestMethod] - public void Publish_should_parse_correctly() - { - var arguments = ArgumentParser.TryParse(["publish", "file1", "--target", "target1"], FileSystem); - arguments.Should().BeOfType(); - var typed = (PublishArguments)arguments!; - - typed.InputFile.Should().Be("file1"); - typed.TargetModuleReference.Should().Be("target1"); - typed.NoRestore.Should().BeFalse(); - } - - [TestMethod] - public void Publish_with_no_restore_should_parse_correctly() - { - var arguments = ArgumentParser.TryParse(["publish", "file1", "--target", "target1", "--no-restore"], FileSystem); - arguments.Should().BeOfType(); - var typed = (PublishArguments)arguments!; - - typed.InputFile.Should().Be("file1"); - typed.TargetModuleReference.Should().Be("target1"); - typed.NoRestore.Should().BeTrue(); - } - - [TestMethod] - public void Restore__with_no_force_should_parse_correctly() - { - var arguments = ArgumentParser.TryParse(["restore", "file1"], FileSystem); - arguments.Should().BeOfType(); - var typed = (RestoreArguments)arguments!; - - typed.ForceModulesRestore.Should().Be(false); - typed.InputFile.Should().Be("file1"); - } - - [TestMethod] - public void Restore_with_force_should_parse_correctly() - { - var arguments = ArgumentParser.TryParse(["restore", "--force", "file1"], FileSystem); - arguments.Should().BeOfType(); - var typed = (RestoreArguments)arguments!; - - typed.ForceModulesRestore.Should().Be(true); - typed.InputFile.Should().Be("file1"); - } - } -} diff --git a/src/Bicep.Cli/Arguments/FormatArguments.cs b/src/Bicep.Cli/Arguments/FormatArguments.cs index a743c403dce..d8ff54db3d9 100644 --- a/src/Bicep.Cli/Arguments/FormatArguments.cs +++ b/src/Bicep.Cli/Arguments/FormatArguments.cs @@ -1,196 +1,22 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Collections.Immutable; -using System.IO.Abstractions; -using Bicep.Cli.Extensions; -using Bicep.Cli.Helpers; using Bicep.Core.PrettyPrintV2; using Bicep.IO.Abstraction; namespace Bicep.Cli.Arguments; -public class FormatArguments : ArgumentsBase, IFilePatternInputOutputArguments +public record FormatArguments( + bool OutputToStdOut, + string? InputFile, + string? OutputDir, + string? OutputFile, + string? FilePattern, + NewlineKind? NewlineKind, + IndentKind? IndentKind, + int? IndentSize, + bool? InsertFinalNewline) : IFilePatternInputOutputArguments { - public FormatArguments(string[] args) : base(Constants.Command.Format) - { - for (var i = 0; i < args.Length; i++) - { - switch (args[i].ToLowerInvariant()) - { - case "--stdout": - OutputToStdOut = true; - break; - - case ArgumentConstants.OutDir: - ArgumentHelper.ValidateNotAlreadySet(ArgumentConstants.OutDir, OutputDir); - OutputDir = ArgumentHelper.GetValueWithValidation(ArgumentConstants.OutDir, args, i); - i++; - break; - - case ArgumentConstants.OutFile: - ArgumentHelper.ValidateNotAlreadySet(ArgumentConstants.OutFile, OutputFile); - OutputFile = ArgumentHelper.GetValueWithValidation(ArgumentConstants.OutFile, args, i); - i++; - break; - - case ArgumentConstants.FilePattern: - ArgumentHelper.ValidateNotAlreadySet(ArgumentConstants.FilePattern, FilePattern); - FilePattern = ArgumentHelper.GetValueWithValidation(ArgumentConstants.FilePattern, args, i); - i++; - break; - - case "--newline-kind": - if (args.Length == i + 1) - { - throw new CommandLineException($"The --newline-kind parameter expects an argument"); - } - if (NewlineKind is not null) - { - throw new CommandLineException($"The --newline-kind parameter cannot be specified twice"); - } - if (!Enum.TryParse(args[i + 1], true, out var newline) || !Enum.IsDefined(newline)) - { - throw new CommandLineException($"The --newline-kind parameter only accepts these values: {string.Join(" | ", Enum.GetNames(typeof(NewlineKind)))}"); - } - NewlineKind = newline; - i++; - break; - - case "--indent-kind": - if (args.Length == i + 1) - { - throw new CommandLineException($"The --indent-kind parameter expects an argument"); - } - if (IndentKind is not null) - { - throw new CommandLineException($"The --indent-kind parameter cannot be specified twice"); - } - if (!Enum.TryParse(args[i + 1], true, out var indentKind) || !Enum.IsDefined(indentKind)) - { - throw new CommandLineException($"The --indent-kind parameter only accepts these values: {string.Join(" | ", Enum.GetNames(typeof(IndentKind)))}"); - } - IndentKind = indentKind; - i++; - break; - - case "--indent-size": - if (args.Length == i + 1) - { - throw new CommandLineException($"The --indent-size parameter expects an argument"); - } - if (IndentSize is not null) - { - throw new CommandLineException($"The --indent-size parameter cannot be specified twice"); - } - if (!int.TryParse(args[i + 1], out var indentSize)) - { - throw new CommandLineException($"The --indent-size parameter only accepts integer values"); - } - IndentSize = indentSize; - i++; - break; - - case "--insert-final-newline": - if (InsertFinalNewline is not null) - { - throw new CommandLineException($"The --insert-final-newline parameter cannot be specified twice"); - } - - if (args.Length == i + 1) - { - InsertFinalNewline = true; - break; - } - - if (bool.TryParse(args[i + 1], out var insertFinalNewline)) - { - InsertFinalNewline = insertFinalNewline; - i++; - } - else - { - // Either "true" or "false" is not supplied after "--insert-final-newline", or the value is not a valid boolean. - // Treat it as only "--insert-final-newline" is specified without a value, and default to true. - InsertFinalNewline = true; - } - break; - - default: - if (args[i].StartsWith("--")) - { - throw new CommandLineException($"Unrecognized parameter \"{args[i]}\""); - } - if (InputFile is not null) - { - throw new CommandLineException($"The input file path cannot be specified multiple times"); - } - InputFile = args[i]; - break; - } - } - - if (InputFile is null && FilePattern is null) - { - throw new CommandLineException($"Either the input file path or the {ArgumentConstants.FilePattern} parameter must be specified"); - } - - if (FilePattern != null) - { - if (InputFile is not null) - { - throw new CommandLineException($"The input file path and the {ArgumentConstants.FilePattern} parameter cannot both be specified"); - } - - if (OutputToStdOut) - { - throw new CommandLineException($"The --stdout parameter cannot be used with the {ArgumentConstants.FilePattern} parameter"); - } - - if (OutputDir is not null) - { - throw new CommandLineException($"The {ArgumentConstants.OutDir} parameter cannot be used with the {ArgumentConstants.FilePattern} parameter"); - } - - if (OutputFile is not null) - { - throw new CommandLineException($"The {ArgumentConstants.OutFile} parameter cannot be used with the {ArgumentConstants.FilePattern} parameter"); - } - } - - if (OutputToStdOut && OutputDir is not null) - { - throw new CommandLineException($"The --outdir and --stdout parameters cannot both be used"); - } - - if (OutputToStdOut && OutputFile is not null) - { - throw new CommandLineException($"The --outfile and --stdout parameters cannot both be used"); - } - - if (OutputDir is not null && OutputFile is not null) - { - throw new CommandLineException($"The --outdir and --outfile parameters cannot both be used"); - } - } - - public static Func OutputFileExtensionResolver { get; } = (_, inputUri) => inputUri.GetExtension().ToString(); - - public bool OutputToStdOut { get; } - - public string? InputFile { get; } - - public string? OutputDir { get; } - - public string? OutputFile { get; } - - public string? FilePattern { get; } - - public NewlineKind? NewlineKind { get; } - - public IndentKind? IndentKind { get; } - - public int? IndentSize { get; } - - public bool? InsertFinalNewline { get; } + public static Func OutputFileExtensionResolver { get; } = + (_, inputUri) => inputUri.GetExtension().ToString(); } diff --git a/src/Bicep.Cli/Arguments/LintArguments.cs b/src/Bicep.Cli/Arguments/LintArguments.cs index 18a711c69bc..4652869e745 100644 --- a/src/Bicep.Cli/Arguments/LintArguments.cs +++ b/src/Bicep.Cli/Arguments/LintArguments.cs @@ -1,71 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Collections.Immutable; -using Bicep.Cli.Helpers; - namespace Bicep.Cli.Arguments; -public class LintArguments : ArgumentsBase, IFilePatternInputArguments -{ - public LintArguments(string[] args) - : base(Constants.Command.Lint) - { - for (var i = 0; i < args.Length; i++) - { - switch (args[i].ToLowerInvariant()) - { - case "--no-restore": - NoRestore = true; - break; - - case ArgumentConstants.DiagnosticsFormat: - ArgumentHelper.ValidateNotAlreadySet(ArgumentConstants.DiagnosticsFormat, DiagnosticsFormat); - DiagnosticsFormat = ArgumentHelper.ToDiagnosticsFormat(ArgumentHelper.GetValueWithValidation(ArgumentConstants.DiagnosticsFormat, args, i)); - i++; - break; - - case ArgumentConstants.FilePattern: - ArgumentHelper.ValidateNotAlreadySet(ArgumentConstants.FilePattern, FilePattern); - FilePattern = ArgumentHelper.GetValueWithValidation(ArgumentConstants.FilePattern, args, i); - i++; - break; - - default: - if (args[i].StartsWith("--")) - { - throw new CommandLineException($"Unrecognized parameter \"{args[i]}\""); - } - if (InputFile is not null) - { - throw new CommandLineException($"The input file path cannot be specified multiple times"); - } - InputFile = args[i]; - break; - } - } - - if (InputFile is null && FilePattern is null) - { - throw new CommandLineException($"Either the input file path or the {ArgumentConstants.FilePattern} parameter must be specified"); - } - - if (FilePattern != null) - { - if (InputFile is not null) - { - throw new CommandLineException($"The input file path and the {ArgumentConstants.FilePattern} parameter cannot both be specified"); - } - } - - DiagnosticsFormat ??= Arguments.DiagnosticsFormat.Default; - } - - public string? InputFile { get; } - - public string? FilePattern { get; } - - public DiagnosticsFormat? DiagnosticsFormat { get; } - - public bool NoRestore { get; } -} +public record LintArguments( + string? InputFile, + string? FilePattern, + DiagnosticsFormat? DiagnosticsFormat, + bool NoRestore) : IFilePatternInputArguments; diff --git a/src/Bicep.Cli/Arguments/PublishArguments.cs b/src/Bicep.Cli/Arguments/PublishArguments.cs index ed1445af879..19a4a78217b 100644 --- a/src/Bicep.Cli/Arguments/PublishArguments.cs +++ b/src/Bicep.Cli/Arguments/PublishArguments.cs @@ -1,103 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Bicep.Cli.Extensions; -namespace Bicep.Cli.Arguments -{ - public class PublishArguments : ArgumentsBase, IInputArguments - { - public PublishArguments(string[] args) : base(Constants.Command.Publish) - { - for (int i = 0; i < args.Length; i++) - { - var isLast = args.Length == i + 1; - switch (args[i].ToLowerInvariant()) - { - case "--no-restore": - NoRestore = true; - break; +namespace Bicep.Cli.Arguments; - case "--target": - if (isLast) - { - throw new CommandLineException("The --target parameter expects an argument."); - } - - if (this.TargetModuleReference is not null) - { - throw new CommandLineException("The --target parameter cannot be specified twice."); - } - - TargetModuleReference = args[i + 1]; - i++; - break; - - case "--documentation-uri": - if (isLast) - { - throw new CommandLineException("The --documentation-uri parameter expects an argument."); - } - - if (this.DocumentationUri is not null) - { - throw new CommandLineException("The --documentation-uri parameter cannot be specified more than once."); - } - - DocumentationUri = args[i + 1]; - - if (!Uri.IsWellFormedUriString(DocumentationUri, UriKind.Absolute)) - { - throw new CommandLineException("The --documentation-uri should be a well formed uri string."); - } - - i++; - break; - - case "--with-source": - WithSource = true; - break; - - case "--force": - Force = true; - break; - - default: - if (args[i].StartsWith("--")) - { - throw new CommandLineException($"Unrecognized parameter \"{args[i]}\""); - } - - if (InputFile is not null) - { - throw new CommandLineException($"The input file path cannot be specified multiple times."); - } - - InputFile = args[i]; - break; - } - } - - if (InputFile is null) - { - throw new CommandLineException($"The input file path was not specified."); - } - - if (TargetModuleReference is null) - { - throw new CommandLineException("The target module was not specified."); - } - } - - public string? DocumentationUri { get; } - - public string InputFile { get; } - - public string TargetModuleReference { get; } - - public bool NoRestore { get; } - - public bool Force { get; } - - public bool WithSource { get; } - } -} +public record PublishArguments( + string InputFile, + string TargetModuleReference, + string? DocumentationUri, + bool NoRestore, + bool Force, + bool WithSource) : IInputArguments; diff --git a/src/Bicep.Cli/Arguments/PublishExtensionArguments.cs b/src/Bicep.Cli/Arguments/PublishExtensionArguments.cs index c989119bbf0..f21dbd8bf73 100644 --- a/src/Bicep.Cli/Arguments/PublishExtensionArguments.cs +++ b/src/Bicep.Cli/Arguments/PublishExtensionArguments.cs @@ -1,84 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Bicep.Cli.Extensions; -using Bicep.Core.Registry.Oci; -namespace Bicep.Cli.Arguments -{ - public class PublishExtensionArguments : ArgumentsBase - { - public PublishExtensionArguments(string[] args) - : base(Constants.Command.PublishExtension) - { - for (int i = 0; i < args.Length; i++) - { - var isLast = args.Length == i + 1; - switch (args[i].ToLowerInvariant()) - { - case "--target": - if (isLast) - { - throw new CommandLineException("The --target parameter expects an argument."); - } +namespace Bicep.Cli.Arguments; - if (this.TargetExtensionReference is not null) - { - throw new CommandLineException("The --target parameter cannot be specified twice."); - } - - TargetExtensionReference = args[i + 1]; - i++; - break; - - case { } when args[i].StartsWith("--bin-"): - var architectureName = args[i].Substring("--bin-".Length); - - if (!SupportedArchitectures.All.Any(x => x.Name == architectureName)) - { - throw new CommandLineException($"Unrecognized parameter \"{args[i]}\""); - } - - if (Binaries.ContainsKey(architectureName)) - { - throw new CommandLineException($"Parameter \"{args[i]}\" cannot be specified multiple times."); - } - - Binaries[architectureName] = args[i + 1]; - i++; - break; - - case "--force": - Force = true; - break; - - default: - if (args[i].StartsWith("--")) - { - throw new CommandLineException($"Unrecognized parameter \"{args[i]}\""); - } - - if (IndexFile is not null) - { - throw new CommandLineException($"The input file path cannot be specified multiple times."); - } - - IndexFile = args[i]; - break; - } - } - - if (TargetExtensionReference is null) - { - throw new CommandLineException("The target extension was not specified."); - } - } - - public Dictionary Binaries { get; } = new(); - - public string? IndexFile { get; } - - public string TargetExtensionReference { get; } - - public bool Force { get; } - } -} +public record PublishExtensionArguments( + string? IndexFile, + string? TargetExtensionReference, + IReadOnlyDictionary Binaries, + bool Force); diff --git a/src/Bicep.Cli/Arguments/RestoreArguments.cs b/src/Bicep.Cli/Arguments/RestoreArguments.cs index 537f5d3ade0..d7538edaba2 100644 --- a/src/Bicep.Cli/Arguments/RestoreArguments.cs +++ b/src/Bicep.Cli/Arguments/RestoreArguments.cs @@ -1,60 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Collections.Immutable; -using Bicep.Cli.Helpers; - namespace Bicep.Cli.Arguments; -public class RestoreArguments : ArgumentsBase, IFilePatternInputArguments -{ - public RestoreArguments(string[] args) : base(Constants.Command.Restore) - { - for (var i = 0; i < args.Length; i++) - { - switch (args[i].ToLowerInvariant()) - { - case "--force": - ForceModulesRestore = true; - break; - - case ArgumentConstants.FilePattern: - ArgumentHelper.ValidateNotAlreadySet(ArgumentConstants.FilePattern, FilePattern); - FilePattern = ArgumentHelper.GetValueWithValidation(ArgumentConstants.FilePattern, args, i); - i++; - break; - - default: - if (args[i].StartsWith("--")) - { - throw new CommandLineException($"Unrecognized parameter \"{args[i]}\""); - } - if (InputFile is not null) - { - throw new CommandLineException($"The input file path cannot be specified multiple times"); - } - InputFile = args[i]; - break; - } - } - - if (InputFile is null && FilePattern is null) - { - throw new CommandLineException($"Either the input file path or the {ArgumentConstants.FilePattern} parameter must be specified"); - } - - if (FilePattern != null) - { - if (InputFile is not null) - { - throw new CommandLineException($"The input file path and the {ArgumentConstants.FilePattern} parameter cannot both be specified"); - } - } - } - - public string? InputFile { get; } - - public string? FilePattern { get; } - - public bool ForceModulesRestore { get; } -} +public record RestoreArguments( + string? InputFile, + string? FilePattern, + bool ForceModulesRestore) : IFilePatternInputArguments; diff --git a/src/Bicep.Cli/Arguments/RootArguments.cs b/src/Bicep.Cli/Arguments/RootArguments.cs deleted file mode 100644 index 4c545d699b9..00000000000 --- a/src/Bicep.Cli/Arguments/RootArguments.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.RegularExpressions; - -namespace Bicep.Cli.Arguments -{ - public class RootArguments : ArgumentsBase - { - public RootArguments(string arg, string commandName) : base(commandName) - { - switch (arg) - { - case var a when new Regex(Constants.Argument.VersionRegex).IsMatch(a): - PrintVersion = true; - break; - - case var a when new Regex(Constants.Argument.HelpRegex).IsMatch(a): - PrintHelp = true; - break; - - case var a when new Regex(Constants.Argument.LicenseRegex).IsMatch(a): - PrintLicense = true; - break; - - case var a when new Regex(Constants.Argument.ThirdPartyNoticesRegex).IsMatch(a): - PrintThirdPartyNotices = true; - break; - } - ; - } - - public bool PrintHelp { get; } - public bool PrintVersion { get; } - public bool PrintLicense { get; } - public bool PrintThirdPartyNotices { get; } - } -} diff --git a/src/Bicep.Cli/Commands/CliInfoPrinter.cs b/src/Bicep.Cli/Commands/CliInfoPrinter.cs new file mode 100644 index 00000000000..4dc64d571f5 --- /dev/null +++ b/src/Bicep.Cli/Commands/CliInfoPrinter.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.IO.Compression; +using Bicep.Core.Exceptions; +using Bicep.Core.Utils; + +namespace Bicep.Cli.Commands +{ + public static class CliInfoPrinter + { + public static void PrintVersion(IOContext io, IEnvironment environment) + { + var output = $@"Bicep CLI version {environment.GetVersionString()}{System.Environment.NewLine}"; + + io.Output.Writer.Write(output); + io.Output.Writer.Flush(); + } + + public static void PrintLicense(IOContext io) + { + WriteEmbeddedResource(io.Output.Writer, "LICENSE.deflated"); + } + + public static void PrintThirdPartyNotices(IOContext io) + { + WriteEmbeddedResource(io.Output.Writer, "NOTICE.deflated"); + } + + private static void WriteEmbeddedResource(TextWriter writer, string streamName) + { + using var stream = typeof(CliInfoPrinter).Assembly.GetManifestResourceStream(streamName) + ?? throw new BicepException($"The resource stream '{streamName}' is missing from this executable."); + + using var decompressor = new DeflateStream(stream, CompressionMode.Decompress); + + using var reader = new StreamReader(decompressor); + string? line = null; + while ((line = reader.ReadLine()) is not null) + { + writer.WriteLine(line); + } + } + } +} diff --git a/src/Bicep.Cli/Commands/PublishExtensionCommand.cs b/src/Bicep.Cli/Commands/PublishExtensionCommand.cs index d246322392a..138d3b29240 100644 --- a/src/Bicep.Cli/Commands/PublishExtensionCommand.cs +++ b/src/Bicep.Cli/Commands/PublishExtensionCommand.cs @@ -49,7 +49,11 @@ public async Task RunAsync(PublishExtensionArguments args, CancellationToke throw new CommandLineException($"The input file path was not specified."); } - logger.LogWarning($"WARNING: The '{args.CommandName}' CLI command group is an experimental feature. Experimental features should be enabled for testing purposes only, as there are no guarantees about the quality or stability of these features. Do not enable these settings for any production usage, or your production environment may be subject to breaking."); + logger.LogWarning($"WARNING: The '{Constants.Command.PublishExtension}' CLI command group is an experimental feature. Experimental features should be enabled for testing purposes only, as there are no guarantees about the quality or stability of these features. Do not enable these settings for any production usage, or your production environment may be subject to breaking."); + if (args.TargetExtensionReference is null) + { + throw new CommandLineException("The target extension was not specified."); + } var reference = ValidateReference(args.TargetExtensionReference); var overwriteIfExists = args.Force; diff --git a/src/Bicep.Cli/Commands/RootCommand.cs b/src/Bicep.Cli/Commands/RootCommand.cs deleted file mode 100644 index 5693ce25b1b..00000000000 --- a/src/Bicep.Cli/Commands/RootCommand.cs +++ /dev/null @@ -1,315 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.IO.Compression; -using Bicep.Cli.Arguments; -using Bicep.Core.Exceptions; -using Bicep.Core.Utils; - -namespace Bicep.Cli.Commands -{ - public class RootCommand( - IOContext io, - IEnvironment environment) : ICommand - { - public int Run(RootArguments args) - { - if (args.PrintVersion) - { - PrintVersion(); - return 0; - } - - if (args.PrintHelp) - { - PrintHelp(); - return 0; - } - - if (args.PrintLicense) - { - PrintLicense(); - return 0; - } - - if (args.PrintThirdPartyNotices) - { - PrintThirdPartyNotices(); - return 0; - } - - return 1; - } - - internal void PrintHelp() - { - var exeName = ThisAssembly.AssemblyName; - var versionString = environment.GetVersionString(); - - var output = -$@"Bicep CLI version {versionString} - -Usage: - {exeName} build [options] [] - Builds a .bicep file. - - Arguments: - The input file - - Options: - --outdir Saves the output at the specified directory. - --outfile Saves the output as the specified file path. - --stdout Prints the output to stdout. - --no-restore Builds the bicep file without restoring external modules. - --diagnostics-format Sets the format with which diagnostics are displayed. Valid values are ( {string.Join(" | ", Enum.GetNames(typeof(DiagnosticsFormat)))} ). - --pattern Builds all files matching the specified glob pattern. - - Examples: - bicep build file.bicep - bicep build file.bicep --stdout - bicep build file.bicep --outdir dir1 - bicep build file.bicep --outfile file.json - bicep build file.bicep --no-restore - bicep build file.bicep --diagnostics-format sarif - bicep build --pattern './dir/**/*.bicep' - - {exeName} format [options] [] - Formats a .bicep file. - - Arguments: - The input file - - Options: - --outdir Saves the output at the specified directory. - --outfile Saves the output as the specified file path. - --stdout Prints the output to stdout. - --newline Set newline char. Valid values are ( Auto | LF | CRLF | CR ). - --indent-kind Set indentation kind. Valid values are ( Space | Tab ). - --indent-size Number of spaces to indent with (Only valid with --indentKind set to Space). - --insert-final-newline Insert a final newline. - --pattern Formats all files matching the specified glob pattern. - - Examples: - bicep format file.bicep - bicep format file.bicep --stdout - bicep format file.bicep --outdir dir1 - bicep format file.bicep --outfile file.json - bicep format file.bicep --indent-kind Tab - bicep format --pattern './dir/**/*.bicep' - - {exeName} decompile [options] - Attempts to decompile a template .json file to .bicep. - - Arguments: - The input file - - Options: - --outdir Saves the output at the specified directory. - --outfile Saves the output as the specified file path. - --stdout Prints the output to stdout. - --force Allows overwriting the output file if it exists (applies only to 'bicep decompile' or 'bicep decompile-params'). - - Examples: - bicep decompile file.json - bicep decompile file.json --stdout - bicep decompile file.json --outdir dir1 - bicep decompile file.json --force - bicep decompile file.json --outfile file.bicep - - {exeName} lint [options] [] - Lints a .bicep file. - - Arguments: - The input file - - Options: - --no-restore Skips restoring external modules. - --diagnostics-format Sets the format with which diagnostics are displayed. Valid values are ( {string.Join(" | ", Enum.GetNames(typeof(DiagnosticsFormat)))} ). - --pattern Lints all files matching the specified glob pattern. - - Examples: - bicep lint file.bicep - bicep lint file.bicep --no-restore - bicep lint file.bicep --diagnostics-format sarif - bicep lint --pattern './dir/**/*.bicep' - - {exeName} decompile-params [options] - Attempts to decompile a parameters .json file to .bicepparam. - - Arguments: - The input file - - Options: - --outdir Saves the output at the specified directory. - --outfile Saves the output as the specified file path. - --stdout Prints the output to stdout. - --force Allows overwriting the output file if it exists (applies only to 'bicep decompile' or 'bicep decompile-params'). - --bicep-file Path to the bicep template file that will be referenced in the using declaration - - Examples: - bicep decompile-params file.json - bicep decompile-params file.json --bicep-file ./dir/main.bicep - bicep decompile-params file.json --stdout - bicep decompile-params file.json --outdir dir1 - bicep decompile-params file.json --force - bicep decompile-params file.json --outfile file.bicepparam - - {exeName} generate-params [options] - Builds parameters file from the given bicep file, updates if there is an existing parameters file. - - Arguments: - The input file - - Options: - --no-restore Generates the parameters file without restoring external modules. - --outdir Saves the output at the specified directory. - --outfile Saves the output as the specified file path. - --stdout Prints the output to stdout. - --output-format Selects the output format {{json, bicepparam}} - --include-params Selects which parameters to include into output {{requiredonly, all}} - - Examples: - bicep generate-params file.bicep - bicep generate-params file.bicep --no-restore - bicep generate-params file.bicep --stdout - bicep generate-params file.bicep --outdir dir1 - bicep generate-params file.bicep --outfile file.parameters.json - bicep generate-params file.bicep --output-format bicepparam --include-params all - - {exeName} publish --target - Publishes the .bicep file to the module registry. - - Arguments: - The input file (can be a Bicep file or an ARM template file) - The module reference - - Options: - --documentation-uri Module documentation uri - --with-source [Experimental] Publish source code with the module - --force Overwrite existing published module or file - - Examples: - bicep publish file.bicep --target br:example.azurecr.io/hello/world:v1 - bicep publish file.bicep --target br:example.azurecr.io/hello/world:v1 --force - bicep publish file.bicep --target br:example.azurecr.io/hello/world:v1 --documentation-uri https://github.com/hello-world/README.md --with-source - bicep publish file.json --target br:example.azurecr.io/hello/world:v1 --documentation-uri https://github.com/hello-world/README.md - - {exeName} restore [] - Restores external modules from the specified Bicep file to the local module cache. - - Arguments: - The input file - - Options: - --pattern Restores all files matching the specified glob pattern. - - Examples: - bicep restore main.bicep - bicep restore --pattern './dir/**/*.bicep' - - {exeName} [options] - Options: - --version -v Shows bicep version information - --help -h Shows this usage information - --license Prints license information - --third-party-notices Prints third-party notices - - {exeName} build-params [] - Builds a .json file from a .bicepparam file. - - Arguments: - The input Bicepparam file - - Options: - --bicep-file Verifies if the specified bicep file path matches the one provided in the params file using declaration - --outdir Saves the output of building the parameter file only (.bicepparam) as json to the specified directory. - --outfile Saves the output of building the parameter file only (.bicepparam) as json to the specified file path. - --stdout Prints the output of building both the parameter file (.bicepparam) and the template it points to (.bicep) as json to stdout. - --no-restore Builds the bicep file (referenced in using declaration) without restoring external modules. - --diagnostics-format Sets the format with which diagnostics are displayed. Valid values are ( {string.Join(" | ", Enum.GetNames(typeof(DiagnosticsFormat)))} ). - --pattern Builds all files matching the specified glob pattern. - - Examples: - bicep build-params params.bicepparam - bicep build-params params.bicepparam --stdout - bicep build-params params.bicepparam --outdir dir1 - bicep build-params params.bicepparam --outfile otherParams.json - bicep build-params params.bicepparam --no-restore - bicep build-params params.bicepparam --diagnostics-format sarif - bicep build-params --pattern './dir/**/*.bicepparam' - - {exeName} jsonrpc [options] - Starts the Bicep CLI listening for JSONRPC messages, for programatically interacting with Bicep. See https://aka.ms/bicep/jsonrpc for more information. - - Options: - --pipe Bicep CLI will connect to the supplied named pipe as a client, and start listening for JSONRPC requests. - --socket Bicep CLI will connect to the supplied TCP port on the loopback interface as a client, and start listening for JSONRPC requests. - --stdio Bicep CLI will use stdin/stdout for JSONRPC requests. - - Examples: - bicep jsonrpc --pipe /path/to/pipe.sock - bicep jsonrpc --socket 9853 - bicep jsonrpc --stdio - - {exeName} snapshot [options] - Generates or validates a deployment snapshot from a .bicepparam file. - - Arguments: - The input .bicepparam file - - Options: - --mode Sets the snapshot mode. Valid values are ( overwrite | validate ). - Overwrite: Generates a new snapshot and saves it to .snapshot.json. - Validate: Compares the generated snapshot against an existing snapshot file. - --tenant-id The tenant ID to use for the deployment. - --subscription-id The subscription ID to use for the deployment. - --resource-group The resource group name to use for the deployment. - --location The location to use for the deployment. - --deployment-name The deployment name to use. - - Examples: - bicep snapshot params.bicepparam - bicep snapshot params.bicepparam --mode overwrite - bicep snapshot params.bicepparam --mode validate - bicep snapshot params.bicepparam --subscription-id 00000000-0000-0000-0000-000000000000 --resource-group my-rg - -"; // this newline is intentional - - io.Output.Writer.Write(output); - io.Output.Writer.Flush(); - } - - private void PrintVersion() - { - var output = $@"Bicep CLI version {environment.GetVersionString()}{System.Environment.NewLine}"; - - io.Output.Writer.Write(output); - io.Output.Writer.Flush(); - } - - private void PrintLicense() - { - WriteEmbeddedResource(io.Output.Writer, "LICENSE.deflated"); - } - - private void PrintThirdPartyNotices() - { - WriteEmbeddedResource(io.Output.Writer, "NOTICE.deflated"); - } - - private static void WriteEmbeddedResource(TextWriter writer, string streamName) - { - using var stream = typeof(RootCommand).Assembly.GetManifestResourceStream(streamName) - ?? throw new BicepException($"The resource stream '{streamName}' is missing from this executable."); - - using var decompressor = new DeflateStream(stream, CompressionMode.Decompress); - - using var reader = new StreamReader(decompressor); - string? line = null; - while ((line = reader.ReadLine()) is not null) - { - writer.WriteLine(line); - } - } - } -} diff --git a/src/Bicep.Cli/Helpers/IServiceCollectionExtensions.cs b/src/Bicep.Cli/Helpers/IServiceCollectionExtensions.cs index 927df27f243..5780baf3f94 100644 --- a/src/Bicep.Cli/Helpers/IServiceCollectionExtensions.cs +++ b/src/Bicep.Cli/Helpers/IServiceCollectionExtensions.cs @@ -63,6 +63,5 @@ public static IServiceCollection AddCommands(this IServiceCollection services) = .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton() - .AddSingleton(); + .AddSingleton(); } diff --git a/src/Bicep.Cli/Program.cs b/src/Bicep.Cli/Program.cs index 5f9f615fc9e..bda5cae8910 100644 --- a/src/Bicep.Cli/Program.cs +++ b/src/Bicep.Cli/Program.cs @@ -16,6 +16,7 @@ using Bicep.Core; using Bicep.Core.Emit; using Bicep.Core.Emit.Options; +using Bicep.Core.PrettyPrintV2; using Bicep.Core.Exceptions; using Bicep.Core.Features; using Bicep.Core.Tracing; @@ -24,7 +25,6 @@ using Microsoft.Extensions.Logging; using Spectre.Console; -// Avoid naming conflict with Bicep.Cli.Commands.RootCommand using SclRootCommand = System.CommandLine.RootCommand; public class HelpExamplesAction : SynchronousCommandLineAction @@ -100,13 +100,6 @@ public async Task RunAsync(string[] args, CancellationToken cancellationTok return await rootCommand.Parse(args).InvokeAsync(cancellationToken: cancellationToken); } - /// - /// Builds the System.CommandLine command hierarchy. Each existing subcommand is currently - /// registered as a legacy pass-through stub via . To migrate a - /// command, replace its LegacyCommand call with a that - /// declares its own and members and - /// invokes the command handler directly. - /// private SclRootCommand BuildCommandLine(CancellationToken cancellationToken) { var rootCommand = new SclRootCommand("Bicep CLI") @@ -134,23 +127,25 @@ private SclRootCommand BuildCommandLine(CancellationToken cancellationToken) rootCommand.SetAction(async (ParseResult pr, CancellationToken ct) => { - var bicepRootCommand = services.GetRequiredService(); - + var environment = services.GetRequiredService(); var unmatched = pr.UnmatchedTokens; if (pr.GetValue(versionOption)) { - return bicepRootCommand.Run(new RootArguments("--version", Constants.Command.Root)); + Commands.CliInfoPrinter.PrintVersion(io, environment); + return 0; } if (pr.GetValue(licenseOption)) { - return bicepRootCommand.Run(new RootArguments("--license", Constants.Command.Root)); + Commands.CliInfoPrinter.PrintLicense(io); + return 0; } if (pr.GetValue(thirdPartyNoticesOption)) { - return bicepRootCommand.Run(new RootArguments("--third-party-notices", Constants.Command.Root)); + Commands.CliInfoPrinter.PrintThirdPartyNotices(io); + return 0; } await io.Error.Writer.WriteLineAsync( @@ -158,21 +153,13 @@ await io.Error.Writer.WriteLineAsync( return 1; }); - // Each subcommand below is a legacy pass-through stub. Arguments not recognized by - // System.CommandLine are collected in ParseResult.UnmatchedTokens and forwarded to - // the existing argument class for parsing. Once a command is fully migrated, replace - // the LegacyCommand call with a Command that has explicit Option/Argument - // members and calls the command handler with the bound values directly. rootCommand.Add(CreateBuildCommand()); rootCommand.Add(CreateTestCommand()); rootCommand.Add(CreateBuildParamsCommand()); - rootCommand.Add(LegacyCommand( - Constants.Command.Format, - "Formats a .bicep file.", - args => Task.FromResult(services.GetRequiredService().Run(new FormatArguments(args))))); + rootCommand.Add(CreateFormatCommand()); rootCommand.Add(CreateGenerateParamsFileCommand()); @@ -180,25 +167,13 @@ await io.Error.Writer.WriteLineAsync( rootCommand.Add(CreateDecompileParamsCommand()); - rootCommand.Add(LegacyCommand( - Constants.Command.Publish, - "Publishes a .bicep file to a registry.", - args => services.GetRequiredService().RunAsync(new PublishArguments(args)))); + rootCommand.Add(CreatePublishCommand()); - rootCommand.Add(LegacyCommand( - Constants.Command.PublishExtension, - "Publishes a Bicep extension to a registry.", - args => services.GetRequiredService().RunAsync(new PublishExtensionArguments(args), cancellationToken))); + rootCommand.Add(CreatePublishExtensionCommand()); - rootCommand.Add(LegacyCommand( - Constants.Command.Restore, - "Restores external modules for a .bicep file.", - args => services.GetRequiredService().RunAsync(new RestoreArguments(args)))); + rootCommand.Add(CreateRestoreCommand()); - rootCommand.Add(LegacyCommand( - Constants.Command.Lint, - "Lints a .bicep file.", - args => services.GetRequiredService().RunAsync(new LintArguments(args)))); + rootCommand.Add(CreateLintCommand()); rootCommand.Add(CreateJsonRpcCommand()); @@ -549,6 +524,265 @@ private Command CreateDecompileParamsCommand() return command; } + private Command CreateFormatCommand() + { + var command = new Command(Constants.Command.Format, "Formats a .bicep file."); + + var inputFileArgument = new Argument("input-file") + { + Description = "The path to the input .bicep file.", + Arity = ArgumentArity.ZeroOrOne, + }; + var stdoutOption = new Option("--stdout") + { + Description = "Print output to stdout.", + }; + var outDirOption = new Option("--outdir") + { + Description = "Save output to the specified directory.", + }; + var outFileOption = new Option("--outfile") + { + Description = "Save output to the specified file path.", + }; + var filePatternOption = new Option("--pattern") + { + Description = "Format all files matching the specified pattern.", + }; + var newlineKindOption = new Option("--newline-kind") + { + Description = "Set the newline kind (Auto, LF, CRLF, CR).", + }; + var indentKindOption = new Option("--indent-kind") + { + Description = "Set the indent kind (Space, Tab).", + }; + var indentSizeOption = new Option("--indent-size") + { + Description = "Set the indent size (number of spaces).", + }; + var insertFinalNewlineOption = new Option("--insert-final-newline") + { + Description = "Insert a final newline.", + }; + + command.Add(inputFileArgument); + command.Add(stdoutOption); + command.Add(outDirOption); + command.Add(outFileOption); + command.Add(filePatternOption); + command.Add(newlineKindOption); + command.Add(indentKindOption); + command.Add(indentSizeOption); + command.Add(insertFinalNewlineOption); + + command.SetAction((result, ct) => RunCommandAsync(() => + { + var args = new FormatArguments( + result.GetValue(stdoutOption), + result.GetValue(inputFileArgument), + result.GetValue(outDirOption), + result.GetValue(outFileOption), + result.GetValue(filePatternOption), + result.GetValue(newlineKindOption), + result.GetValue(indentKindOption), + result.GetValue(indentSizeOption), + result.GetValue(insertFinalNewlineOption)); + + return Task.FromResult(services.GetRequiredService().Run(args)); + })); + + return command; + } + + private Command CreatePublishCommand() + { + var command = new Command(Constants.Command.Publish, "Publishes a .bicep file to a registry."); + + var inputFileArgument = new Argument("input-file") + { + Description = "The path to the .bicep file to publish.", + }; + var targetOption = new Option("--target") + { + Description = "The target module reference.", + }; + var documentationUriOption = new Option("--documentation-uri") + { + Description = "The documentation URI.", + }; + var noRestoreOption = new Option("--no-restore") + { + Description = "Do not restore modules prior to publishing.", + }; + var forceOption = new Option("--force") + { + Description = "Force publish even if the module already exists.", + }; + var withSourceOption = new Option("--with-source") + { + Description = "Publish with source code.", + }; + + command.Add(inputFileArgument); + command.Add(targetOption); + command.Add(documentationUriOption); + command.Add(noRestoreOption); + command.Add(forceOption); + command.Add(withSourceOption); + + command.SetAction((result, ct) => RunCommandAsync(async () => + { + var target = result.GetValue(targetOption) + ?? throw new CommandLineException("The target module was not specified."); + var args = new PublishArguments( + result.GetRequiredValue(inputFileArgument), + target, + result.GetValue(documentationUriOption), + result.GetValue(noRestoreOption), + result.GetValue(forceOption), + result.GetValue(withSourceOption)); + + return await services.GetRequiredService().RunAsync(args); + })); + + return command; + } + + private Command CreatePublishExtensionCommand() + { + var command = new Command(Constants.Command.PublishExtension, "Publishes a Bicep extension to a registry."); + + var indexFileArgument = new Argument("index-file") + { + Description = "The path to the index file.", + Arity = ArgumentArity.ZeroOrOne, + }; + var targetOption = new Option("--target") + { + Description = "The target extension reference.", + }; + var forceOption = new Option("--force") + { + Description = "Force publish even if the extension already exists.", + }; + // Per-architecture binary options. + var binLinuxX64Option = new Option("--bin-linux-x64") { Description = "Path to the linux-x64 binary." }; + var binLinuxArm64Option = new Option("--bin-linux-arm64") { Description = "Path to the linux-arm64 binary." }; + var binOsxX64Option = new Option("--bin-osx-x64") { Description = "Path to the osx-x64 binary." }; + var binOsxArm64Option = new Option("--bin-osx-arm64") { Description = "Path to the osx-arm64 binary." }; + var binWinX64Option = new Option("--bin-win-x64") { Description = "Path to the win-x64 binary." }; + var binWinArm64Option = new Option("--bin-win-arm64") { Description = "Path to the win-arm64 binary." }; + + command.Add(indexFileArgument); + command.Add(targetOption); + command.Add(forceOption); + command.Add(binLinuxX64Option); + command.Add(binLinuxArm64Option); + command.Add(binOsxX64Option); + command.Add(binOsxArm64Option); + command.Add(binWinX64Option); + command.Add(binWinArm64Option); + + command.SetAction((result, ct) => RunCommandAsync(async () => + { + var binaries = new Dictionary(); + if (result.GetValue(binLinuxX64Option) is { } p1) { binaries["linux-x64"] = p1; } + if (result.GetValue(binLinuxArm64Option) is { } p2) { binaries["linux-arm64"] = p2; } + if (result.GetValue(binOsxX64Option) is { } p3) { binaries["osx-x64"] = p3; } + if (result.GetValue(binOsxArm64Option) is { } p4) { binaries["osx-arm64"] = p4; } + if (result.GetValue(binWinX64Option) is { } p5) { binaries["win-x64"] = p5; } + if (result.GetValue(binWinArm64Option) is { } p6) { binaries["win-arm64"] = p6; } + + var args = new PublishExtensionArguments( + result.GetValue(indexFileArgument), + result.GetValue(targetOption), + binaries, + result.GetValue(forceOption)); + + return await services.GetRequiredService().RunAsync(args, ct); + })); + + return command; + } + + private Command CreateRestoreCommand() + { + var command = new Command(Constants.Command.Restore, "Restores external modules for a .bicep file."); + + var inputFileArgument = new Argument("input-file") + { + Description = "The path to the .bicep file.", + Arity = ArgumentArity.ZeroOrOne, + }; + var filePatternOption = new Option("--pattern") + { + Description = "Restore all files matching the specified pattern.", + }; + var forceOption = new Option("--force") + { + Description = "Force restore even if modules are already cached.", + }; + + command.Add(inputFileArgument); + command.Add(filePatternOption); + command.Add(forceOption); + + command.SetAction((result, ct) => RunCommandAsync(async () => + { + var args = new RestoreArguments( + result.GetValue(inputFileArgument), + result.GetValue(filePatternOption), + result.GetValue(forceOption)); + + return await services.GetRequiredService().RunAsync(args); + })); + + return command; + } + + private Command CreateLintCommand() + { + var command = new Command(Constants.Command.Lint, "Lints a .bicep file."); + + var inputFileArgument = new Argument("input-file") + { + Description = "The path to the .bicep file.", + Arity = ArgumentArity.ZeroOrOne, + }; + var filePatternOption = new Option("--pattern") + { + Description = "Lint all files matching the specified pattern.", + }; + var noRestoreOption = new Option("--no-restore") + { + Description = "Do not restore modules prior to linting.", + }; + var diagnosticsFormatOption = new Option("--diagnostics-format") + { + Description = "Set the format of diagnostics (Default, SARIF).", + }; + + command.Add(inputFileArgument); + command.Add(filePatternOption); + command.Add(noRestoreOption); + command.Add(diagnosticsFormatOption); + + command.SetAction((result, ct) => RunCommandAsync(async () => + { + var diagnosticsFormat = result.GetValue(diagnosticsFormatOption) ?? Arguments.DiagnosticsFormat.Default; + var args = new LintArguments( + result.GetValue(inputFileArgument), + result.GetValue(filePatternOption), + diagnosticsFormat, + result.GetValue(noRestoreOption)); + + return await services.GetRequiredService().RunAsync(args); + })); + + return command; + } + private Command CreateJsonRpcCommand() { var command = new Command(Constants.Command.JsonRpc, "Starts the Bicep JSON-RPC server."); @@ -843,30 +1077,6 @@ private static ImmutableDictionary ParseAdditionalArguments(IRea return additionalArguments.ToImmutableDictionary(); } - /// - /// Creates a pass-through command stub that forwards all unrecognized tokens to an - /// existing argument class and command handler. System.CommandLine's built-in options - /// (e.g. --help) are still handled; everything else lands in - /// and is passed to - /// as a plain string[]. - /// - /// Once a command is fully migrated, replace this stub with a that - /// declares its own and members. - /// - /// - private Command LegacyCommand(string name, string description, Func> handler) - { - var command = new Command(name, description) - { - TreatUnmatchedTokensAsErrors = false, - }; - - command.SetAction((ParseResult pr, CancellationToken ct) => - RunCommandAsync(() => handler(pr.UnmatchedTokens.ToArray()))); - - return command; - } - private static ILoggerFactory CreateLoggerFactory(IOContext io) { // apparently logging requires a factory factory 🤦‍ diff --git a/src/Bicep.Cli/Services/ArgumentParser.cs b/src/Bicep.Cli/Services/ArgumentParser.cs deleted file mode 100644 index 1850e547135..00000000000 --- a/src/Bicep.Cli/Services/ArgumentParser.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.IO.Abstractions; -using System.Text.RegularExpressions; -using Bicep.Cli.Arguments; - -namespace Bicep.Cli.Services -{ - public static class ArgumentParser - { - public static ArgumentsBase? TryParse(string[] args, IFileSystem fileSystem) - { - if (args.Length < 1) - { - return null; - } - - // parse root arguments - if (new Regex(Constants.Argument.VersionRegex).IsMatch(args[0]) || - new Regex(Constants.Argument.HelpRegex).IsMatch(args[0]) || - new Regex(Constants.Argument.LicenseRegex).IsMatch(args[0]) || - new Regex(Constants.Argument.ThirdPartyNoticesRegex).IsMatch(args[0])) - { - return new RootArguments(args[0], Constants.Command.Root); - } - - // parse verb - return (args[0].ToLowerInvariant()) switch - { - Constants.Command.Format => new FormatArguments(args[1..]), - Constants.Command.PublishExtension => new PublishExtensionArguments(args[1..]), - Constants.Command.Publish => new PublishArguments(args[1..]), - Constants.Command.Restore => new RestoreArguments(args[1..]), - Constants.Command.Lint => new LintArguments(args[1..]), - _ => null, - }; - } - } -} From de1ec233a79c14491a718c70f57c356ae24296c9 Mon Sep 17 00:00:00 2001 From: Anthony Martin <38542602+anthony-c-martin@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:36:07 -0400 Subject: [PATCH 7/8] Fix up tests --- src/Bicep.Cli.IntegrationTests/HelpTests.cs | 377 ++++++++++++++ .../InvalidArgsTests.cs | 238 +++++++++ .../RootCommandTests.cs | 46 +- src/Bicep.Cli/Arguments/ArgumentsBase.cs | 15 - src/Bicep.Cli/Arguments/DiagnosticsFormat.cs | 11 +- .../Arguments/InputOutputArgumentsResolver.cs | 4 +- src/Bicep.Cli/Commands/FormatCommand.cs | 8 +- src/Bicep.Cli/Constants/CliConstants.cs | 67 ++- src/Bicep.Cli/Helpers/ArgumentHelper.cs | 25 + .../{Commands => Helpers}/CliInfoPrinter.cs | 2 +- src/Bicep.Cli/Program.cs | 486 +++++++++++------- .../Commands/GenerateCommandTests.cs | 25 +- .../Commands/ValidateCommandTests.cs | 58 +-- .../Mocks/MockConsole.cs | 52 +- .../ModuleFiles/BaseCommandHandlerTests.cs | 26 +- 15 files changed, 1087 insertions(+), 353 deletions(-) create mode 100644 src/Bicep.Cli.IntegrationTests/HelpTests.cs create mode 100644 src/Bicep.Cli.IntegrationTests/InvalidArgsTests.cs delete mode 100644 src/Bicep.Cli/Arguments/ArgumentsBase.cs rename src/Bicep.Cli/{Commands => Helpers}/CliInfoPrinter.cs (98%) diff --git a/src/Bicep.Cli.IntegrationTests/HelpTests.cs b/src/Bicep.Cli.IntegrationTests/HelpTests.cs new file mode 100644 index 00000000000..1ccffd7ba3d --- /dev/null +++ b/src/Bicep.Cli.IntegrationTests/HelpTests.cs @@ -0,0 +1,377 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Bicep.Core.UnitTests.Assertions; +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bicep.Cli.IntegrationTests +{ + [TestClass] + public class HelpTests : TestBase + { + [TestMethod] + public async Task Root_Help_ShouldSucceed_WithExpectedOutput() + { + var (output, error, result) = await Bicep("--help"); + + using (new AssertionScope()) + { + result.Should().Be(0); + error.Should().BeEmpty(); + output.Should().ContainAll( + "build", + "build-params", + "decompile", + "decompile-params", + "format", + "generate-params", + "lint", + "publish", + "restore", + "test", + "--version", + "--license", + "--third-party-notices"); + } + } + + [TestMethod] + public async Task Build_Help_ShouldSucceed_WithExpectedOutput() + { + var (output, error, result) = await Bicep("build", "--help"); + + using (new AssertionScope()) + { + result.Should().Be(0); + error.Should().BeEmpty(); + output.Should().ContainAll( + "build", + "Builds a .bicep file.", + "--stdout", + "--no-restore", + "--outdir", + "--outfile", + "--pattern", + "--diagnostics-format"); + } + } + + [TestMethod] + public async Task Test_Help_ShouldSucceed_WithExpectedOutput() + { + var (output, error, result) = await Bicep("test", "--help"); + + using (new AssertionScope()) + { + result.Should().Be(0); + error.Should().BeEmpty(); + output.Should().ContainAll( + "test", + "Runs tests in a .bicep file.", + "--no-restore", + "--diagnostics-format"); + } + } + + [TestMethod] + public async Task BuildParams_Help_ShouldSucceed_WithExpectedOutput() + { + var (output, error, result) = await Bicep("build-params", "--help"); + + using (new AssertionScope()) + { + result.Should().Be(0); + error.Should().BeEmpty(); + output.Should().ContainAll( + "build-params", + "Builds a .json file from a .bicepparam file.", + "--stdout", + "--no-restore", + "--outdir", + "--outfile", + "--pattern", + "--bicep-file", + "--diagnostics-format"); + } + } + + [TestMethod] + public async Task Format_Help_ShouldSucceed_WithExpectedOutput() + { + var (output, error, result) = await Bicep("format", "--help"); + + using (new AssertionScope()) + { + result.Should().Be(0); + error.Should().BeEmpty(); + output.Should().ContainAll( + "format", + "Formats a .bicep file.", + "--stdout", + "--outdir", + "--outfile", + "--pattern", + "--newline-kind", + "--indent-kind", + "--indent-size", + "--insert-final-newline"); + } + } + + [TestMethod] + public async Task GenerateParams_Help_ShouldSucceed_WithExpectedOutput() + { + var (output, error, result) = await Bicep("generate-params", "--help"); + + using (new AssertionScope()) + { + result.Should().Be(0); + error.Should().BeEmpty(); + output.Should().ContainAll( + "generate-params", + "Builds parameters file", + "--stdout", + "--no-restore", + "--outdir", + "--outfile", + "--output-format", + "--include-params"); + } + } + + [TestMethod] + public async Task Decompile_Help_ShouldSucceed_WithExpectedOutput() + { + var (output, error, result) = await Bicep("decompile", "--help"); + + using (new AssertionScope()) + { + result.Should().Be(0); + error.Should().BeEmpty(); + output.Should().ContainAll( + "decompile", + "Attempts to decompile a template .json file to .bicep.", + "--stdout", + "--force", + "--outdir", + "--outfile"); + } + } + + [TestMethod] + public async Task DecompileParams_Help_ShouldSucceed_WithExpectedOutput() + { + var (output, error, result) = await Bicep("decompile-params", "--help"); + + using (new AssertionScope()) + { + result.Should().Be(0); + error.Should().BeEmpty(); + output.Should().ContainAll( + "decompile-params", + "Attempts to decompile a parameters .json file to .bicepparam.", + "--stdout", + "--force", + "--outdir", + "--outfile", + "--bicep-file"); + } + } + + [TestMethod] + public async Task Publish_Help_ShouldSucceed_WithExpectedOutput() + { + var (output, error, result) = await Bicep("publish", "--help"); + + using (new AssertionScope()) + { + result.Should().Be(0); + error.Should().BeEmpty(); + output.Should().ContainAll( + "publish", + "Publishes the .bicep file to the module registry.", + "--target", + "--documentation-uri", + "--no-restore", + "--force", + "--with-source"); + } + } + + [TestMethod] + public async Task PublishExtension_Help_ShouldSucceed_WithExpectedOutput() + { + var (output, error, result) = await Bicep("publish-extension", "--help"); + + using (new AssertionScope()) + { + result.Should().Be(0); + error.Should().BeEmpty(); + output.Should().ContainAll( + "publish-extension", + "Publishes a Bicep extension to a registry.", + "--target", + "--force", + "--bin-linux-x64", + "--bin-win-x64"); + } + } + + [TestMethod] + public async Task Restore_Help_ShouldSucceed_WithExpectedOutput() + { + var (output, error, result) = await Bicep("restore", "--help"); + + using (new AssertionScope()) + { + result.Should().Be(0); + error.Should().BeEmpty(); + output.Should().ContainAll( + "restore", + "Restores external modules", + "--pattern", + "--force"); + } + } + + [TestMethod] + public async Task Lint_Help_ShouldSucceed_WithExpectedOutput() + { + var (output, error, result) = await Bicep("lint", "--help"); + + using (new AssertionScope()) + { + result.Should().Be(0); + error.Should().BeEmpty(); + output.Should().ContainAll( + "lint", + "Lints a .bicep file.", + "--pattern", + "--no-restore", + "--diagnostics-format"); + } + } + + [TestMethod] + public async Task JsonRpc_Help_ShouldSucceed_WithExpectedOutput() + { + var (output, error, result) = await Bicep("jsonrpc", "--help"); + + using (new AssertionScope()) + { + result.Should().Be(0); + error.Should().BeEmpty(); + output.Should().ContainAll( + "jsonrpc", + "JSONRPC", + "--pipe", + "--socket", + "--stdio"); + } + } + + [TestMethod] + public async Task LocalDeploy_Help_ShouldSucceed_WithExpectedOutput() + { + var (output, error, result) = await Bicep("local-deploy", "--help"); + + using (new AssertionScope()) + { + result.Should().Be(0); + error.Should().BeEmpty(); + output.Should().ContainAll( + "local-deploy", + "local deployment", + "--no-restore", + "--format"); + } + } + + [TestMethod] + public async Task Snapshot_Help_ShouldSucceed_WithExpectedOutput() + { + var (output, error, result) = await Bicep("snapshot", "--help"); + + using (new AssertionScope()) + { + result.Should().Be(0); + error.Should().BeEmpty(); + output.Should().ContainAll( + "snapshot", + "deployment snapshot", + "--mode", + "--tenant-id", + "--subscription-id", + "--location", + "--resource-group", + "--deployment-name"); + } + } + + [TestMethod] + public async Task Deploy_Help_ShouldSucceed_WithExpectedOutput() + { + var (output, error, result) = await Bicep("deploy", "--help"); + + using (new AssertionScope()) + { + result.Should().Be(0); + error.Should().BeEmpty(); + output.Should().ContainAll( + "deploy", + "Deploys infrastructure", + "--no-restore", + "--format"); + } + } + + [TestMethod] + public async Task WhatIf_Help_ShouldSucceed_WithExpectedOutput() + { + var (output, error, result) = await Bicep("what-if", "--help"); + + using (new AssertionScope()) + { + result.Should().Be(0); + error.Should().BeEmpty(); + output.Should().ContainAll( + "what-if", + "Previews the changes", + "--no-restore"); + } + } + + [TestMethod] + public async Task Teardown_Help_ShouldSucceed_WithExpectedOutput() + { + var (output, error, result) = await Bicep("teardown", "--help"); + + using (new AssertionScope()) + { + result.Should().Be(0); + error.Should().BeEmpty(); + output.Should().ContainAll( + "teardown", + "Tears down resources", + "--no-restore"); + } + } + + [TestMethod] + public async Task Console_Help_ShouldSucceed_WithExpectedOutput() + { + var (output, error, result) = await Bicep("console", "--help"); + + using (new AssertionScope()) + { + result.Should().Be(0); + error.Should().BeEmpty(); + output.Should().ContainAll( + "console", + "Opens an interactive Bicep console."); + } + } + } +} diff --git a/src/Bicep.Cli.IntegrationTests/InvalidArgsTests.cs b/src/Bicep.Cli.IntegrationTests/InvalidArgsTests.cs new file mode 100644 index 00000000000..e66c166ec20 --- /dev/null +++ b/src/Bicep.Cli.IntegrationTests/InvalidArgsTests.cs @@ -0,0 +1,238 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bicep.Cli.IntegrationTests +{ + [TestClass] + public class InvalidArgsTests : TestBase + { + [TestMethod] + public async Task Empty_args_should_fail_with_error() + { + var (output, error, result) = await Bicep(); + + using (new AssertionScope()) + { + result.Should().NotBe(0); + error.Should().NotBeEmpty(); + } + } + + [TestMethod] + public async Task Unknown_command_should_fail_with_error() + { + var (output, error, result) = await Bicep("notacommand"); + + using (new AssertionScope()) + { + result.Should().NotBe(0); + error.Should().NotBeEmpty(); + } + } + + [DataTestMethod] + // Missing input file (CommandLineException thrown from action) + [DataRow(new[] { "test" }, "The input file path was not specified")] + [DataRow(new[] { "decompile" }, "The input file path was not specified")] + [DataRow(new[] { "generate-params" }, "The input file path was not specified")] + [DataRow(new[] { "publish" }, "The input file path was not specified")] + // Missing required option + [DataRow(new[] { "publish", "file.bicep" }, "The target module was not specified.")] + // Either input file or --pattern required (thrown from InputOutputArgumentsResolver) + [DataRow(new[] { "build" }, "Either the input file path or the --pattern parameter must be specified")] + [DataRow(new[] { "build-params" }, "Either the input file path or the --pattern parameter must be specified")] + [DataRow(new[] { "restore" }, "Either the input file path or the --pattern parameter must be specified")] + [DataRow(new[] { "lint" }, "Either the input file path or the --pattern parameter must be specified")] + [DataRow(new[] { "format" }, "Either the input file path or the --pattern parameter must be specified")] + // Unrecognized options (rejected by System.CommandLine for commands with TreatUnmatchedTokensAsErrors = true) + [DataRow(new[] { "build", "--wibble" }, "Unrecognized parameter \"--wibble\"")] + [DataRow(new[] { "test", "--wibble" }, "Unrecognized parameter \"--wibble\"")] + [DataRow(new[] { "build-params", "--wibble" }, "Unrecognized parameter \"--wibble\"")] + [DataRow(new[] { "decompile", "--wibble" }, "Unrecognized parameter \"--wibble\"")] + [DataRow(new[] { "generate-params", "--wibble" }, "Unrecognized parameter \"--wibble\"")] + [DataRow(new[] { "restore", "--fake" }, "Unrecognized parameter \"--fake\"")] + [DataRow(new[] { "lint", "--fake" }, "Unrecognized parameter \"--fake\"")] + [DataRow(new[] { "--wiggle" }, "Unrecognized command or argument '--wiggle'.")] + public async Task Invalid_args_should_fail_with_expected_error(string[] args, string expectedError) + { + var (output, error, result) = await Bicep(args); + + using (new AssertionScope()) + { + result.Should().NotBe(0); + error.Should().Contain(expectedError); + } + } + + [TestMethod] + public async Task JsonRpc_with_pipe_and_socket_should_fail() + { + var (output, error, result) = await Bicep("jsonrpc", "--pipe", "foo", "--socket", "1234"); + + using (new AssertionScope()) + { + result.Should().NotBe(0); + error.Should().Contain("Only one of --pipe, --socket, or --stdio may be specified."); + } + } + + [TestMethod] + public async Task JsonRpc_with_pipe_and_stdio_should_fail() + { + var (output, error, result) = await Bicep("jsonrpc", "--pipe", "foo", "--stdio"); + + using (new AssertionScope()) + { + result.Should().NotBe(0); + error.Should().Contain("Only one of --pipe, --socket, or --stdio may be specified."); + } + } + + [TestMethod] + public async Task JsonRpc_with_socket_and_stdio_should_fail() + { + var (output, error, result) = await Bicep("jsonrpc", "--socket", "1234", "--stdio"); + + using (new AssertionScope()) + { + result.Should().NotBe(0); + error.Should().Contain("Only one of --pipe, --socket, or --stdio may be specified."); + } + } + + [TestMethod] + public async Task Publish_with_invalid_documentation_uri_should_fail() + { + var (output, error, result) = await Bicep("publish", "file.bicep", "--target", "br:example.azurecr.io/module:v1", "--documentation-uri", "not-a-uri"); + + using (new AssertionScope()) + { + result.Should().NotBe(0); + error.Should().Contain("The --documentation-uri should be a well formed uri string."); + } + } + + [TestMethod] + public async Task Publish_with_empty_documentation_uri_should_fail() + { + // --documentation-uri present but no value supplied → Tokens.Count == 0 + var (output, error, result) = await Bicep("publish", "file.bicep", "--target", "br:example.azurecr.io/module:v1", "--documentation-uri"); + + using (new AssertionScope()) + { + result.Should().NotBe(0); + error.Should().Contain("The --documentation-uri parameter expects an argument."); + } + } + + [TestMethod] + public async Task Publish_with_documentation_uri_specified_twice_should_fail() + { + // --documentation-uri supplied twice → Tokens.Count > 1 + var (output, error, result) = await Bicep("publish", "file.bicep", "--target", "br:example.azurecr.io/module:v1", "--documentation-uri", "https://example.com", "--documentation-uri", "https://other.com"); + + using (new AssertionScope()) + { + result.Should().NotBe(0); + error.Should().Contain("The --documentation-uri parameter cannot be specified more than once."); + } + } + + [TestMethod] + public async Task Format_with_stdout_and_outdir_should_fail() + { + var (output, error, result) = await Bicep("format", "--stdout", "--outdir", ".", "file.bicep"); + + using (new AssertionScope()) + { + result.Should().NotBe(0); + error.Should().Contain("The --outdir and --stdout parameters cannot both be used"); + } + } + + [TestMethod] + public async Task Format_with_outdir_and_outfile_should_fail() + { + var (output, error, result) = await Bicep("format", "--outdir", ".", "--outfile", "out.bicep", "file.bicep"); + + using (new AssertionScope()) + { + result.Should().NotBe(0); + error.Should().Contain("The --outdir and --outfile parameters cannot both be used"); + } + } + + [DataTestMethod] + // build: --pattern conflicts + [DataRow(new[] { "build", "--stdout", "--pattern", "*.bicep" }, "The --stdout parameter cannot be used with the --pattern parameter")] + [DataRow(new[] { "build", "--outfile", "foo", "--pattern", "*.bicep" }, "The --outfile parameter cannot be used with the --pattern parameter")] + // build: output option conflicts + [DataRow(new[] { "build", "--stdout", "--outdir", ".", "file.bicep" }, "The --outdir and --stdout parameters cannot both be used")] + [DataRow(new[] { "build", "--stdout", "--outfile", "foo", "file.bicep" }, "The --outfile and --stdout parameters cannot both be used")] + [DataRow(new[] { "build", "--outdir", ".", "--outfile", "foo", "file.bicep" }, "The --outdir and --outfile parameters cannot both be used")] + // build-params: --pattern conflicts + [DataRow(new[] { "build-params", "--bicep-file", "a.bicep", "--pattern", "*.bicepparam" }, "The --bicep-file parameter cannot be used with the --pattern parameter")] + [DataRow(new[] { "build-params", "--stdout", "--pattern", "*.bicepparam" }, "The --stdout parameter cannot be used with the --pattern parameter")] + [DataRow(new[] { "build-params", "--outfile", "foo", "--pattern", "*.bicepparam" }, "The --outfile parameter cannot be used with the --pattern parameter")] + // build-params: output option conflicts + [DataRow(new[] { "build-params", "--stdout", "--outdir", ".", "file.bicepparam" }, "The --outdir and --stdout parameters cannot both be used")] + [DataRow(new[] { "build-params", "--stdout", "--outfile", "foo", "file.bicepparam" }, "The --outfile and --stdout parameters cannot both be used")] + [DataRow(new[] { "build-params", "--outdir", ".", "--outfile", "foo", "file.bicepparam" }, "The --outdir and --outfile parameters cannot both be used")] + // decompile: output option conflicts + [DataRow(new[] { "decompile", "--stdout", "--outdir", ".", "file.json" }, "The --outdir and --stdout parameters cannot both be used")] + [DataRow(new[] { "decompile", "--stdout", "--outfile", "foo", "file.json" }, "The --outfile and --stdout parameters cannot both be used")] + [DataRow(new[] { "decompile", "--outdir", ".", "--outfile", "foo", "file.json" }, "The --outdir and --outfile parameters cannot both be used")] + // decompile-params: output option conflicts + [DataRow(new[] { "decompile-params", "--stdout", "--outdir", ".", "file.json" }, "The --outdir and --stdout parameters cannot both be used")] + [DataRow(new[] { "decompile-params", "--stdout", "--outfile", "foo", "file.json" }, "The --outfile and --stdout parameters cannot both be used")] + [DataRow(new[] { "decompile-params", "--outdir", ".", "--outfile", "foo", "file.json" }, "The --outdir and --outfile parameters cannot both be used")] + // format: --pattern conflicts + [DataRow(new[] { "format", "--stdout", "--pattern", "*.bicep" }, "The --stdout parameter cannot be used with the --pattern parameter")] + [DataRow(new[] { "format", "--outdir", ".", "--pattern", "*.bicep" }, "The --outdir parameter cannot be used with the --pattern parameter")] + [DataRow(new[] { "format", "--outfile", "foo", "--pattern", "*.bicep" }, "The --outfile parameter cannot be used with the --pattern parameter")] + // format: output option conflicts (--outfile + --stdout missing from old FormatCommand.cs) + [DataRow(new[] { "format", "--stdout", "--outfile", "foo", "file.bicep" }, "The --outfile and --stdout parameters cannot both be used")] + // generate-params: output option conflicts + [DataRow(new[] { "generate-params", "--stdout", "--outdir", ".", "file.bicep" }, "The --outdir and --stdout parameters cannot both be used")] + [DataRow(new[] { "generate-params", "--stdout", "--outfile", "foo", "file.bicep" }, "The --outfile and --stdout parameters cannot both be used")] + [DataRow(new[] { "generate-params", "--outdir", ".", "--outfile", "foo", "file.bicep" }, "The --outdir and --outfile parameters cannot both be used")] + public async Task Conflicting_output_options_should_fail_with_expected_error(string[] args, string expectedError) + { + var (output, error, result) = await Bicep(args); + + using (new AssertionScope()) + { + result.Should().NotBe(0); + error.Should().Contain(expectedError); + } + } + + [TestMethod] + public async Task PublishExtension_without_input_file_or_binaries_should_fail() + { + var (output, error, result) = await Bicep("publish-extension", "--target", "br:example.azurecr.io/ext:v1"); + + using (new AssertionScope()) + { + result.Should().NotBe(0); + error.Should().Contain("The input file path was not specified."); + } + } + + [TestMethod] + public async Task PublishExtension_without_target_should_fail() + { + var (output, error, result) = await Bicep("publish-extension", "index.json"); + + using (new AssertionScope()) + { + result.Should().NotBe(0); + error.Should().Contain("The target extension was not specified."); + } + } + } +} diff --git a/src/Bicep.Cli.IntegrationTests/RootCommandTests.cs b/src/Bicep.Cli.IntegrationTests/RootCommandTests.cs index 1fc1320216b..382aa138dea 100644 --- a/src/Bicep.Cli.IntegrationTests/RootCommandTests.cs +++ b/src/Bicep.Cli.IntegrationTests/RootCommandTests.cs @@ -19,10 +19,9 @@ public async Task Build_WithWrongArgs_ShouldFail_WithExpectedErrorMessage() using (new AssertionScope()) { result.Should().Be(1); - output.Should().BeEmpty(); error.Should().NotBeEmpty(); - error.Should().Contain($"Unrecognized arguments \"wrong fake broken\" specified. Use \"bicep --help\" to view available options."); + error.Should().ContainAll("wrong", "fake", "broken"); } } @@ -41,43 +40,6 @@ public async Task BicepVersionShouldPrintVersionInformation() } } - [TestMethod] - public async Task BicepHelpShouldPrintHelp() - { - var settings = new InvocationSettings() { FeatureOverrides = new(RegistryEnabled: true) }; - - var (output, error, result) = await Bicep(settings, "--help"); - - using (new AssertionScope()) - { - result.Should().Be(0); - error.Should().BeEmpty(); - - output.Should().NotBeEmpty(); - output.Should().ContainAll( - "build", - "[options]", - "", - ".bicep", - "Arguments:", - "Options:", - "--outdir", - "--outfile", - "--stdout", - "--diagnostics-format", - "--version", - "--help", - "information", - "version", - "bicep", - "usage", - "--license", - "--third-party-notices", - "license information", - "third-party notices"); - } - } - [TestMethod] public async Task BicepLicenseShouldPrintLicense() { @@ -138,11 +100,7 @@ public async Task BicepHelpShouldAlwaysIncludePublish() output.Should().ContainAll( "publish", "Publishes", - "registry", - "reference", - "azurecr.io", - "br", - "--target"); + "registry"); } } } diff --git a/src/Bicep.Cli/Arguments/ArgumentsBase.cs b/src/Bicep.Cli/Arguments/ArgumentsBase.cs deleted file mode 100644 index 17ebc58fdcc..00000000000 --- a/src/Bicep.Cli/Arguments/ArgumentsBase.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace Bicep.Cli.Arguments -{ - public abstract class ArgumentsBase - { - public string CommandName { get; } - - protected ArgumentsBase(string commandName) - { - CommandName = commandName; - } - } -} diff --git a/src/Bicep.Cli/Arguments/DiagnosticsFormat.cs b/src/Bicep.Cli/Arguments/DiagnosticsFormat.cs index 86c6b4182d7..6efc688205c 100644 --- a/src/Bicep.Cli/Arguments/DiagnosticsFormat.cs +++ b/src/Bicep.Cli/Arguments/DiagnosticsFormat.cs @@ -1,11 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace Bicep.Cli.Arguments +namespace Bicep.Cli.Arguments; + +public enum DiagnosticsFormat { - public enum DiagnosticsFormat - { - Default, - Sarif - } + Default, + Sarif } diff --git a/src/Bicep.Cli/Arguments/InputOutputArgumentsResolver.cs b/src/Bicep.Cli/Arguments/InputOutputArgumentsResolver.cs index 2913a7a01ec..f3cfc0cfe9e 100644 --- a/src/Bicep.Cli/Arguments/InputOutputArgumentsResolver.cs +++ b/src/Bicep.Cli/Arguments/InputOutputArgumentsResolver.cs @@ -65,7 +65,7 @@ public IReadOnlyList ResolveFilePatternInputArguments(IFilePatternInputAr return result; } - throw new InvalidOperationException("Either InputFile or FilePattern must be specified."); + throw new CommandLineException("Either the input file path or the --pattern parameter must be specified"); } public IReadOnlyList<(IOUri InputUri, IOUri OutputUri)> ResolveFilePatternInputOutputArguments(T arguments) @@ -95,7 +95,7 @@ public IReadOnlyList ResolveFilePatternInputArguments(IFilePatternInputAr return result; } - throw new InvalidOperationException("Either InputFile or FilePattern must be specified."); + throw new CommandLineException("Either the input file path or the --pattern parameter must be specified"); } private IOUri ResolveOutputUri(IOUri inputUri, string? outputDir, string? outputFile, string outputFileExtension) diff --git a/src/Bicep.Cli/Commands/FormatCommand.cs b/src/Bicep.Cli/Commands/FormatCommand.cs index 56c187a5e2c..2918a992839 100644 --- a/src/Bicep.Cli/Commands/FormatCommand.cs +++ b/src/Bicep.Cli/Commands/FormatCommand.cs @@ -3,6 +3,7 @@ using System.IO.Abstractions; using Bicep.Cli.Arguments; +using Bicep.Cli.Constants; using Bicep.Cli.Helpers; using Bicep.Core.Configuration; using Bicep.Core.Diagnostics; @@ -26,10 +27,15 @@ public class FormatCommand( { public int Run(FormatArguments args) { + if (args.FilePattern is not null && args.OutputDir is not null) + { + throw new CommandLineException($"The {Option.OutDir} parameter cannot be used with the {Option.Pattern} parameter"); + } + ArgumentHelper.ValidateOutputOptions(args.OutputToStdOut, args.OutputDir, args.OutputFile, args.FilePattern); + foreach (var (inputUri, outputUri) in inputOutputArgumentsResolver.ResolveFilePatternInputOutputArguments(args)) { ArgumentHelper.ValidateBicepOrBicepParamFile(inputUri); - this.Format(args, inputUri, outputUri, args.OutputToStdOut); } diff --git a/src/Bicep.Cli/Constants/CliConstants.cs b/src/Bicep.Cli/Constants/CliConstants.cs index 7986dbc6036..dcb0edbcb8d 100644 --- a/src/Bicep.Cli/Constants/CliConstants.cs +++ b/src/Bicep.Cli/Constants/CliConstants.cs @@ -28,9 +28,68 @@ public static class Command public static class Argument { - public const string VersionRegex = @"^(--version|-v)$"; - public const string HelpRegex = @"^(--help|-h)$"; - public const string LicenseRegex = @"^--license$"; - public const string ThirdPartyNoticesRegex = @"^--third-party-notices$"; + public const string InputFile = "Input file"; + public const string IndexFile = "Index file"; + public const string ParametersFile = "Parameters file"; + } + + public static class Option + { + // Root options + public const string Version = "--version"; + public const string VersionShort = "-v"; + public const string License = "--license"; + public const string ThirdPartyNotices = "--third-party-notices"; + + // Common output options + public const string Stdout = "--stdout"; + public const string OutDir = "--outdir"; + public const string OutFile = "--outfile"; + public const string Pattern = "--pattern"; + public const string NoRestore = "--no-restore"; + public const string Force = "--force"; + public const string DiagnosticsFormat = "--diagnostics-format"; + + // Build / BuildParams + public const string BicepFile = "--bicep-file"; + + // GenerateParams + public const string OutputFormat = "--output-format"; + public const string IncludeParams = "--include-params"; + + // Format + public const string NewlineKind = "--newline-kind"; + public const string IndentKind = "--indent-kind"; + public const string IndentSize = "--indent-size"; + public const string InsertFinalNewline = "--insert-final-newline"; + + // Publish + public const string Target = "--target"; + public const string DocumentationUri = "--documentation-uri"; + public const string WithSource = "--with-source"; + + // PublishExtension binaries + public const string BinLinuxX64 = "--bin-linux-x64"; + public const string BinLinuxArm64 = "--bin-linux-arm64"; + public const string BinOsxX64 = "--bin-osx-x64"; + public const string BinOsxArm64 = "--bin-osx-arm64"; + public const string BinWinX64 = "--bin-win-x64"; + public const string BinWinArm64 = "--bin-win-arm64"; + + // JsonRpc + public const string Pipe = "--pipe"; + public const string Socket = "--socket"; + public const string Stdio = "--stdio"; + + // Deploy / local-deploy / what-if / teardown + public const string Format = "--format"; + + // Snapshot + public const string Mode = "--mode"; + public const string TenantId = "--tenant-id"; + public const string SubscriptionId = "--subscription-id"; + public const string Location = "--location"; + public const string ResourceGroup = "--resource-group"; + public const string DeploymentName = "--deployment-name"; } } diff --git a/src/Bicep.Cli/Helpers/ArgumentHelper.cs b/src/Bicep.Cli/Helpers/ArgumentHelper.cs index 2e7d8c0bae6..6d3703b57a5 100644 --- a/src/Bicep.Cli/Helpers/ArgumentHelper.cs +++ b/src/Bicep.Cli/Helpers/ArgumentHelper.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO.Abstractions; using Bicep.Cli.Arguments; +using Bicep.Cli.Constants; using Bicep.Cli.Logging; using Bicep.Core.Extensions; using Bicep.IO.Abstraction; @@ -13,6 +14,30 @@ namespace Bicep.Cli.Helpers; public class ArgumentHelper { + public static void ValidateOutputOptions(bool outputToStdOut, string? outputDir, string? outputFile, string? filePattern = null) + { + if (filePattern is not null && outputToStdOut) + { + throw new CommandLineException($"The {Option.Stdout} parameter cannot be used with the {Option.Pattern} parameter"); + } + if (filePattern is not null && outputFile is not null) + { + throw new CommandLineException($"The {Option.OutFile} parameter cannot be used with the {Option.Pattern} parameter"); + } + if (outputToStdOut && outputDir is not null) + { + throw new CommandLineException($"The {Option.OutDir} and {Option.Stdout} parameters cannot both be used"); + } + if (outputToStdOut && outputFile is not null) + { + throw new CommandLineException($"The {Option.OutFile} and {Option.Stdout} parameters cannot both be used"); + } + if (outputDir is not null && outputFile is not null) + { + throw new CommandLineException($"The {Option.OutDir} and {Option.OutFile} parameters cannot both be used"); + } + } + public static DiagnosticsFormat ToDiagnosticsFormat(string? format) { if (format is null || (format is not null && format.Equals("default", StringComparison.OrdinalIgnoreCase))) diff --git a/src/Bicep.Cli/Commands/CliInfoPrinter.cs b/src/Bicep.Cli/Helpers/CliInfoPrinter.cs similarity index 98% rename from src/Bicep.Cli/Commands/CliInfoPrinter.cs rename to src/Bicep.Cli/Helpers/CliInfoPrinter.cs index 4dc64d571f5..a19bb50febb 100644 --- a/src/Bicep.Cli/Commands/CliInfoPrinter.cs +++ b/src/Bicep.Cli/Helpers/CliInfoPrinter.cs @@ -5,7 +5,7 @@ using Bicep.Core.Exceptions; using Bicep.Core.Utils; -namespace Bicep.Cli.Commands +namespace Bicep.Cli.Helpers { public static class CliInfoPrinter { diff --git a/src/Bicep.Cli/Program.cs b/src/Bicep.Cli/Program.cs index bda5cae8910..678b0adaea8 100644 --- a/src/Bicep.Cli/Program.cs +++ b/src/Bicep.Cli/Program.cs @@ -26,6 +26,7 @@ using Spectre.Console; using SclRootCommand = System.CommandLine.RootCommand; +using System.CommandLine.Parsing; public class HelpExamplesAction : SynchronousCommandLineAction { @@ -97,28 +98,30 @@ public async Task RunAsync(string[] args, CancellationToken cancellationTok Trace.WriteLine($"Bicep version: {environment.GetVersionString()}, OS: {environment.CurrentPlatform?.ToString() ?? "unknown"}, Architecture: {environment.CurrentArchitecture}, CLI arguments: \"{string.Join(' ', args)}\""); var rootCommand = BuildCommandLine(cancellationToken); - return await rootCommand.Parse(args).InvokeAsync(cancellationToken: cancellationToken); + var invocationConfig = new InvocationConfiguration + { + Output = io.Output.Writer, + Error = io.Error.Writer, + }; + return await rootCommand.Parse(args).InvokeAsync(invocationConfig, cancellationToken); } private SclRootCommand BuildCommandLine(CancellationToken cancellationToken) { - var rootCommand = new SclRootCommand("Bicep CLI") - { - TreatUnmatchedTokensAsErrors = true, - }; + var rootCommand = new SclRootCommand("Bicep CLI"); // System.CommandLine adds --version and --help to RootCommand by default. // Replace both with custom options so their output goes through io.Output.Writer // (not Console.Out) and --version prints the Bicep-specific version string. - var builtInVersion = rootCommand.Options.FirstOrDefault(o => o.Name == "--version"); + var builtInVersion = rootCommand.Options.FirstOrDefault(o => o.Name == Constants.Option.Version); if (builtInVersion is not null) { rootCommand.Options.Remove(builtInVersion); } - var versionOption = new Option("--version", "-v") { Description = "Show version information." }; - var licenseOption = new Option("--license") { Description = "Print license information." }; - var thirdPartyNoticesOption = new Option("--third-party-notices") { Description = "Print third-party notice information." }; + var versionOption = new Option(Constants.Option.Version, Constants.Option.VersionShort) { Description = "Shows bicep version information." }; + var licenseOption = new Option(Constants.Option.License) { Description = "Prints license information." }; + var thirdPartyNoticesOption = new Option(Constants.Option.ThirdPartyNotices) { Description = "Prints third-party notices." }; // rootCommand.Add(helpOption); rootCommand.Add(versionOption); @@ -132,19 +135,19 @@ private SclRootCommand BuildCommandLine(CancellationToken cancellationToken) if (pr.GetValue(versionOption)) { - Commands.CliInfoPrinter.PrintVersion(io, environment); + CliInfoPrinter.PrintVersion(io, environment); return 0; } if (pr.GetValue(licenseOption)) { - Commands.CliInfoPrinter.PrintLicense(io); + CliInfoPrinter.PrintLicense(io); return 0; } if (pr.GetValue(thirdPartyNoticesOption)) { - Commands.CliInfoPrinter.PrintThirdPartyNotices(io); + CliInfoPrinter.PrintThirdPartyNotices(io); return 0; } @@ -199,34 +202,34 @@ private Command CreateBuildCommand() TreatUnmatchedTokensAsErrors = true, }; - var inputFileArgument = new Argument("input-file") + var inputFileArgument = new Argument(Constants.Argument.InputFile) { Description = "The path to the input .bicep file.", Arity = ArgumentArity.ZeroOrOne, }; - var stdoutOption = new Option("--stdout") + var stdoutOption = new Option(Constants.Option.Stdout) { - Description = "Print output to stdout.", + Description = "Prints the output to stdout.", }; - var noRestoreOption = new Option("--no-restore") + var noRestoreOption = new Option(Constants.Option.NoRestore) { - Description = "Do not restore modules prior to building.", + Description = "Builds the bicep file without restoring external modules.", }; - var outDirOption = new Option("--outdir") + var outDirOption = new Option(Constants.Option.OutDir) { - Description = "Save output to the specified directory.", + Description = "Saves the output at the specified directory.", }; - var outFileOption = new Option("--outfile") + var outFileOption = new Option(Constants.Option.OutFile) { - Description = "Save output to the specified file path.", + Description = "Saves the output as the specified file path.", }; - var filePatternOption = new Option("--pattern") + var filePatternOption = new Option(Constants.Option.Pattern) { - Description = "Build all files matching the specified pattern.", + Description = "Builds all files matching the specified glob pattern.", }; - var diagnosticsFormatOption = new Option("--diagnostics-format") + var diagnosticsFormatOption = new Option(Constants.Option.DiagnosticsFormat) { - Description = "Set the format of diagnostics (Default, SARIF).", + Description = "Sets the diagnostics format. Valid values are (Default, SARIF).", }; command.Add(inputFileArgument); @@ -236,16 +239,24 @@ private Command CreateBuildCommand() command.Add(outFileOption); command.Add(filePatternOption); command.Add(diagnosticsFormatOption); + command.Validators.Add(result => ValidatePositionalArgument(result, inputFileArgument)); command.SetAction((result, ct) => RunCommandAsync(async () => { + var outputToStdOut = result.GetValue(stdoutOption); + var outputDir = result.GetValue(outDirOption); + var outputFile = result.GetValue(outFileOption); + var filePattern = result.GetValue(filePatternOption); + + ArgumentHelper.ValidateOutputOptions(outputToStdOut, outputDir, outputFile, filePattern); + var args = new BuildArguments( result.GetValue(inputFileArgument), - result.GetValue(stdoutOption), + outputToStdOut, result.GetValue(noRestoreOption), - result.GetValue(outDirOption), - result.GetValue(outFileOption), - result.GetValue(filePatternOption), + outputDir, + outputFile, + filePattern, result.GetValue(diagnosticsFormatOption)); return await services.GetRequiredService().RunAsync(args); @@ -261,15 +272,16 @@ private Command CreateTestCommand() TreatUnmatchedTokensAsErrors = true, }; - var inputFileArgument = new Argument("input-file") + var inputFileArgument = new Argument(Constants.Argument.InputFile) { Description = "The path to the input .bicep file.", + Arity = ArgumentArity.ZeroOrOne, }; - var noRestoreOption = new Option("--no-restore") + var noRestoreOption = new Option(Constants.Option.NoRestore) { Description = "Do not restore modules prior to running tests.", }; - var diagnosticsFormatOption = new Option("--diagnostics-format") + var diagnosticsFormatOption = new Option(Constants.Option.DiagnosticsFormat) { Description = "Set the format of diagnostics (Default, SARIF).", }; @@ -277,11 +289,14 @@ private Command CreateTestCommand() command.Add(inputFileArgument); command.Add(noRestoreOption); command.Add(diagnosticsFormatOption); + command.Validators.Add(result => ValidatePositionalArgument(result, inputFileArgument)); command.SetAction((result, ct) => RunCommandAsync(async () => { + var inputFile = result.GetValue(inputFileArgument) + ?? throw new CommandLineException("The input file path was not specified"); var args = new TestArguments( - result.GetRequiredValue(inputFileArgument), + inputFile, result.GetValue(noRestoreOption), result.GetValue(diagnosticsFormatOption)); @@ -293,43 +308,43 @@ private Command CreateTestCommand() private Command CreateBuildParamsCommand() { - var command = new Command(Constants.Command.BuildParams, "Builds a .bicepparam file.") + var command = new Command(Constants.Command.BuildParams, "Builds a .json file from a .bicepparam file.") { TreatUnmatchedTokensAsErrors = true, }; - var inputFileArgument = new Argument("input-file") + var inputFileArgument = new Argument(Constants.Argument.InputFile) { Description = "The path to the input .bicepparam file.", Arity = ArgumentArity.ZeroOrOne, }; - var stdoutOption = new Option("--stdout") + var stdoutOption = new Option(Constants.Option.Stdout) { - Description = "Print output to stdout.", + Description = "Prints the output of building both the parameter file (.bicepparam) and the template it points to (.bicep) as json to stdout.", }; - var noRestoreOption = new Option("--no-restore") + var noRestoreOption = new Option(Constants.Option.NoRestore) { - Description = "Do not restore modules prior to building.", + Description = "Builds the bicep file (referenced in using declaration) without restoring external modules.", }; - var outDirOption = new Option("--outdir") + var outDirOption = new Option(Constants.Option.OutDir) { - Description = "Save output to the specified directory.", + Description = "Saves the output of building the parameter file only (.bicepparam) as json to the specified directory.", }; - var outFileOption = new Option("--outfile") + var outFileOption = new Option(Constants.Option.OutFile) { - Description = "Save output to the specified file path.", + Description = "Saves the output of building the parameter file only (.bicepparam) as json to the specified file path.", }; - var filePatternOption = new Option("--pattern") + var filePatternOption = new Option(Constants.Option.Pattern) { - Description = "Build all files matching the specified pattern.", + Description = "Builds all files matching the specified glob pattern.", }; - var bicepFileOption = new Option("--bicep-file") + var bicepFileOption = new Option(Constants.Option.BicepFile) { - Description = "Path to the .bicep template file that will be used to validate the .bicepparam file.", + Description = "Verifies if the specified bicep file path matches the one provided in the params file using declaration.", }; - var diagnosticsFormatOption = new Option("--diagnostics-format") + var diagnosticsFormatOption = new Option(Constants.Option.DiagnosticsFormat) { - Description = "Set the format of diagnostics (Default, SARIF).", + Description = "Sets the diagnostics format. Valid values are (Default, SARIF).", }; command.Add(inputFileArgument); @@ -340,17 +355,30 @@ private Command CreateBuildParamsCommand() command.Add(filePatternOption); command.Add(bicepFileOption); command.Add(diagnosticsFormatOption); + command.Validators.Add(result => ValidatePositionalArgument(result, inputFileArgument)); command.SetAction((result, ct) => RunCommandAsync(async () => { + var outputToStdOut = result.GetValue(stdoutOption); + var outputDir = result.GetValue(outDirOption); + var outputFile = result.GetValue(outFileOption); + var filePattern = result.GetValue(filePatternOption); + var bicepFile = result.GetValue(bicepFileOption); + + if (filePattern is not null && bicepFile is not null) + { + throw new CommandLineException("The --bicep-file parameter cannot be used with the --pattern parameter"); + } + ArgumentHelper.ValidateOutputOptions(outputToStdOut, outputDir, outputFile, filePattern); + var args = new BuildParamsArguments( result.GetValue(inputFileArgument), - result.GetValue(stdoutOption), + outputToStdOut, result.GetValue(noRestoreOption), - result.GetValue(outDirOption), - result.GetValue(outFileOption), - result.GetValue(filePatternOption), - result.GetValue(bicepFileOption), + outputDir, + outputFile, + filePattern, + bicepFile, result.GetValue(diagnosticsFormatOption)); return await services.GetRequiredService().RunAsync(args); @@ -361,38 +389,39 @@ private Command CreateBuildParamsCommand() private Command CreateGenerateParamsFileCommand() { - var command = new Command(Constants.Command.GenerateParamsFile, "Generates a parameters file for a .bicep file.") + var command = new Command(Constants.Command.GenerateParamsFile, "Builds parameters file from the given bicep file, updates if there is an existing parameters file.") { TreatUnmatchedTokensAsErrors = true, }; - var inputFileArgument = new Argument("input-file") + var inputFileArgument = new Argument(Constants.Argument.InputFile) { Description = "The path to the input .bicep file.", + Arity = ArgumentArity.ZeroOrOne, }; - var stdoutOption = new Option("--stdout") + var stdoutOption = new Option(Constants.Option.Stdout) { - Description = "Print output to stdout.", + Description = "Prints the output to stdout.", }; - var noRestoreOption = new Option("--no-restore") + var noRestoreOption = new Option(Constants.Option.NoRestore) { - Description = "Do not restore modules prior to generating.", + Description = "Generates the parameters file without restoring external modules.", }; - var outDirOption = new Option("--outdir") + var outDirOption = new Option(Constants.Option.OutDir) { - Description = "Save output to the specified directory.", + Description = "Saves the output at the specified directory.", }; - var outFileOption = new Option("--outfile") + var outFileOption = new Option(Constants.Option.OutFile) { - Description = "Save output to the specified file path.", + Description = "Saves the output as the specified file path.", }; - var outputFormatOption = new Option("--output-format") + var outputFormatOption = new Option(Constants.Option.OutputFormat) { - Description = "Output format (Json, BicepParam).", + Description = "Selects the output format (json, bicepparam).", }; - var includeParamsOption = new Option("--include-params") + var includeParamsOption = new Option(Constants.Option.IncludeParams) { - Description = "Which parameters to include (RequiredOnly, All).", + Description = "Selects which parameters to include into output (requiredonly, all).", }; command.Add(inputFileArgument); @@ -402,15 +431,24 @@ private Command CreateGenerateParamsFileCommand() command.Add(outFileOption); command.Add(outputFormatOption); command.Add(includeParamsOption); + command.Validators.Add(result => ValidatePositionalArgument(result, inputFileArgument)); command.SetAction((result, ct) => RunCommandAsync(async () => { + var outputToStdOut = result.GetValue(stdoutOption); + var outputDir = result.GetValue(outDirOption); + var outputFile = result.GetValue(outFileOption); + + ArgumentHelper.ValidateOutputOptions(outputToStdOut, outputDir, outputFile); + + var inputFile = result.GetValue(inputFileArgument) + ?? throw new CommandLineException("The input file path was not specified"); var args = new GenerateParametersFileArguments( - result.GetRequiredValue(inputFileArgument), - result.GetValue(stdoutOption), + inputFile, + outputToStdOut, result.GetValue(noRestoreOption), - result.GetValue(outDirOption), - result.GetValue(outFileOption), + outputDir, + outputFile, result.GetValue(outputFormatOption), result.GetValue(includeParamsOption)); @@ -427,25 +465,26 @@ private Command CreateDecompileCommand() TreatUnmatchedTokensAsErrors = true, }; - var inputFileArgument = new Argument("input-file") + var inputFileArgument = new Argument(Constants.Argument.InputFile) { Description = "The path to the ARM template .json file.", + Arity = ArgumentArity.ZeroOrOne, }; - var stdoutOption = new Option("--stdout") + var stdoutOption = new Option(Constants.Option.Stdout) { - Description = "Print output to stdout.", + Description = "Prints the output to stdout.", }; - var forceOption = new Option("--force") + var forceOption = new Option(Constants.Option.Force) { - Description = "Allow overwriting existing files.", + Description = "Allows overwriting the output file if it exists (applies only to 'bicep decompile' or 'bicep decompile-params').", }; - var outDirOption = new Option("--outdir") + var outDirOption = new Option(Constants.Option.OutDir) { - Description = "Save output to the specified directory.", + Description = "Saves the output at the specified directory.", }; - var outFileOption = new Option("--outfile") + var outFileOption = new Option(Constants.Option.OutFile) { - Description = "Save output to the specified file path.", + Description = "Saves the output as the specified file path.", }; command.Add(inputFileArgument); @@ -453,15 +492,24 @@ private Command CreateDecompileCommand() command.Add(forceOption); command.Add(outDirOption); command.Add(outFileOption); + command.Validators.Add(result => ValidatePositionalArgument(result, inputFileArgument)); command.SetAction((result, ct) => RunCommandAsync(async () => { + var outputToStdOut = result.GetValue(stdoutOption); + var outputDir = result.GetValue(outDirOption); + var outputFile = result.GetValue(outFileOption); + + ArgumentHelper.ValidateOutputOptions(outputToStdOut, outputDir, outputFile); + + var inputFile = result.GetValue(inputFileArgument) + ?? throw new CommandLineException("The input file path was not specified"); var args = new DecompileArguments( - result.GetRequiredValue(inputFileArgument), - result.GetValue(stdoutOption), + inputFile, + outputToStdOut, result.GetValue(forceOption), - result.GetValue(outDirOption), - result.GetValue(outFileOption)); + outputDir, + outputFile); return await services.GetRequiredService().RunAsync(args); })); @@ -476,29 +524,29 @@ private Command CreateDecompileParamsCommand() TreatUnmatchedTokensAsErrors = true, }; - var inputFileArgument = new Argument("input-file") + var inputFileArgument = new Argument(Constants.Argument.InputFile) { Description = "The path to the parameters .json file.", }; - var stdoutOption = new Option("--stdout") + var stdoutOption = new Option(Constants.Option.Stdout) { - Description = "Print output to stdout.", + Description = "Prints the output to stdout.", }; - var forceOption = new Option("--force") + var forceOption = new Option(Constants.Option.Force) { - Description = "Allow overwriting existing files.", + Description = "Allows overwriting the output file if it exists (applies only to 'bicep decompile' or 'bicep decompile-params').", }; - var outDirOption = new Option("--outdir") + var outDirOption = new Option(Constants.Option.OutDir) { - Description = "Save output to the specified directory.", + Description = "Saves the output at the specified directory.", }; - var outFileOption = new Option("--outfile") + var outFileOption = new Option(Constants.Option.OutFile) { - Description = "Save output to the specified file path.", + Description = "Saves the output as the specified file path.", }; - var bicepFileOption = new Option("--bicep-file") + var bicepFileOption = new Option(Constants.Option.BicepFile) { - Description = "Path to the .bicep template file associated with the parameters file.", + Description = "Path to the bicep template file that will be referenced in the using declaration.", }; command.Add(inputFileArgument); @@ -507,15 +555,22 @@ private Command CreateDecompileParamsCommand() command.Add(outDirOption); command.Add(outFileOption); command.Add(bicepFileOption); + command.Validators.Add(result => ValidateRequiredPositionalArgument(result, inputFileArgument)); command.SetAction((result, ct) => RunCommandAsync(async () => { + var outputToStdOut = result.GetValue(stdoutOption); + var outputDir = result.GetValue(outDirOption); + var outputFile = result.GetValue(outFileOption); + + ArgumentHelper.ValidateOutputOptions(outputToStdOut, outputDir, outputFile); + var args = new DecompileParamsArguments( result.GetRequiredValue(inputFileArgument), - result.GetValue(stdoutOption), + outputToStdOut, result.GetValue(forceOption), - result.GetValue(outDirOption), - result.GetValue(outFileOption), + outputDir, + outputFile, result.GetValue(bicepFileOption)); return await Task.FromResult(services.GetRequiredService().Run(args)); @@ -528,40 +583,40 @@ private Command CreateFormatCommand() { var command = new Command(Constants.Command.Format, "Formats a .bicep file."); - var inputFileArgument = new Argument("input-file") + var inputFileArgument = new Argument(Constants.Argument.InputFile) { Description = "The path to the input .bicep file.", Arity = ArgumentArity.ZeroOrOne, }; - var stdoutOption = new Option("--stdout") + var stdoutOption = new Option(Constants.Option.Stdout) { - Description = "Print output to stdout.", + Description = "Prints the output to stdout.", }; - var outDirOption = new Option("--outdir") + var outDirOption = new Option(Constants.Option.OutDir) { - Description = "Save output to the specified directory.", + Description = "Saves the output at the specified directory.", }; - var outFileOption = new Option("--outfile") + var outFileOption = new Option(Constants.Option.OutFile) { - Description = "Save output to the specified file path.", + Description = "Saves the output as the specified file path.", }; - var filePatternOption = new Option("--pattern") + var filePatternOption = new Option(Constants.Option.Pattern) { - Description = "Format all files matching the specified pattern.", + Description = "Formats all files matching the specified glob pattern.", }; - var newlineKindOption = new Option("--newline-kind") + var newlineKindOption = new Option(Constants.Option.NewlineKind) { - Description = "Set the newline kind (Auto, LF, CRLF, CR).", + Description = "Set newline char. Valid values are (Auto, LF, CRLF, CR).", }; - var indentKindOption = new Option("--indent-kind") + var indentKindOption = new Option(Constants.Option.IndentKind) { - Description = "Set the indent kind (Space, Tab).", + Description = "Set indentation kind. Valid values are (Space, Tab).", }; - var indentSizeOption = new Option("--indent-size") + var indentSizeOption = new Option(Constants.Option.IndentSize) { - Description = "Set the indent size (number of spaces).", + Description = "Number of spaces to indent with (only valid with --indent-kind set to Space).", }; - var insertFinalNewlineOption = new Option("--insert-final-newline") + var insertFinalNewlineOption = new Option(Constants.Option.InsertFinalNewline) { Description = "Insert a final newline.", }; @@ -575,6 +630,7 @@ private Command CreateFormatCommand() command.Add(indentKindOption); command.Add(indentSizeOption); command.Add(insertFinalNewlineOption); + command.Validators.Add(result => ValidatePositionalArgument(result, inputFileArgument)); command.SetAction((result, ct) => RunCommandAsync(() => { @@ -597,31 +653,33 @@ private Command CreateFormatCommand() private Command CreatePublishCommand() { - var command = new Command(Constants.Command.Publish, "Publishes a .bicep file to a registry."); + var command = new Command(Constants.Command.Publish, "Publishes the .bicep file to the module registry."); - var inputFileArgument = new Argument("input-file") + var inputFileArgument = new Argument(Constants.Argument.InputFile) { Description = "The path to the .bicep file to publish.", + Arity = ArgumentArity.ZeroOrOne, }; - var targetOption = new Option("--target") + var targetOption = new Option(Constants.Option.Target) { Description = "The target module reference.", }; - var documentationUriOption = new Option("--documentation-uri") + var documentationUriOption = new Option(Constants.Option.DocumentationUri) { - Description = "The documentation URI.", + Description = "Module documentation URI.", + Arity = ArgumentArity.ZeroOrMore, }; - var noRestoreOption = new Option("--no-restore") + var noRestoreOption = new Option(Constants.Option.NoRestore) { Description = "Do not restore modules prior to publishing.", }; - var forceOption = new Option("--force") + var forceOption = new Option(Constants.Option.Force) { - Description = "Force publish even if the module already exists.", + Description = "Overwrite existing published module or file.", }; - var withSourceOption = new Option("--with-source") + var withSourceOption = new Option(Constants.Option.WithSource) { - Description = "Publish with source code.", + Description = "[Experimental] Publish source code with the module.", }; command.Add(inputFileArgument); @@ -630,15 +688,35 @@ private Command CreatePublishCommand() command.Add(noRestoreOption); command.Add(forceOption); command.Add(withSourceOption); + command.Validators.Add(result => ValidatePositionalArgument(result, inputFileArgument)); command.SetAction((result, ct) => RunCommandAsync(async () => { + var inputFile = result.GetValue(inputFileArgument) + ?? throw new CommandLineException("The input file path was not specified"); var target = result.GetValue(targetOption) ?? throw new CommandLineException("The target module was not specified."); + var docUriResult = result.GetResult(documentationUriOption); + if (docUriResult is not null) + { + if (docUriResult.Tokens.Count == 0) + { + throw new CommandLineException("The --documentation-uri parameter expects an argument."); + } + if (docUriResult.Tokens.Count > 1) + { + throw new CommandLineException("The --documentation-uri parameter cannot be specified more than once."); + } + } + var documentationUri = docUriResult?.Tokens.Count == 1 ? docUriResult.Tokens[0].Value : null; + if (documentationUri is not null && !Uri.IsWellFormedUriString(documentationUri, UriKind.Absolute)) + { + throw new CommandLineException("The --documentation-uri should be a well formed uri string."); + } var args = new PublishArguments( - result.GetRequiredValue(inputFileArgument), + inputFile, target, - result.GetValue(documentationUriOption), + documentationUri, result.GetValue(noRestoreOption), result.GetValue(forceOption), result.GetValue(withSourceOption)); @@ -651,28 +729,28 @@ private Command CreatePublishCommand() private Command CreatePublishExtensionCommand() { - var command = new Command(Constants.Command.PublishExtension, "Publishes a Bicep extension to a registry."); + var command = new Command(Constants.Command.PublishExtension, "[Experimental] Publishes a Bicep extension to a registry."); - var indexFileArgument = new Argument("index-file") + var indexFileArgument = new Argument(Constants.Argument.IndexFile) { Description = "The path to the index file.", Arity = ArgumentArity.ZeroOrOne, }; - var targetOption = new Option("--target") + var targetOption = new Option(Constants.Option.Target) { Description = "The target extension reference.", }; - var forceOption = new Option("--force") + var forceOption = new Option(Constants.Option.Force) { Description = "Force publish even if the extension already exists.", }; // Per-architecture binary options. - var binLinuxX64Option = new Option("--bin-linux-x64") { Description = "Path to the linux-x64 binary." }; - var binLinuxArm64Option = new Option("--bin-linux-arm64") { Description = "Path to the linux-arm64 binary." }; - var binOsxX64Option = new Option("--bin-osx-x64") { Description = "Path to the osx-x64 binary." }; - var binOsxArm64Option = new Option("--bin-osx-arm64") { Description = "Path to the osx-arm64 binary." }; - var binWinX64Option = new Option("--bin-win-x64") { Description = "Path to the win-x64 binary." }; - var binWinArm64Option = new Option("--bin-win-arm64") { Description = "Path to the win-arm64 binary." }; + var binLinuxX64Option = new Option(Constants.Option.BinLinuxX64) { Description = "Path to the linux-x64 binary." }; + var binLinuxArm64Option = new Option(Constants.Option.BinLinuxArm64) { Description = "Path to the linux-arm64 binary." }; + var binOsxX64Option = new Option(Constants.Option.BinOsxX64) { Description = "Path to the osx-x64 binary." }; + var binOsxArm64Option = new Option(Constants.Option.BinOsxArm64) { Description = "Path to the osx-arm64 binary." }; + var binWinX64Option = new Option(Constants.Option.BinWinX64) { Description = "Path to the win-x64 binary." }; + var binWinArm64Option = new Option(Constants.Option.BinWinArm64) { Description = "Path to the win-arm64 binary." }; command.Add(indexFileArgument); command.Add(targetOption); @@ -683,6 +761,7 @@ private Command CreatePublishExtensionCommand() command.Add(binOsxArm64Option); command.Add(binWinX64Option); command.Add(binWinArm64Option); + command.Validators.Add(result => ValidatePositionalArgument(result, indexFileArgument)); command.SetAction((result, ct) => RunCommandAsync(async () => { @@ -708,18 +787,18 @@ private Command CreatePublishExtensionCommand() private Command CreateRestoreCommand() { - var command = new Command(Constants.Command.Restore, "Restores external modules for a .bicep file."); + var command = new Command(Constants.Command.Restore, "Restores external modules from the specified Bicep file to the local module cache."); - var inputFileArgument = new Argument("input-file") + var inputFileArgument = new Argument(Constants.Argument.InputFile) { Description = "The path to the .bicep file.", Arity = ArgumentArity.ZeroOrOne, }; - var filePatternOption = new Option("--pattern") + var filePatternOption = new Option(Constants.Option.Pattern) { - Description = "Restore all files matching the specified pattern.", + Description = "Restores all files matching the specified glob pattern.", }; - var forceOption = new Option("--force") + var forceOption = new Option(Constants.Option.Force) { Description = "Force restore even if modules are already cached.", }; @@ -727,6 +806,7 @@ private Command CreateRestoreCommand() command.Add(inputFileArgument); command.Add(filePatternOption); command.Add(forceOption); + command.Validators.Add(result => ValidatePositionalArgument(result, inputFileArgument)); command.SetAction((result, ct) => RunCommandAsync(async () => { @@ -745,28 +825,29 @@ private Command CreateLintCommand() { var command = new Command(Constants.Command.Lint, "Lints a .bicep file."); - var inputFileArgument = new Argument("input-file") + var inputFileArgument = new Argument(Constants.Argument.InputFile) { Description = "The path to the .bicep file.", Arity = ArgumentArity.ZeroOrOne, }; - var filePatternOption = new Option("--pattern") + var filePatternOption = new Option(Constants.Option.Pattern) { - Description = "Lint all files matching the specified pattern.", + Description = "Lints all files matching the specified glob pattern.", }; - var noRestoreOption = new Option("--no-restore") + var noRestoreOption = new Option(Constants.Option.NoRestore) { - Description = "Do not restore modules prior to linting.", + Description = "Skips restoring external modules.", }; - var diagnosticsFormatOption = new Option("--diagnostics-format") + var diagnosticsFormatOption = new Option(Constants.Option.DiagnosticsFormat) { - Description = "Set the format of diagnostics (Default, SARIF).", + Description = "Sets the diagnostics format. Valid values are (Default, SARIF).", }; command.Add(inputFileArgument); command.Add(filePatternOption); command.Add(noRestoreOption); command.Add(diagnosticsFormatOption); + command.Validators.Add(result => ValidatePositionalArgument(result, inputFileArgument)); command.SetAction((result, ct) => RunCommandAsync(async () => { @@ -785,19 +866,19 @@ private Command CreateLintCommand() private Command CreateJsonRpcCommand() { - var command = new Command(Constants.Command.JsonRpc, "Starts the Bicep JSON-RPC server."); + var command = new Command(Constants.Command.JsonRpc, "Starts the Bicep CLI listening for JSONRPC messages, for programatically interacting with Bicep."); - var pipeOption = new Option("--pipe") + var pipeOption = new Option(Constants.Option.Pipe) { - Description = "Connect via a named pipe with the given name.", + Description = "Bicep CLI will connect to the supplied named pipe as a client, and start listening for JSONRPC requests.", }; - var socketOption = new Option("--socket") + var socketOption = new Option(Constants.Option.Socket) { - Description = "Connect via a TCP socket on the specified port.", + Description = "Bicep CLI will connect to the supplied TCP port on the loopback interface as a client, and start listening for JSONRPC requests.", }; - var stdioOption = new Option("--stdio") + var stdioOption = new Option(Constants.Option.Stdio) { - Description = "Use standard input/output for communication (default when no transport is specified).", + Description = "Bicep CLI will use stdin/stdout for JSONRPC requests.", }; command.Add(pipeOption); @@ -831,33 +912,34 @@ private Command CreateJsonRpcCommand() private Command CreateDeployCommand() { - var command = new Command(Constants.Command.Deploy, "Deploys infrastructure using a .bicepparam file.") + var command = new Command(Constants.Command.Deploy, "[Experimental] Deploys infrastructure using a .bicepparam file.") { TreatUnmatchedTokensAsErrors = false, }; - var parametersFileArgument = new Argument("parameters-file") + var inputFileArgument = new Argument(Constants.Argument.ParametersFile) { Description = "The path to the .bicepparam file.", }; - var noRestoreOption = new Option("--no-restore") + var noRestoreOption = new Option(Constants.Option.NoRestore) { Description = "Do not restore modules prior to deploying.", }; - var formatOption = new Option("--format") + var formatOption = new Option(Constants.Option.Format) { Description = "Output format for deployment results (Default, Json).", }; - command.Add(parametersFileArgument); + command.Add(inputFileArgument); command.Add(noRestoreOption); command.Add(formatOption); + command.Validators.Add(result => ValidateRequiredPositionalArgument(result, inputFileArgument)); command.SetAction((result, ct) => RunCommandAsync(async () => { var additionalArguments = ParseAdditionalArguments(result.UnmatchedTokens); var args = new DeployArguments( - result.GetRequiredValue(parametersFileArgument), + result.GetRequiredValue(inputFileArgument), result.GetValue(noRestoreOption), additionalArguments, result.GetValue(formatOption)); @@ -870,28 +952,29 @@ private Command CreateDeployCommand() private Command CreateWhatIfCommand() { - var command = new Command(Constants.Command.WhatIf, "Previews the changes a deployment would make.") + var command = new Command(Constants.Command.WhatIf, "[Experimental] Previews the changes a deployment would make.") { TreatUnmatchedTokensAsErrors = false, }; - var parametersFileArgument = new Argument("parameters-file") + var inputFileArgument = new Argument(Constants.Argument.ParametersFile) { Description = "The path to the .bicepparam file.", }; - var noRestoreOption = new Option("--no-restore") + var noRestoreOption = new Option(Constants.Option.NoRestore) { Description = "Do not restore modules prior to running what-if.", }; - command.Add(parametersFileArgument); + command.Add(inputFileArgument); command.Add(noRestoreOption); + command.Validators.Add(result => ValidateRequiredPositionalArgument(result, inputFileArgument)); command.SetAction((result, ct) => RunCommandAsync(async () => { var additionalArguments = ParseAdditionalArguments(result.UnmatchedTokens); var args = new WhatIfArguments( - result.GetRequiredValue(parametersFileArgument), + result.GetRequiredValue(inputFileArgument), result.GetValue(noRestoreOption), additionalArguments); @@ -903,28 +986,29 @@ private Command CreateWhatIfCommand() private Command CreateTeardownCommand() { - var command = new Command(Constants.Command.Teardown, "Tears down resources deployed by a .bicepparam file.") + var command = new Command(Constants.Command.Teardown, "[Experimental] Tears down resources deployed by a .bicepparam file.") { TreatUnmatchedTokensAsErrors = false, }; - var parametersFileArgument = new Argument("parameters-file") + var inputFileArgument = new Argument(Constants.Argument.ParametersFile) { Description = "The path to the .bicepparam file.", }; - var noRestoreOption = new Option("--no-restore") + var noRestoreOption = new Option(Constants.Option.NoRestore) { Description = "Do not restore modules prior to tearing down.", }; - command.Add(parametersFileArgument); + command.Add(inputFileArgument); command.Add(noRestoreOption); + command.Validators.Add(result => ValidateRequiredPositionalArgument(result, inputFileArgument)); command.SetAction((result, ct) => RunCommandAsync(async () => { var additionalArguments = ParseAdditionalArguments(result.UnmatchedTokens); var args = new TeardownArguments( - result.GetRequiredValue(parametersFileArgument), + result.GetRequiredValue(inputFileArgument), result.GetValue(noRestoreOption), additionalArguments); @@ -936,29 +1020,30 @@ private Command CreateTeardownCommand() private Command CreateLocalDeployCommand() { - var command = new Command(Constants.Command.LocalDeploy, "Performs a local deployment."); + var command = new Command(Constants.Command.LocalDeploy, "[Experimental] Performs a local deployment."); - var paramsFileArgument = new Argument("parameters-file") + var inputFileArgument = new Argument(Constants.Argument.ParametersFile) { Description = "The path to the .bicepparam file.", }; - var noRestoreOption = new Option("--no-restore") + var noRestoreOption = new Option(Constants.Option.NoRestore) { Description = "Do not restore modules prior to deploying.", }; - var formatOption = new Option("--format") + var formatOption = new Option(Constants.Option.Format) { Description = "Output format for deployment results (Default, Json).", }; - command.Add(paramsFileArgument); + command.Add(inputFileArgument); command.Add(noRestoreOption); command.Add(formatOption); + command.Validators.Add(result => ValidateRequiredPositionalArgument(result, inputFileArgument)); command.SetAction((result, ct) => RunCommandAsync(async () => { var args = new LocalDeployArguments( - result.GetRequiredValue(paramsFileArgument), + result.GetRequiredValue(inputFileArgument), result.GetValue(noRestoreOption), result.GetValue(formatOption)); @@ -970,35 +1055,35 @@ private Command CreateLocalDeployCommand() private Command CreateSnapshotCommand() { - var command = new Command(Constants.Command.Snapshot, "Creates an extension snapshot."); + var command = new Command(Constants.Command.Snapshot, "Generates or validates a deployment snapshot from a .bicepparam file."); - var inputFileArgument = new Argument("input-file") + var inputFileArgument = new Argument(Constants.Argument.ParametersFile) { Description = "The path to the .bicepparam file.", }; - var modeOption = new Option("--mode") + var modeOption = new Option(Constants.Option.Mode) { - Description = "Snapshot mode (Overwrite, Validate).", + Description = "Sets the snapshot mode. Valid values are (overwrite, validate).", }; - var tenantIdOption = new Option("--tenant-id") + var tenantIdOption = new Option(Constants.Option.TenantId) { - Description = "The tenant ID.", + Description = "The tenant ID to use for the deployment.", }; - var subscriptionIdOption = new Option("--subscription-id") + var subscriptionIdOption = new Option(Constants.Option.SubscriptionId) { - Description = "The subscription ID.", + Description = "The subscription ID to use for the deployment.", }; - var locationOption = new Option("--location") + var locationOption = new Option(Constants.Option.Location) { - Description = "The location.", + Description = "The location to use for the deployment.", }; - var resourceGroupOption = new Option("--resource-group") + var resourceGroupOption = new Option(Constants.Option.ResourceGroup) { - Description = "The resource group.", + Description = "The resource group name to use for the deployment.", }; - var deploymentNameOption = new Option("--deployment-name") + var deploymentNameOption = new Option(Constants.Option.DeploymentName) { - Description = "The deployment name.", + Description = "The deployment name to use.", }; command.Add(inputFileArgument); @@ -1008,6 +1093,7 @@ private Command CreateSnapshotCommand() command.Add(locationOption); command.Add(resourceGroupOption); command.Add(deploymentNameOption); + command.Validators.Add(result => ValidateRequiredPositionalArgument(result, inputFileArgument)); command.SetAction((result, ct) => RunCommandAsync(async () => { @@ -1035,6 +1121,22 @@ private Command CreateConsoleCommand() return command; } + + private static void ValidatePositionalArgument(CommandResult result, Argument argument) + { + if (result.GetValue(argument) is {} inputValue && inputValue.StartsWith("--", StringComparison.Ordinal)) + { + result.AddError($"Unrecognized parameter \"{inputValue}\""); + } + } + + private static void ValidateRequiredPositionalArgument(CommandResult result, Argument argument) + { + if (result.GetRequiredValue(argument) is {} inputValue && inputValue.StartsWith("--", StringComparison.Ordinal)) + { + result.AddError($"Unrecognized parameter \"{inputValue}\""); + } + } private async Task RunCommandAsync(Func> action) { diff --git a/src/Bicep.RegistryModuleTool.IntegrationTests/Commands/GenerateCommandTests.cs b/src/Bicep.RegistryModuleTool.IntegrationTests/Commands/GenerateCommandTests.cs index cd846398faf..d916166f1c1 100644 --- a/src/Bicep.RegistryModuleTool.IntegrationTests/Commands/GenerateCommandTests.cs +++ b/src/Bicep.RegistryModuleTool.IntegrationTests/Commands/GenerateCommandTests.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions; using System.IO.Abstractions.TestingHelpers; using Bicep.RegistryModuleTool.Commands; +using Bicep.RegistryModuleTool.Extensions; using Bicep.RegistryModuleTool.ModuleFiles; using Bicep.RegistryModuleTool.TestFixtures.Assertions; using Bicep.RegistryModuleTool.TestFixtures.Extensions; @@ -25,7 +26,7 @@ public async Task InvokeAsync_OnSuccess_ReturnsZero(MockFileSystem fileSystemBef { var sut = CreateGenerateCommand(fileSystemBeforeGeneration); - var exitCode = await sut.InvokeAsync(""); + var exitCode = await sut.Parse("").InvokeAsync(); exitCode.Should().Be(0); } @@ -36,7 +37,7 @@ public async Task InvokeAsync_OnSuccess_ProducesExpectedFiles(MockFileSystem fil { var sut = CreateGenerateCommand(fileSystemBeforeGeneration); - await sut.InvokeAsync(""); + await sut.Parse("").InvokeAsync(); fileSystemBeforeGeneration.Should().HaveSameFilesAs(fileSystemAfterGeneration); } @@ -49,7 +50,7 @@ public async Task InvokeAsync_RepeatOnSuccess_ProducesSameFiles(MockFileSystem f for (int i = 0; i < 2; i++) { - await sut.InvokeAsync(""); + await sut.Parse("").InvokeAsync(); fileSystemBeforeGeneration.Should().HaveSameFilesAs(fileSystemAfterGeneration); } @@ -63,7 +64,7 @@ public async Task InvokeAsync_BicepBuildError_ReturnsOne() fileSystem.File.WriteAllText(MainBicepFile.FileName, "something"); - var exitCode = await sut.InvokeAsync(""); + var exitCode = await sut.Parse("").InvokeAsync(); exitCode.Should().Be(1); } @@ -72,15 +73,15 @@ public async Task InvokeAsync_BicepBuildError_ReturnsOne() public async Task InvokeAsync_BicepBuildError_PrintDiagnostics() { var fileSystem = MockFileSystemFactory.CreateForSample(Sample.Valid); - var sut = CreateGenerateCommand(fileSystem); var mainBicepFilePath = fileSystem.Path.GetFullPath(MainBicepFile.FileName); var console = new MockConsole().ExpectErrorLines( @$"{mainBicepFilePath}(1,1) : Error BCP007: This declaration type is not recognized. Specify a metadata, parameter, variable, resource, or output declaration. [https://aka.ms/bicep/core-diagnostics#BCP007]", @$"Failed to build ""{mainBicepFilePath}""."); + var sut = CreateGenerateCommand(fileSystem, console); fileSystem.File.WriteAllText(MainBicepFile.FileName, "something"); - await sut.InvokeAsync("", console); + await sut.Parse("").InvokeAsync(); console.Verify(); } @@ -98,7 +99,7 @@ static object[] CreateTestCase(Sample before, Sample after) => ]; } - private static GenerateCommand CreateGenerateCommand(IFileSystem fileSystem) + private static GenerateCommand CreateGenerateCommand(IFileSystem fileSystem, IConsole? console = null) { var serviceCollection = new ServiceCollection() .AddBicepCompilerWithFileSystem(fileSystem) @@ -107,11 +108,13 @@ private static GenerateCommand CreateGenerateCommand(IFileSystem fileSystem) var serviceProvider = serviceCollection.BuildServiceProvider(); var handler = serviceProvider.GetRequiredService(); + var effectiveConsole = console ?? new MockConsole(); - return new GenerateCommand() - { - Handler = handler, - }; + var command = new GenerateCommand(); + command.SetAction(async (ParseResult _, CancellationToken ct) => + await handler.InvokeAsync(effectiveConsole, ct)); + + return command; } } } diff --git a/src/Bicep.RegistryModuleTool.IntegrationTests/Commands/ValidateCommandTests.cs b/src/Bicep.RegistryModuleTool.IntegrationTests/Commands/ValidateCommandTests.cs index 182856254f3..54feaa810f5 100644 --- a/src/Bicep.RegistryModuleTool.IntegrationTests/Commands/ValidateCommandTests.cs +++ b/src/Bicep.RegistryModuleTool.IntegrationTests/Commands/ValidateCommandTests.cs @@ -3,7 +3,9 @@ using System.CommandLine; using System.IO.Abstractions; +using Bicep.Core.Parsing; using Bicep.RegistryModuleTool.Commands; +using Bicep.RegistryModuleTool.Extensions; using Bicep.RegistryModuleTool.ModuleFiles; using Bicep.RegistryModuleTool.TestFixtures.Extensions; using Bicep.RegistryModuleTool.TestFixtures.MockFactories; @@ -23,7 +25,7 @@ public async Task InvokeAsync_ValidFiles_ReturnsZero() var fileSystem = MockFileSystemFactory.CreateForSample(Sample.Valid); var sut = CreateValidateCommand(fileSystem); - var exitCode = await sut.InvokeAsync(""); + var exitCode = await sut.Parse("").InvokeAsync(); exitCode.Should().Be(0); } @@ -34,7 +36,7 @@ public async Task InvokeAsync_InvalidFiles_ReturnsOne() var fileSystem = MockFileSystemFactory.CreateForSample(Sample.Invalid); var sut = CreateValidateCommand(fileSystem); - var exitCode = await sut.InvokeAsync(""); + var exitCode = await sut.Parse("").InvokeAsync(); exitCode.Should().Be(1); } @@ -43,9 +45,8 @@ public async Task InvokeAsync_InvalidFiles_ReturnsOne() public async Task InvokeAsync_InvalidFiles_WritesErrorsToConsole() { var fileSystem = MockFileSystemFactory.CreateForSample(Sample.Invalid); - var sut = CreateValidateCommand(fileSystem); var testFile = MainBicepTestFile.Open(fileSystem); - var console = new MockConsole().ExpectErrorLines( + var console = new MockConsole().ExpectErrorLines(StringUtils.SplitOnNewLine( $""" The file "{fileSystem.Path.GetFullPath(MainBicepFile.FileName)}" is invalid: - A description must be specified for parameter "dnsPrefix". @@ -53,30 +54,24 @@ public async Task InvokeAsync_InvalidFiles_WritesErrorsToConsole() - A description must be specified for output "controlPlaneFQDN". - Metadata "description" must contain at least 10 characters. - """, - $""" The file "{testFile.Path}" is invalid: - Could not find tests in the file. Please make sure to add at least one module referencing the main Bicep file. - """, - $""" The file "{fileSystem.Path.GetFullPath(MainArmTemplateFile.FileName)}" is invalid: - The file is modified or outdated. Please run "brm generate" to regenerate it. - """, - $""" The file "{fileSystem.Path.GetFullPath(ReadmeFile.FileName)}" is invalid: - The file is modified or outdated. Please run "brm generate" to regenerate it. - """, - $""" The file "{fileSystem.Path.GetFullPath(VersionFile.FileName)}" is invalid: - #: Required properties ["$schema","version"] are not present. - The file is modified or outdated. Please run "brm generate" to regenerate it. - """); + """)); + + var sut = CreateValidateCommand(fileSystem, console); - await sut.InvokeAsync("", console); + await sut.Parse("").InvokeAsync(); console.Verify(); } @@ -85,12 +80,10 @@ public async Task InvokeAsync_InvalidFiles_WritesErrorsToConsole() public async Task InvokeAsync_BicepBuildError_ReturnsOne() { var fileSystem = MockFileSystemFactory.CreateForSample(Sample.Valid); - var sut = CreateValidateCommand(fileSystem); - var console = new MockConsole(); - fileSystem.File.WriteAllText(MainBicepFile.FileName, "something"); + var sut = CreateValidateCommand(fileSystem); - var exitCode = await sut.InvokeAsync("", console); + var exitCode = await sut.Parse("").InvokeAsync(); exitCode.Should().Be(1); } @@ -99,15 +92,14 @@ public async Task InvokeAsync_BicepBuildError_ReturnsOne() public async Task InvokeAsync_BicepBuildError_PrintDiagnostics() { var fileSystem = MockFileSystemFactory.CreateForSample(Sample.Valid); - var sut = CreateValidateCommand(fileSystem); var mainBicepFilePath = fileSystem.Path.GetFullPath(MainBicepFile.FileName); var console = new MockConsole().ExpectErrorLines( @$"{mainBicepFilePath}(1,1) : Error BCP007: This declaration type is not recognized. Specify a metadata, parameter, variable, resource, or output declaration. [https://aka.ms/bicep/core-diagnostics#BCP007]", @$"Failed to build ""{mainBicepFilePath}""."); - fileSystem.File.WriteAllText(MainBicepFile.FileName, "something"); + var sut = CreateValidateCommand(fileSystem, console); - await sut.InvokeAsync("", console); + await sut.Parse("").InvokeAsync(); console.Verify(); } @@ -116,13 +108,11 @@ public async Task InvokeAsync_BicepBuildError_PrintDiagnostics() public async Task InvokeAsync_BicepTestBuildError_ReturnsOne() { var fileSystem = MockFileSystemFactory.CreateForSample(Sample.Valid); - var sut = CreateValidateCommand(fileSystem); - var console = new MockConsole(); var bicepTestFile = MainBicepTestFile.Open(fileSystem); - fileSystem.File.WriteAllText(bicepTestFile.Path, "something"); + var sut = CreateValidateCommand(fileSystem); - var exitCode = await sut.InvokeAsync("", console); + var exitCode = await sut.Parse("").InvokeAsync(); exitCode.Should().Be(1); } @@ -131,21 +121,19 @@ public async Task InvokeAsync_BicepTestBuildError_ReturnsOne() public async Task InvokeAsync_BicepTestBuildError_PrintDiagnostics() { var fileSystem = MockFileSystemFactory.CreateForSample(Sample.Valid); - var sut = CreateValidateCommand(fileSystem); var bicepTestFile = MainBicepTestFile.Open(fileSystem); - fileSystem.File.WriteAllText(bicepTestFile.Path, "something"); - var console = new MockConsole().ExpectErrorLines( @$"{bicepTestFile.Path}(1,1) : Error BCP007: This declaration type is not recognized. Specify a metadata, parameter, variable, resource, or output declaration. [https://aka.ms/bicep/core-diagnostics#BCP007]", @$"Failed to build ""{bicepTestFile.Path}""."); + var sut = CreateValidateCommand(fileSystem, console); - await sut.InvokeAsync("", console); + await sut.Parse("").InvokeAsync(); console.Verify(); } - private static ValidateCommand CreateValidateCommand(IFileSystem fileSystem) + private static ValidateCommand CreateValidateCommand(IFileSystem fileSystem, IConsole? console = null) { var serviceCollection = new ServiceCollection() .AddBicepCompilerWithFileSystem(fileSystem) @@ -154,11 +142,13 @@ private static ValidateCommand CreateValidateCommand(IFileSystem fileSystem) var serviceProvider = serviceCollection.BuildServiceProvider(); var handler = serviceProvider.GetRequiredService(); + var effectiveConsole = console ?? new MockConsole(); + + var command = new ValidateCommand(); + command.SetAction(async (ParseResult _, CancellationToken ct) => + await handler.InvokeAsync(effectiveConsole, ct)); - return new ValidateCommand() - { - Handler = handler, - }; + return command; } } } diff --git a/src/Bicep.RegistryModuleTool.TestFixtures/Mocks/MockConsole.cs b/src/Bicep.RegistryModuleTool.TestFixtures/Mocks/MockConsole.cs index 7084a63be1f..7c94253b915 100644 --- a/src/Bicep.RegistryModuleTool.TestFixtures/Mocks/MockConsole.cs +++ b/src/Bicep.RegistryModuleTool.TestFixtures/Mocks/MockConsole.cs @@ -1,26 +1,24 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.CommandLine; -using System.CommandLine.IO; +using Bicep.RegistryModuleTool.Extensions; using FluentAssertions; -using Moq; namespace Bicep.RegistryModuleTool.TestFixtures.Mocks { public class MockConsole : IConsole { - private readonly Mock outMock = StrictMock.Of(); + private readonly StringWriter outWriter = new(); - private readonly Mock errorMock = StrictMock.Of(); + private readonly StringWriter errorWriter = new(); private readonly List expectedOutLines = new(); private readonly List expectedErrorLines = new(); - private readonly List actualOutLines = new(); + public TextWriter Out => this.outWriter; - private readonly List actualErrorLines = new(); + public TextWriter Error => this.errorWriter; public MockConsole ExpectOutLines(params string[] expectedOutLines) => this.ExpectOutLines((IEnumerable)expectedOutLines); @@ -36,12 +34,7 @@ public MockConsole ExpectOutLines(IEnumerable expectedOutLines) public MockConsole ExpectOutLine(string expectedOutLine) { - this.expectedOutLines.Add(expectedOutLine + Environment.NewLine); - - this.outMock.Setup(x => x.Write(It.IsAny())).Callback(actualOutLine => - { - this.actualOutLines.Add(actualOutLine); - }); + this.expectedOutLines.Add(expectedOutLine); return this; } @@ -60,27 +53,31 @@ public MockConsole ExpectErrorLines(IEnumerable expectedErrorLines) public MockConsole ExpectErrorLine(string expectedErrorLine) { - this.expectedErrorLines.Add($"{expectedErrorLine.ReplaceLineEndings()}{Environment.NewLine}"); - - this.errorMock.Setup(x => x.Write(It.IsAny())).Callback(this.actualErrorLines.Add); + this.expectedErrorLines.Add(expectedErrorLine.ReplaceLineEndings()); return this; } - public IStandardStreamWriter Out => this.outMock.Object; - - public IStandardStreamWriter Error => this.errorMock.Object; - - public bool IsOutputRedirected => false; + public void Verify() + { + var actualOutLines = GetLines(this.outWriter); + var actualErrorLines = GetLines(this.errorWriter); + VerifyStringList(actualOutLines, this.expectedOutLines); + VerifyStringList(actualErrorLines, this.expectedErrorLines); + } - public bool IsErrorRedirected => false; + private static List GetLines(StringWriter writer) + { + var content = writer.ToString().ReplaceLineEndings(Environment.NewLine); + var lines = content.Split(Environment.NewLine).ToList(); - public bool IsInputRedirected => false; + // Remove trailing empty entry produced by a final newline + if (lines.Count > 0 && lines[^1] == string.Empty) + { + lines.RemoveAt(lines.Count - 1); + } - public void Verify() - { - VerifyStringList(this.actualOutLines, this.expectedOutLines); - VerifyStringList(this.actualErrorLines, this.expectedErrorLines); + return lines; } private static void VerifyStringList(List a, List b) @@ -99,3 +96,4 @@ private static void VerifyStringList(List a, List b) } } } + diff --git a/src/Bicep.RegistryModuleTool.UnitTests/ModuleFiles/BaseCommandHandlerTests.cs b/src/Bicep.RegistryModuleTool.UnitTests/ModuleFiles/BaseCommandHandlerTests.cs index 031eaad7321..6000161dfa6 100644 --- a/src/Bicep.RegistryModuleTool.UnitTests/ModuleFiles/BaseCommandHandlerTests.cs +++ b/src/Bicep.RegistryModuleTool.UnitTests/ModuleFiles/BaseCommandHandlerTests.cs @@ -1,10 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.CommandLine; -using System.CommandLine.Invocation; using Bicep.Core.Exceptions; using Bicep.RegistryModuleTool.Commands; +using Bicep.RegistryModuleTool.Extensions; using Bicep.RegistryModuleTool.TestFixtures.MockFactories; using FluentAssertions; using Microsoft.Extensions.Logging; @@ -65,14 +64,10 @@ public async Task InvokeAsync_CaughtUnexpectedException_LogsCritical(Exception e It.IsAny>())); } - private static Task InvokeAsync(ICommandHandler handler) + private static Task InvokeAsync(BaseCommandHandler handler) { - var command = new TestCommand() - { - Handler = handler, - }; - - return command.InvokeAsync(""); + var console = new StringWriterConsole(); + return handler.InvokeAsync(console, CancellationToken.None); } private static IEnumerable GetExceptionData() => GetExpectedExceptionData().Concat(GetUnexpectedExceptionData()); @@ -113,12 +108,10 @@ private static IEnumerable GetUnexpectedExceptionData() }; } - private class TestCommand : Command + private sealed class StringWriterConsole : IConsole { - public TestCommand() - : base("test") - { - } + public TextWriter Out { get; } = new StringWriter(); + public TextWriter Error { get; } = new StringWriter(); } private class PassThroughCommandHandler : BaseCommandHandler @@ -131,7 +124,7 @@ public PassThroughCommandHandler(int exitCode) this.exitCode = exitCode; } - protected override Task InvokeInternalAsync(InvocationContext context) => Task.FromResult(this.exitCode); + protected override Task InvokeInternalAsync(IConsole console, CancellationToken cancellationToken) => Task.FromResult(this.exitCode); } private class ThrowExceptionCommandHandler : BaseCommandHandler @@ -144,10 +137,11 @@ public ThrowExceptionCommandHandler(Exception exceptionToThrow, ILogger? logger this.exceptionToThrow = exceptionToThrow; } - protected override Task InvokeInternalAsync(InvocationContext context) + protected override Task InvokeInternalAsync(IConsole console, CancellationToken cancellationToken) { throw exceptionToThrow; } } } } + From cc5b4c77b62ccb879a2f9918da6442706b70fb16 Mon Sep 17 00:00:00 2001 From: Anthony Martin <38542602+anthony-c-martin@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:45:51 -0400 Subject: [PATCH 8/8] Fix merge: add ManagementGroupId to snapshot command, fix help validator, restore package version --- src/Bicep.Cli/Bicep.Cli.csproj | 1 - src/Bicep.Cli/Program.cs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Bicep.Cli/Bicep.Cli.csproj b/src/Bicep.Cli/Bicep.Cli.csproj index 237221b0a40..61b7355cbb9 100644 --- a/src/Bicep.Cli/Bicep.Cli.csproj +++ b/src/Bicep.Cli/Bicep.Cli.csproj @@ -27,7 +27,6 @@ - diff --git a/src/Bicep.Cli/Program.cs b/src/Bicep.Cli/Program.cs index 9103ceee20b..e61627eb306 100644 --- a/src/Bicep.Cli/Program.cs +++ b/src/Bicep.Cli/Program.cs @@ -1138,7 +1138,7 @@ private static void ValidatePositionalArgument(CommandResult result, Argument argument) { - if (result.GetRequiredValue(argument) is {} inputValue && inputValue.StartsWith("--", StringComparison.Ordinal)) + if (result.GetValue(argument) is {} inputValue && inputValue.StartsWith("--", StringComparison.Ordinal)) { result.AddError($"Unrecognized parameter \"{inputValue}\""); }