diff --git a/src/System.CommandLine.ApiCompatibility.Tests/ApiCompatibilityApprovalTests.System_CommandLine_api_is_not_changed.approved.txt b/src/System.CommandLine.ApiCompatibility.Tests/ApiCompatibilityApprovalTests.System_CommandLine_api_is_not_changed.approved.txt index 12c06b5db2..eba7ca8ad9 100644 --- a/src/System.CommandLine.ApiCompatibility.Tests/ApiCompatibilityApprovalTests.System_CommandLine_api_is_not_changed.approved.txt +++ b/src/System.CommandLine.ApiCompatibility.Tests/ApiCompatibilityApprovalTests.System_CommandLine_api_is_not_changed.approved.txt @@ -17,8 +17,10 @@ System.CommandLine public static CliArgument AcceptExistingOnly(this CliArgument argument) public static CliArgument AcceptExistingOnly(this CliArgument argument) public abstract class CliAction + public System.Boolean Exclusive { get; } public System.Int32 Invoke(ParseResult parseResult) public System.Threading.Tasks.Task InvokeAsync(ParseResult parseResult, System.Threading.CancellationToken cancellationToken = null) + protected System.Void set_Exclusive(System.Boolean value) public abstract class CliArgument : CliSymbol public ArgumentArity Arity { get; set; } public System.Collections.Generic.List>> CompletionSources { get; } @@ -76,6 +78,8 @@ System.CommandLine public ParseResult Parse(System.Collections.Generic.IReadOnlyList args) public ParseResult Parse(System.String commandLine) public System.Void ThrowIfInvalid() + public class CliConfigurationException : System.Exception, System.Runtime.Serialization.ISerializable + .ctor(System.String message) public class CliDirective : CliSymbol .ctor(System.String name) public CliAction Action { get; set; } @@ -111,8 +115,6 @@ System.CommandLine public System.Collections.Generic.IEnumerable Parents { get; } public System.Collections.Generic.IEnumerable GetCompletions(System.CommandLine.Completions.CompletionContext context) public System.String ToString() - public class CommandLineConfigurationException : System.Exception, System.Runtime.Serialization.ISerializable - .ctor(System.String message) public static class CompletionSourceExtensions public static System.Void Add(this System.Collections.Generic.List>> completionSources, System.Func> completionsDelegate) public static System.Void Add(this System.Collections.Generic.List>> completionSources, System.String[] completions) diff --git a/src/System.CommandLine.Tests/CommandLineConfigurationTests.cs b/src/System.CommandLine.Tests/CommandLineConfigurationTests.cs index d6fae8df50..f1223a892a 100644 --- a/src/System.CommandLine.Tests/CommandLineConfigurationTests.cs +++ b/src/System.CommandLine.Tests/CommandLineConfigurationTests.cs @@ -6,7 +6,7 @@ namespace System.CommandLine.Tests; -public class CommandLineConfigurationTests +public class CliConfigurationTests { [Fact] public void ThrowIfInvalid_throws_if_there_are_duplicate_sibling_option_aliases_on_the_root_command() @@ -26,7 +26,7 @@ public void ThrowIfInvalid_throws_if_there_are_duplicate_sibling_option_aliases_ var validate = () => config.ThrowIfInvalid(); validate.Should() - .Throw() + .Throw() .Which .Message .Should() @@ -54,7 +54,7 @@ public void ThrowIfInvalid_throws_if_there_are_duplicate_sibling_option_aliases_ var validate = () => config.ThrowIfInvalid(); validate.Should() - .Throw() + .Throw() .Which .Message .Should() @@ -79,7 +79,7 @@ public void ThrowIfInvalid_throws_if_there_are_duplicate_sibling_subcommand_alia var validate = () => config.ThrowIfInvalid(); validate.Should() - .Throw() + .Throw() .Which .Message .Should() @@ -103,7 +103,7 @@ public void ThrowIfInvalid_throws_if_there_are_duplicate_sibling_subcommand_alia var validate = () => config.ThrowIfInvalid(); validate.Should() - .Throw() + .Throw() .Which .Message .Should() @@ -128,7 +128,7 @@ public void ThrowIfInvalid_throws_if_sibling_command_and_option_aliases_collide_ var validate = () => config.ThrowIfInvalid(); validate.Should() - .Throw() + .Throw() .Which .Message .Should() @@ -156,7 +156,7 @@ public void ThrowIfInvalid_throws_if_sibling_command_and_option_aliases_collide_ var validate = () => config.ThrowIfInvalid(); validate.Should() - .Throw() + .Throw() .Which .Message .Should() @@ -179,7 +179,7 @@ public void ThrowIfInvalid_throws_if_there_are_duplicate_sibling_global_option_a var validate = () => config.ThrowIfInvalid(); validate.Should() - .Throw() + .Throw() .Which .Message .Should() @@ -235,7 +235,7 @@ public void ThrowIfInvalid_throws_if_a_command_is_its_own_parent() var validate = () => config.ThrowIfInvalid(); validate.Should() - .Throw() + .Throw() .Which .Message .Should() @@ -254,7 +254,7 @@ public void ThrowIfInvalid_throws_if_a_parentage_cycle_is_detected() var validate = () => config.ThrowIfInvalid(); validate.Should() - .Throw() + .Throw() .Which .Message .Should() diff --git a/src/System.CommandLine.Tests/DirectiveTests.cs b/src/System.CommandLine.Tests/DirectiveTests.cs index 53cb14d9bc..58398be4a7 100644 --- a/src/System.CommandLine.Tests/DirectiveTests.cs +++ b/src/System.CommandLine.Tests/DirectiveTests.cs @@ -1,9 +1,11 @@ // Copyright (c) .NET Foundation and contributors. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using FluentAssertions; +using FluentAssertions.Execution; using Xunit; namespace System.CommandLine.Tests @@ -31,6 +33,16 @@ public void Raw_tokens_still_hold_directives() result.Tokens.Should().Contain(t => t.Value == "[parse]"); } + [Fact] + public void Directives_must_precede_other_symbols() + { + CliDirective directive = new("parse"); + + ParseResult result = Parse(new CliOption("-y"), directive, "-y [parse]"); + + result.FindResultFor(directive).Should().BeNull(); + } + [Fact] public void Multiple_directives_are_allowed() { @@ -47,14 +59,111 @@ public void Multiple_directives_are_allowed() result.FindResultFor(suggestDirective).Should().NotBeNull(); } - [Fact] - public void Directives_must_be_the_first_argument() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Multiple_instances_of_the_same_directive_can_be_invoked(bool invokeAsync) { - CliDirective directive = new("parse"); + var commandActionWasCalled = false; + var directiveCallCount = 0; + + var testDirective = new TestDirective("test") + { + Action = new NonexclusiveTestAction(_ => directiveCallCount++) + }; + + var config = new CliConfiguration(new CliRootCommand + { + Action = new NonexclusiveTestAction(_ => commandActionWasCalled = true) + }) + { + Directives = { testDirective } + }; + + if (invokeAsync) + { + await config.InvokeAsync("[test:1] [test:2]"); + } + else + { + config.Invoke("[test:1] [test:2]"); + } + + using var _ = new AssertionScope(); + + commandActionWasCalled.Should().BeTrue(); + directiveCallCount.Should().Be(2); + } - ParseResult result = Parse(new CliOption("-y"), directive, "-y [parse]"); + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Multiple_different_directives_can_be_invoked(bool invokeAsync) + { + bool commandActionWasCalled = false; + bool directiveOneActionWasCalled = false; + bool directiveTwoActionWasCalled = false; + + var directiveOne = new TestDirective("one") + { + Action = new NonexclusiveTestAction(_ => directiveOneActionWasCalled = true) + }; + var directiveTwo = new TestDirective("two") + { + Action = new NonexclusiveTestAction(_ => directiveTwoActionWasCalled = true) + }; + var config = new CliConfiguration(new CliRootCommand + { + Action = new NonexclusiveTestAction(_ => commandActionWasCalled = true) + }) + { + Directives = { directiveOne, directiveTwo } + }; + + if (invokeAsync) + { + await config.InvokeAsync("[one] [two]"); + } + else + { + config.Invoke("[one] [two]"); + } + + using var _ = new AssertionScope(); + + commandActionWasCalled.Should().BeTrue(); + directiveOneActionWasCalled.Should().BeTrue(); + directiveTwoActionWasCalled.Should().BeTrue(); + } - result.FindResultFor(directive).Should().BeNull(); + public class TestDirective : CliDirective + { + public TestDirective(string name) : base(name) + { + } + } + + private class NonexclusiveTestAction : CliAction + { + private readonly Action _invoke; + + public NonexclusiveTestAction(Action invoke) + { + _invoke = invoke; + Exclusive = false; + } + + public override int Invoke(ParseResult parseResult) + { + _invoke(parseResult); + return 0; + } + + public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + { + ; + return Task.FromResult(Invoke(parseResult)); + } } [Theory] diff --git a/src/System.CommandLine.Tests/EnvironmentVariableDirectiveTests.cs b/src/System.CommandLine.Tests/EnvironmentVariableDirectiveTests.cs index 8792e3f69c..cb47806f31 100644 --- a/src/System.CommandLine.Tests/EnvironmentVariableDirectiveTests.cs +++ b/src/System.CommandLine.Tests/EnvironmentVariableDirectiveTests.cs @@ -1,6 +1,7 @@ -using FluentAssertions; -using System.CommandLine.Parsing; +using System.CommandLine.Help; +using FluentAssertions; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Xunit; @@ -9,167 +10,137 @@ namespace System.CommandLine.Tests { public class EnvironmentVariableDirectiveTests { - private static readonly Random randomizer = new(Seed: 456476756); - private readonly string test_variable = $"TEST_ENVIRONMENT_VARIABLE{randomizer.Next()}"; + private static readonly Random _random = new(); + private readonly string _testVariableName = $"TEST_ENVIRONMENT_VARIABLE_{_random.Next()}"; [Fact] public async Task Sets_environment_variable_to_value() { bool asserted = false; - string variable = test_variable; - const string value = "This is a test"; + const string value = "hello"; var rootCommand = new CliRootCommand(); - rootCommand.SetAction((_) => + rootCommand.SetAction(_ => { asserted = true; - Environment.GetEnvironmentVariable(variable).Should().Be(value); + Environment.GetEnvironmentVariable(_testVariableName).Should().Be(value); }); var config = new CliConfiguration(rootCommand) { - Directives = { new EnvironmentVariablesDirective() } + Directives = { new EnvironmentVariablesDirective() }, + EnableDefaultExceptionHandler = false }; - await config.InvokeAsync(new[] { $"[env:{variable}={value}]" }); + await config.InvokeAsync($"[env:{_testVariableName}={value}]"); asserted.Should().BeTrue(); } [Fact] - public async Task Trims_environment_variable_name() + public async Task Sets_environment_variable_value_containing_equals_sign() { bool asserted = false; - string variable = test_variable; - const string value = "This is a test"; + const string value = "1=2"; var rootCommand = new CliRootCommand(); - rootCommand.SetAction((_) => + rootCommand.SetAction(_ => { asserted = true; - Environment.GetEnvironmentVariable(variable).Should().Be(value); + Environment.GetEnvironmentVariable(_testVariableName).Should().Be(value); }); var config = new CliConfiguration(rootCommand) { - Directives = { new EnvironmentVariablesDirective() } + Directives = { new EnvironmentVariablesDirective() }, + EnableDefaultExceptionHandler = false }; - await config.InvokeAsync(new[] { $"[env: {variable} ={value}]" }); + await config.InvokeAsync($"[env:{_testVariableName}={value}]" ); asserted.Should().BeTrue(); } [Fact] - public async Task Trims_environment_variable_value() + public async Task Ignores_environment_directive_without_equals_sign() { bool asserted = false; - string variable = test_variable; - const string value = "This is a test"; + string variable = _testVariableName; var rootCommand = new CliRootCommand(); - rootCommand.SetAction((_) => + rootCommand.SetAction(_ => { asserted = true; - Environment.GetEnvironmentVariable(variable).Should().Be(value); + Environment.GetEnvironmentVariable(variable).Should().BeNull(); }); var config = new CliConfiguration(rootCommand) { - Directives = { new EnvironmentVariablesDirective() } + Directives = { new EnvironmentVariablesDirective() }, + EnableDefaultExceptionHandler = false }; - await config.InvokeAsync(new[] { $"[env:{variable}= {value} ]" }); + await config.InvokeAsync( $"[env:{variable}]" ); asserted.Should().BeTrue(); } [Fact] - public async Task Sets_environment_variable_value_containing_equals_sign() + public static async Task Ignores_environment_directive_with_empty_variable_name() { bool asserted = false; - string variable = test_variable; - const string value = "This is = a test containing equals"; + string value = "value"; var rootCommand = new CliRootCommand(); - rootCommand.SetAction((_) => + rootCommand.SetAction(_ => { asserted = true; - Environment.GetEnvironmentVariable(variable).Should().Be(value); + var env = Environment.GetEnvironmentVariables(); + env.Values.Cast().Should().NotContain(value); }); var config = new CliConfiguration(rootCommand) { - Directives = { new EnvironmentVariablesDirective() } + Directives = { new EnvironmentVariablesDirective() }, + EnableDefaultExceptionHandler = false }; - await config.InvokeAsync(new[] { $"[env:{variable}={value}]" }); + var result = config.Parse($"[env:={value}]"); - asserted.Should().BeTrue(); - } - - [Fact] - public async Task Ignores_environment_directive_without_equals_sign() - { - bool asserted = false; - string variable = test_variable; - var rootCommand = new CliRootCommand(); - rootCommand.SetAction((_) => - { - asserted = true; - Environment.GetEnvironmentVariable(variable).Should().BeNull(); - }); - - var config = new CliConfiguration(rootCommand) - { - Directives = { new EnvironmentVariablesDirective() } - }; - - await config.InvokeAsync(new[] { $"[env:{variable}]" }); + await result.InvokeAsync(); asserted.Should().BeTrue(); } [Fact] - public static async Task Ignores_environment_directive_with_empty_variable_name() + public void It_does_not_prevent_help_from_being_invoked() { - bool asserted = false; - string value = $"This is a test, random: {randomizer.Next()}"; - var rootCommand = new CliRootCommand(); - rootCommand.SetAction((_) => - { - asserted = true; - var env = Environment.GetEnvironmentVariables(); - env.Values.Cast().Should().NotContain(value); - }); + var root = new CliRootCommand(); + root.SetAction(_ => { }); - var config = new CliConfiguration(rootCommand) - { - Directives = { new EnvironmentVariablesDirective() } - }; + var customHelpAction = new CustomHelpAction(); + root.Options.OfType().Single().Action = customHelpAction; - await config.InvokeAsync(new[] { $"[env:={value}]" }); + var config = new CliConfiguration(root); + config.Directives.Add(new EnvironmentVariablesDirective()); - asserted.Should().BeTrue(); + root.Parse($"[env:{_testVariableName}=1] -h", config).Invoke(); + + customHelpAction.WasCalled.Should().BeTrue(); + Environment.GetEnvironmentVariable(_testVariableName).Should().Be("1"); } - [Fact] - public static async Task Ignores_environment_directive_with_whitespace_variable_name() + private class CustomHelpAction : CliAction { - bool asserted = false; - string value = $"This is a test, random: {randomizer.Next()}"; - var rootCommand = new CliRootCommand(); - rootCommand.SetAction((_) => - { - asserted = true; - var env = Environment.GetEnvironmentVariables(); - env.Values.Cast().Should().NotContain(value); - }); + public bool WasCalled { get; private set; } - var config = new CliConfiguration(rootCommand) + public override int Invoke(ParseResult parseResult) { - Directives = { new EnvironmentVariablesDirective() } - }; + WasCalled = true; + return 0; + } - await config.InvokeAsync(new[] { $"[env: ={value}]" }); - - asserted.Should().BeTrue(); + public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + { + WasCalled = true; + return Task.FromResult(0); + } } } } diff --git a/src/System.CommandLine.Tests/Help/HelpBuilderTests.Customization.cs b/src/System.CommandLine.Tests/Help/HelpBuilderTests.Customization.cs index 3518431795..2f809b7e0a 100644 --- a/src/System.CommandLine.Tests/Help/HelpBuilderTests.Customization.cs +++ b/src/System.CommandLine.Tests/Help/HelpBuilderTests.Customization.cs @@ -5,7 +5,10 @@ using System.CommandLine.Help; using System.IO; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using FluentAssertions; +using FluentAssertions.Common; using Xunit; using static System.Environment; diff --git a/src/System.CommandLine.Tests/Invocation/InvocationTests.cs b/src/System.CommandLine.Tests/Invocation/InvocationTests.cs index 5761d25416..b3f07344f9 100644 --- a/src/System.CommandLine.Tests/Invocation/InvocationTests.cs +++ b/src/System.CommandLine.Tests/Invocation/InvocationTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.CommandLine.Help; +using System.CommandLine.Parsing; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -188,14 +189,39 @@ public async Task Anonymous_RootCommand_int_returning_Action_can_set_custom_resu (await rootCommand.Parse("").InvokeAsync()).Should().Be(123); } - - internal sealed class CustomExitCodeAction : CliAction + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Nonexclusive_actions_handle_exceptions_and_return_an_error_return_code(bool invokeAsync) { - public override int Invoke(ParseResult context) - => 123; + var nonexclusiveAction = new NonexclusiveTestAction + { + ThrowOnInvoke = true + }; - public override Task InvokeAsync(ParseResult context, CancellationToken cancellationToken = default) - => Task.FromResult(456); + var command = new CliRootCommand + { + new CliOption("-x") + { + Action = nonexclusiveAction + } + }; + + int returnCode; + + var parseResult = CliParser.Parse(command, "-x"); + + if (invokeAsync) + { + returnCode = await parseResult.InvokeAsync(); + } + else + { + returnCode = parseResult.Invoke(); + } + + returnCode.Should().Be(1); } [Fact] @@ -212,5 +238,48 @@ public async Task Command_InvokeAsync_with_cancelation_token_invokes_command_han cts.Cancel(); await command.Parse("test").InvokeAsync(cancellationToken: cts.Token); } + + private class CustomExitCodeAction : CliAction + { + public override int Invoke(ParseResult context) + => 123; + + public override Task InvokeAsync(ParseResult context, CancellationToken cancellationToken = default) + => Task.FromResult(456); + } + + private class NonexclusiveTestAction : CliAction + { + public NonexclusiveTestAction() + { + Exclusive = false; + } + + public bool ThrowOnInvoke { get; set; } + + public bool HasBeenInvoked { get; private set; } + + public override int Invoke(ParseResult parseResult) + { + HasBeenInvoked = true; + if (ThrowOnInvoke) + { + throw new Exception("oops!"); + } + + return 0; + } + + public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + { + HasBeenInvoked = true; + if (ThrowOnInvoke) + { + throw new Exception("oops!"); + } + + return Task.FromResult(0); + } + } } } diff --git a/src/System.CommandLine.Tests/ParsingValidationTests.cs b/src/System.CommandLine.Tests/ParsingValidationTests.cs index b9723b3d62..7827b9ed16 100644 --- a/src/System.CommandLine.Tests/ParsingValidationTests.cs +++ b/src/System.CommandLine.Tests/ParsingValidationTests.cs @@ -1109,13 +1109,15 @@ private string ExistingFile() [Fact] public void A_command_with_subcommands_is_invalid_to_invoke_if_it_has_no_handler() { - var outer = new CliCommand("outer"); - var inner = new CliCommand("inner"); - var innerer = new CliCommand("inner-er"); - outer.Subcommands.Add(inner); - inner.Subcommands.Add(innerer); + var outer = new CliCommand("outer") + { + new CliCommand("inner") + { + new CliCommand("inner-er") + } + }; - var result = outer.Parse("outer inner arg"); + var result = outer.Parse("outer inner"); result.Errors .Should() @@ -1125,7 +1127,7 @@ public void A_command_with_subcommands_is_invalid_to_invoke_if_it_has_no_handler } [Fact] - public void A_root_command_is_invalid_if_it_has_no_handler() + public void A_root_command_with_subcommands_is_invalid_to_invoke_if_it_has_no_handler() { var rootCommand = new CliRootCommand(); var inner = new CliCommand("inner"); diff --git a/src/System.CommandLine/CliAction.cs b/src/System.CommandLine/CliAction.cs index 33d5004157..c857811287 100644 --- a/src/System.CommandLine/CliAction.cs +++ b/src/System.CommandLine/CliAction.cs @@ -11,6 +11,8 @@ namespace System.CommandLine /// public abstract class CliAction { + public bool Exclusive { get; protected set; } = true; + /// /// Performs an action when the associated symbol is invoked on the command line. /// diff --git a/src/System.CommandLine/CliConfiguration.cs b/src/System.CommandLine/CliConfiguration.cs index d2af5c4337..6a2fc93dfc 100644 --- a/src/System.CommandLine/CliConfiguration.cs +++ b/src/System.CommandLine/CliConfiguration.cs @@ -169,7 +169,7 @@ public Task InvokeAsync(string[] args, CancellationToken cancellationToken /// Throws an exception if the parser configuration is ambiguous or otherwise not valid. /// /// Due to the performance cost of this method, it is recommended to be used in unit testing or in scenarios where the parser is configured dynamically at runtime. - /// Thrown if the configuration is found to be invalid. + /// Thrown if the configuration is found to be invalid. public void ThrowIfInvalid() { ThrowIfInvalid(RootCommand); @@ -178,7 +178,7 @@ static void ThrowIfInvalid(CliCommand command) { if (command.Parents.FlattenBreadthFirst(c => c.Parents).Any(ancestor => ancestor == command)) { - throw new CommandLineConfigurationException($"Cycle detected in command tree. Command '{command.Name}' is its own ancestor."); + throw new CliConfigurationException($"Cycle detected in command tree. Command '{command.Name}' is its own ancestor."); } int count = command.Subcommands.Count + command.Options.Count; @@ -192,11 +192,11 @@ static void ThrowIfInvalid(CliCommand command) if (symbol1.Name.Equals(symbol2.Name, StringComparison.Ordinal) || (aliases1 is not null && aliases1.Contains(symbol2.Name))) { - throw new CommandLineConfigurationException($"Duplicate alias '{symbol2.Name}' found on command '{command.Name}'."); + throw new CliConfigurationException($"Duplicate alias '{symbol2.Name}' found on command '{command.Name}'."); } else if (aliases2 is not null && aliases2.Contains(symbol1.Name)) { - throw new CommandLineConfigurationException($"Duplicate alias '{symbol1.Name}' found on command '{command.Name}'."); + throw new CliConfigurationException($"Duplicate alias '{symbol1.Name}' found on command '{command.Name}'."); } if (aliases1 is not null && aliases2 is not null) @@ -208,7 +208,7 @@ static void ThrowIfInvalid(CliCommand command) { if (aliases1.Contains(symbol2Alias)) { - throw new CommandLineConfigurationException($"Duplicate alias '{symbol2Alias}' found on command '{command.Name}'."); + throw new CliConfigurationException($"Duplicate alias '{symbol2Alias}' found on command '{command.Name}'."); } } } diff --git a/src/System.CommandLine/CommandLineConfigurationException.cs b/src/System.CommandLine/CommandLineConfigurationException.cs index 35a49e88a6..ddaf1c8a49 100644 --- a/src/System.CommandLine/CommandLineConfigurationException.cs +++ b/src/System.CommandLine/CommandLineConfigurationException.cs @@ -6,10 +6,10 @@ namespace System.CommandLine; /// /// Indicates that a command line configuration is invalid. /// -public class CommandLineConfigurationException : Exception +public class CliConfigurationException : Exception { /// - public CommandLineConfigurationException(string message) : base(message) + public CliConfigurationException(string message) : base(message) { } } \ No newline at end of file diff --git a/src/System.CommandLine/EnvironmentVariablesDirective.cs b/src/System.CommandLine/EnvironmentVariablesDirective.cs index b560897219..d65ae37fe5 100644 --- a/src/System.CommandLine/EnvironmentVariablesDirective.cs +++ b/src/System.CommandLine/EnvironmentVariablesDirective.cs @@ -26,27 +26,24 @@ private sealed class EnvironmentVariablesDirectiveAction : CliAction { private readonly EnvironmentVariablesDirective _directive; - internal EnvironmentVariablesDirectiveAction(EnvironmentVariablesDirective directive) => _directive = directive; + internal EnvironmentVariablesDirectiveAction(EnvironmentVariablesDirective directive) + { + _directive = directive; + Exclusive = false; + } public override int Invoke(ParseResult parseResult) { SetEnvVars(parseResult); - return parseResult.CommandResult.Command.Action?.Invoke(parseResult) ?? 0; + return 0; } public override Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) { - if (cancellationToken.IsCancellationRequested) - { - return Task.FromCanceled(cancellationToken); - } - SetEnvVars(parseResult); - return parseResult.CommandResult.Command.Action is not null - ? parseResult.CommandResult.Command.Action.InvokeAsync(parseResult, cancellationToken) - : Task.FromResult(0); + return Task.FromResult(0); } private void SetEnvVars(ParseResult parseResult) diff --git a/src/System.CommandLine/Invocation/InvocationPipeline.cs b/src/System.CommandLine/Invocation/InvocationPipeline.cs index 381f9d3e6c..5ca47a9abd 100644 --- a/src/System.CommandLine/Invocation/InvocationPipeline.cs +++ b/src/System.CommandLine/Invocation/InvocationPipeline.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation and contributors. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -12,7 +13,7 @@ internal static async Task InvokeAsync(ParseResult parseResult, Cancellatio { if (parseResult.Action is null) { - return 0; + return ReturnCodeForMissingAction(parseResult); } ProcessTerminationHandler? terminationHandler = null; @@ -20,10 +21,21 @@ internal static async Task InvokeAsync(ParseResult parseResult, Cancellatio try { + if (parseResult.NonexclusiveActions is not null) + { + for (var i = 0; i < parseResult.NonexclusiveActions.Count; i++) + { + var action = parseResult.NonexclusiveActions[i]; + await action.InvokeAsync(parseResult, cts.Token); + } + } + Task startedInvocation = parseResult.Action.InvokeAsync(parseResult, cts.Token); if (parseResult.Configuration.ProcessTerminationTimeout.HasValue) + { terminationHandler = new(cts, startedInvocation, parseResult.Configuration.ProcessTerminationTimeout.Value); + } if (terminationHandler is null) { @@ -52,16 +64,34 @@ internal static int Invoke(ParseResult parseResult) { if (parseResult.Action is null) { - return 0; + return ReturnCodeForMissingAction(parseResult); } - try + if (parseResult.NonexclusiveActions is not null) { - return parseResult.Action.Invoke(parseResult); + for (var i = 0; i < parseResult.NonexclusiveActions.Count; i++) + { + var action = parseResult.NonexclusiveActions[i]; + var result = TryInvokeAction(parseResult, action); + if (!result.success) + { + return result.returnCode; + } + } } - catch (Exception ex) when (parseResult.Configuration.EnableDefaultExceptionHandler) + + return TryInvokeAction(parseResult, parseResult.Action).returnCode; + + static (int returnCode, bool success) TryInvokeAction(ParseResult parseResult, CliAction action) { - return DefaultExceptionHandler(ex, parseResult.Configuration); + try + { + return (action.Invoke(parseResult), true); + } + catch (Exception ex) when (parseResult.Configuration.EnableDefaultExceptionHandler) + { + return (DefaultExceptionHandler(ex, parseResult.Configuration), false); + } } } @@ -79,5 +109,17 @@ private static int DefaultExceptionHandler(Exception exception, CliConfiguration } return 1; } + + private static int ReturnCodeForMissingAction(ParseResult parseResult) + { + if (parseResult.Errors.Count > 0) + { + return 1; + } + else + { + return 0; + } + } } } diff --git a/src/System.CommandLine/Invocation/ParseErrorResultAction.cs b/src/System.CommandLine/Invocation/ParseErrorAction.cs similarity index 95% rename from src/System.CommandLine/Invocation/ParseErrorResultAction.cs rename to src/System.CommandLine/Invocation/ParseErrorAction.cs index 1b3d43e4d7..04845a29da 100644 --- a/src/System.CommandLine/Invocation/ParseErrorResultAction.cs +++ b/src/System.CommandLine/Invocation/ParseErrorAction.cs @@ -7,7 +7,7 @@ namespace System.CommandLine.Invocation { - internal sealed class ParseErrorResultAction : CliAction + internal sealed class ParseErrorAction : CliAction { public override int Invoke(ParseResult parseResult) { diff --git a/src/System.CommandLine/ParseResult.cs b/src/System.CommandLine/ParseResult.cs index 854eaa8e3a..a540cace89 100644 --- a/src/System.CommandLine/ParseResult.cs +++ b/src/System.CommandLine/ParseResult.cs @@ -17,11 +17,11 @@ namespace System.CommandLine /// public sealed class ParseResult { - private readonly IReadOnlyList _errors; private readonly CommandResult _rootCommandResult; private readonly IReadOnlyList _unmatchedTokens; private CompletionContext? _completionContext; - private CliAction? _action; + private readonly CliAction? _action; + private readonly List? _nonexclusiveActions; private Dictionary? _namedResults; internal ParseResult( @@ -29,15 +29,17 @@ internal ParseResult( CommandResult rootCommandResult, CommandResult commandResult, List tokens, - IReadOnlyList? unmatchedTokens, + List? unmatchedTokens, List? errors, string? commandLineText = null, - CliAction? action = null) + CliAction? action = null, + List? nonexclusiveActions = null) { Configuration = configuration; _rootCommandResult = rootCommandResult; CommandResult = commandResult; _action = action; + _nonexclusiveActions = nonexclusiveActions; // skip the root command when populating Tokens property if (tokens.Count > 1) @@ -55,7 +57,7 @@ internal ParseResult( CommandLineText = commandLineText; _unmatchedTokens = unmatchedTokens is null ? Array.Empty() : unmatchedTokens; - _errors = errors is not null ? errors : Array.Empty(); + Errors = errors is not null ? errors : Array.Empty(); } internal static ParseResult Empty() => new CliRootCommand().Parse(Array.Empty()); @@ -78,7 +80,7 @@ internal ParseResult( /// /// Gets the parse errors found while parsing command line input. /// - public IReadOnlyList Errors => _errors; + public IReadOnlyList Errors { get; } /// /// Gets the tokens identified while parsing command line input. @@ -261,14 +263,11 @@ public IEnumerable GetCompletions( context = tcc.AtCursorPosition(position.Value); } - var completions = - currentSymbol is not null - ? currentSymbol.GetCompletions(context) - : Array.Empty(); + var completions = currentSymbol.GetCompletions(context); string[] optionsWithArgumentLimitReached = currentSymbolResult is CommandResult commandResult - ? OptionsWithArgumentLimitReached(commandResult) - : Array.Empty(); + ? OptionsWithArgumentLimitReached(commandResult) + : Array.Empty(); completions = completions.Where(item => optionsWithArgumentLimitReached.All(s => s != item.Label)); @@ -305,6 +304,8 @@ public Task InvokeAsync(CancellationToken cancellationToken = default) /// public CliAction? Action => _action ?? CommandResult.Command.Action; + internal IReadOnlyList? NonexclusiveActions => _nonexclusiveActions; + private SymbolResult SymbolToComplete(int? position = null) { var commandResult = CommandResult; diff --git a/src/System.CommandLine/Parsing/ParseOperation.cs b/src/System.CommandLine/Parsing/ParseOperation.cs index 246a9fbcd9..0b78328888 100644 --- a/src/System.CommandLine/Parsing/ParseOperation.cs +++ b/src/System.CommandLine/Parsing/ParseOperation.cs @@ -17,8 +17,10 @@ internal sealed class ParseOperation private int _index; private CommandResult _innermostCommandResult; - private bool _isHelpRequested, _isParseRequested; - private CliAction? _action; + private bool _isHelpRequested; + private bool _isDiagramRequested; + private CliAction? _primaryAction; + private List? _nonexclusiveActions; public ParseOperation( List tokens, @@ -60,16 +62,16 @@ internal ParseResult Parse() Validate(); } - if (_action is null) + if (_primaryAction is null) { if (_configuration.EnableTypoCorrections && _rootCommandResult.Command.TreatUnmatchedTokensAsErrors && _symbolResultTree.UnmatchedTokens is not null) { - _action = new TypoCorrectionAction(); + _primaryAction = new TypoCorrectionAction(); } else if (_configuration.EnableParseErrorReporting && _symbolResultTree.ErrorCount > 0) { - _action = new ParseErrorResultAction(); + _primaryAction = new ParseErrorAction(); } } @@ -81,7 +83,8 @@ internal ParseResult Parse() _symbolResultTree.UnmatchedTokens, _symbolResultTree.Errors, _rawInput, - _action); + _primaryAction, + _nonexclusiveActions); } private void ParseSubcommand() @@ -187,8 +190,8 @@ private void ParseOption() if (!_symbolResultTree.TryGetValue(option, out SymbolResult? symbolResult)) { - // parse directive has a precedence over --help and --version - if (!_isParseRequested) + // DiagramDirective has a precedence over --help and --version + if (!_isDiagramRequested) { if (option.Action is not null) { @@ -197,7 +200,7 @@ private void ParseOption() _isHelpRequested = true; } - _action = option.Action; + _primaryAction = option.Action; } } @@ -319,11 +322,26 @@ void ParseDirective() result.AddValue(withoutBrackets.Slice(indexOfColon + 1).ToString()); } - _action = directive.Action; + if (directive.Action is not null) + { + if (directive.Action.Exclusive) + { + _primaryAction = directive.Action; + } + else + { + if (_nonexclusiveActions is null) + { + _nonexclusiveActions = new(); + } + + _nonexclusiveActions.Add(directive.Action); + } + } if (directive is DiagramDirective) { - _isParseRequested = true; + _isDiagramRequested = true; } } }