9 Commits
1.3 ... 1.3.2

Author SHA1 Message Date
Alexey Golub
b17341b56c Update version 2020-07-31 15:41:47 +03:00
Alexey Golub
5bda964fb5 Cleanup 2020-07-31 15:34:38 +03:00
Daniel Hix
432430489a Add error for non-scalar parameters bound without any values (#71) 2020-07-31 15:08:13 +03:00
Ron Myers
9a20101f30 Fix application crashes if there are two environment variables with same name, differing only in case (#67) 2020-07-28 14:20:02 +03:00
Alexey Golub
b491818779 Update version 2020-07-19 18:20:42 +03:00
Alexey Golub
69c24c8dfc Refactor 2020-07-19 18:11:54 +03:00
Ihor Nechyporuk
004f906148 Fix exit code overflow for unhandled exceptions on Unix systems (#62) 2020-07-19 16:50:37 +03:00
Volodymyr Shkolka
ac83233dc2 Add ability to specify active debugger attachment instead of passive (#61) 2020-07-10 13:54:09 +03:00
Alexey Golub
082910c968 Update readme 2020-05-24 12:46:16 +03:00
27 changed files with 223 additions and 169 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -1,3 +1,13 @@
### v1.3.2 (31-Jul-2020)
- Fixed an issue where a command was incorrectly allowed to execute when the user did not specify any value for a non-scalar parameter. Since they are always required, a parameter needs to be bound to (at least) one value. (Thanks [@Daniel Hix](https://github.com/ADustyOldMuffin))
- Fixed an issue where `CliApplication.RunAsync(...)` threw `ArgumentException` if there were two environment variables, whose names differed only in case. Environment variable names are now treated case-sensitively. (Thanks [@Ron Myers](https://github.com/ron-myers))
### v1.3.1 (19-Jul-2020)
- Running the application with the debug directive (`myapp [debug]`) will now also try to launch a debugger instance. In most cases it will save time as you won't need to attach the debugger manually. (Thanks [@Volodymyr Shkolka](https://github.com/BlackGad))
- Fixed an issue where unhandled generic exceptions (i.e. not `CommandException`) sometimes caused the application to incorrectly return successful exit code due to an overflow issue on Unix systems. Starting from this version, all unhandled generic exceptions will produce `1` as the exit code when thrown. Instances of `CommandException` can still be configured to return any specified exit code, but it's recommended to constrain the values between `1` and `255` to avoid overflow issues. (Thanks [@Ihor Nechyporuk](https://github.com/inech))
### v1.3 (23-May-2020) ### v1.3 (23-May-2020)
- Changed analyzers to report errors instead of warnings. If you find that some analyzer works incorrectly, please report it on GitHub. You can also configure inspection severity overrides in your project if you need to. - Changed analyzers to report errors instead of warnings. If you find that some analyzer works incorrectly, please report it on GitHub. You can also configure inspection severity overrides in your project if you need to.

View File

@@ -206,7 +206,7 @@ namespace CliFx.Analyzers
// Duplicate environment variable name // Duplicate environment variable name
var duplicateEnvironmentVariableNameOptions = options var duplicateEnvironmentVariableNameOptions = options
.Where(p => !string.IsNullOrWhiteSpace(p.EnvironmentVariableName)) .Where(p => !string.IsNullOrWhiteSpace(p.EnvironmentVariableName))
.GroupBy(p => p.EnvironmentVariableName, StringComparer.OrdinalIgnoreCase) .GroupBy(p => p.EnvironmentVariableName, StringComparer.Ordinal)
.Where(g => g.Count() > 1) .Where(g => g.Count() > 1)
.SelectMany(g => g.AsEnumerable()) .SelectMany(g => g.AsEnumerable())
.ToArray(); .ToArray();

View File

@@ -134,11 +134,17 @@ namespace CliFx.Tests
[Command] [Command]
private class RequiredOptionCommand : ICommand private class RequiredOptionCommand : ICommand
{ {
[CommandOption(nameof(OptionA))] [CommandOption(nameof(Option), IsRequired = true)]
public string? OptionA { get; set; } public string? Option { get; set; }
[CommandOption(nameof(OptionB), IsRequired = true)] public ValueTask ExecuteAsync(IConsole console) => default;
public string? OptionB { get; set; } }
[Command]
private class RequiredArrayOptionCommand : ICommand
{
[CommandOption(nameof(Option), IsRequired = true)]
public IReadOnlyList<string>? Option { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default; public ValueTask ExecuteAsync(IConsole console) => default;
} }

View File

@@ -825,88 +825,6 @@ namespace CliFx.Tests
}); });
} }
[Fact]
public void Property_annotated_as_an_option_can_be_bound_from_multiple_values_even_if_the_inputs_use_mixed_naming()
{
// Arrange
var input = new CommandInputBuilder()
.AddOption("option", "foo")
.AddOption("o", "bar")
.AddOption("option", "baz")
.Build();
// Act
var instance = CommandHelper.ResolveCommand<ArrayOptionCommand>(input);
// Assert
instance.Should().BeEquivalentTo(new ArrayOptionCommand
{
Option = new[] {"foo", "bar", "baz"}
});
}
[Fact]
public void Property_annotated_as_a_required_option_must_always_be_set()
{
// Arrange
var input = new CommandInputBuilder()
.AddOption(nameof(RequiredOptionCommand.OptionA), "foo")
.Build();
// Act & assert
var ex = Assert.Throws<CliFxException>(() => CommandHelper.ResolveCommand<RequiredOptionCommand>(input));
_output.WriteLine(ex.Message);
}
[Fact]
public void Property_annotated_as_a_required_option_must_always_be_bound_to_some_value()
{
// Arrange
var input = new CommandInputBuilder()
.AddOption(nameof(RequiredOptionCommand.OptionB))
.Build();
// Act & assert
var ex = Assert.Throws<CliFxException>(() => CommandHelper.ResolveCommand<RequiredOptionCommand>(input));
_output.WriteLine(ex.Message);
}
[Fact]
public void Property_annotated_as_parameter_is_bound_directly_from_argument_value_according_to_the_order()
{
// Arrange
var input = new CommandInputBuilder()
.AddParameter("foo")
.AddParameter("bar")
.AddParameter("hello")
.AddParameter("world")
.Build();
// Act
var instance = CommandHelper.ResolveCommand<ParametersCommand>(input);
// Assert
instance.Should().BeEquivalentTo(new ParametersCommand
{
ParameterA = "foo",
ParameterB = "bar",
ParameterC = new[] {"hello", "world"}
});
}
[Fact]
public void Property_annotated_as_parameter_must_always_be_bound_to_some_value()
{
// Arrange
var input = new CommandInputBuilder()
.AddParameter("foo")
.Build();
// Act & assert
var ex = Assert.Throws<CliFxException>(() => CommandHelper.ResolveCommand<ParametersCommand>(input));
_output.WriteLine(ex.Message);
}
[Fact] [Fact]
public void Property_of_custom_type_that_implements_IEnumerable_can_only_be_bound_if_that_type_has_a_constructor_accepting_an_array() public void Property_of_custom_type_that_implements_IEnumerable_can_only_be_bound_if_that_type_has_a_constructor_accepting_an_array()
{ {
@@ -959,6 +877,114 @@ namespace CliFx.Tests
_output.WriteLine(ex.Message); _output.WriteLine(ex.Message);
} }
[Fact]
public void Property_annotated_as_an_option_can_be_bound_from_multiple_values_even_if_the_inputs_use_mixed_naming()
{
// Arrange
var input = new CommandInputBuilder()
.AddOption("option", "foo")
.AddOption("o", "bar")
.AddOption("option", "baz")
.Build();
// Act
var instance = CommandHelper.ResolveCommand<ArrayOptionCommand>(input);
// Assert
instance.Should().BeEquivalentTo(new ArrayOptionCommand
{
Option = new[] {"foo", "bar", "baz"}
});
}
[Fact]
public void Property_annotated_as_a_required_option_must_always_be_set()
{
// Arrange
var input = new CommandInputBuilder()
.Build();
// Act & assert
var ex = Assert.Throws<CliFxException>(() => CommandHelper.ResolveCommand<RequiredOptionCommand>(input));
_output.WriteLine(ex.Message);
}
[Fact]
public void Property_annotated_as_a_required_option_must_always_be_bound_to_some_value()
{
// Arrange
var input = new CommandInputBuilder()
.AddOption(nameof(RequiredOptionCommand.Option))
.Build();
// Act & assert
var ex = Assert.Throws<CliFxException>(() => CommandHelper.ResolveCommand<RequiredOptionCommand>(input));
_output.WriteLine(ex.Message);
}
[Fact]
public void Property_annotated_as_a_required_option_must_always_be_bound_to_at_least_one_value_if_it_expects_multiple_values()
{
// Arrange
var input = new CommandInputBuilder()
.AddOption(nameof(RequiredOptionCommand.Option))
.Build();
// Act & assert
var ex = Assert.Throws<CliFxException>(() => CommandHelper.ResolveCommand<RequiredArrayOptionCommand>(input));
_output.WriteLine(ex.Message);
}
[Fact]
public void Property_annotated_as_parameter_is_bound_directly_from_argument_value_according_to_the_order()
{
// Arrange
var input = new CommandInputBuilder()
.AddParameter("foo")
.AddParameter("bar")
.AddParameter("hello")
.AddParameter("world")
.Build();
// Act
var instance = CommandHelper.ResolveCommand<ParametersCommand>(input);
// Assert
instance.Should().BeEquivalentTo(new ParametersCommand
{
ParameterA = "foo",
ParameterB = "bar",
ParameterC = new[] {"hello", "world"}
});
}
[Fact]
public void Property_annotated_as_parameter_must_always_be_bound_to_some_value()
{
// Arrange
var input = new CommandInputBuilder()
.AddParameter("foo")
.Build();
// Act & assert
var ex = Assert.Throws<CliFxException>(() => CommandHelper.ResolveCommand<ParametersCommand>(input));
_output.WriteLine(ex.Message);
}
[Fact]
public void Property_annotated_as_parameter_must_always_be_bound_to_at_least_one_value_if_it_expects_multiple_values()
{
// Arrange
var input = new CommandInputBuilder()
.AddParameter("foo")
.AddParameter("bar")
.Build();
// Act & assert
var ex = Assert.Throws<CliFxException>(() => CommandHelper.ResolveCommand<ParametersCommand>(input));
_output.WriteLine(ex.Message);
}
[Fact] [Fact]
public void All_provided_option_arguments_must_be_bound_to_corresponding_properties() public void All_provided_option_arguments_must_be_bound_to_corresponding_properties()
{ {

View File

@@ -1,12 +1,6 @@
using System; using System.Collections.Generic;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Tests.Internal;
using CliWrap;
using CliWrap.Buffered;
using FluentAssertions; using FluentAssertions;
using Xunit; using Xunit;
@@ -14,30 +8,6 @@ namespace CliFx.Tests
{ {
public partial class DirectivesSpecs public partial class DirectivesSpecs
{ {
[Fact]
public async Task Debug_directive_can_be_specified_to_have_the_application_wait_until_debugger_is_attached()
{
// We can't actually attach a debugger in tests, so instead just cancel execution after some time
// Arrange
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
var stdOut = new StringBuilder();
var command = Cli.Wrap("dotnet")
.WithArguments(a => a
.Add(Dummy.Program.Location)
.Add("[debug]"))
.WithEnvironmentVariables(e => e
.Set("ENV_TARGET", "Mars")) | stdOut;
// Act
await command.ExecuteAsync(cts.Token).Task.IgnoreCancellation();
var stdOutData = stdOut.ToString();
// Assert
stdOutData.Should().Contain("Attach debugger to");
}
[Fact] [Fact]
public async Task Preview_directive_can_be_specified_to_print_provided_arguments_as_they_were_parsed() public async Task Preview_directive_can_be_specified_to_print_provided_arguments_as_they_were_parsed()
{ {

View File

@@ -89,5 +89,27 @@ namespace CliFx.Tests
Option = $"foo{Path.PathSeparator}bar" Option = $"foo{Path.PathSeparator}bar"
}); });
} }
[Fact]
public void Option_can_use_a_specific_environment_variable_as_fallback_while_respecting_case()
{
// Arrange
const string expected = "foobar";
var input = CommandInput.Empty;
var envVars = new Dictionary<string, string>
{
["ENV_OPT"] = expected,
["env_opt"] = "2"
};
// Act
var instance = CommandHelper.ResolveCommand<EnvironmentVariableCommand>(input, envVars);
// Assert
instance.Should().BeEquivalentTo(new EnvironmentVariableCommand
{
Option = expected
});
}
} }
} }

View File

@@ -20,7 +20,7 @@ namespace CliFx.Tests
private class CommandExceptionCommand : ICommand private class CommandExceptionCommand : ICommand
{ {
[CommandOption("code", 'c')] [CommandOption("code", 'c')]
public int ExitCode { get; set; } = 1337; public int ExitCode { get; set; } = 133;
[CommandOption("msg", 'm')] [CommandOption("msg", 'm')]
public string? Message { get; set; } public string? Message { get; set; }

View File

@@ -1,6 +1,4 @@
using System; using System.IO;
using System.Globalization;
using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using FluentAssertions; using FluentAssertions;
using Xunit; using Xunit;

View File

@@ -1,7 +1,7 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>1.3</Version> <Version>1.3.2</Version>
<Company>Tyrrrz</Company> <Company>Tyrrrz</Company>
<Copyright>Copyright (C) Alexey Golub</Copyright> <Copyright>Copyright (C) Alexey Golub</Copyright>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>

View File

@@ -28,7 +28,8 @@ namespace CliFx
/// </summary> /// </summary>
public ApplicationConfiguration( public ApplicationConfiguration(
IReadOnlyList<Type> commandTypes, IReadOnlyList<Type> commandTypes,
bool isDebugModeAllowed, bool isPreviewModeAllowed) bool isDebugModeAllowed,
bool isPreviewModeAllowed)
{ {
CommandTypes = commandTypes; CommandTypes = commandTypes;
IsDebugModeAllowed = isDebugModeAllowed; IsDebugModeAllowed = isDebugModeAllowed;

View File

@@ -41,13 +41,15 @@ namespace CliFx
private void WriteError(string message) => _console.WithForegroundColor(ConsoleColor.Red, () => private void WriteError(string message) => _console.WithForegroundColor(ConsoleColor.Red, () =>
_console.Error.WriteLine(message)); _console.Error.WriteLine(message));
private async ValueTask WaitForDebuggerAsync() private async ValueTask LaunchAndWaitForDebuggerAsync()
{ {
var processId = ProcessEx.GetCurrentProcessId(); var processId = ProcessEx.GetCurrentProcessId();
_console.WithForegroundColor(ConsoleColor.Green, () => _console.WithForegroundColor(ConsoleColor.Green, () =>
_console.Output.WriteLine($"Attach debugger to PID {processId} to continue.")); _console.Output.WriteLine($"Attach debugger to PID {processId} to continue."));
Debugger.Launch();
while (!Debugger.IsAttached) while (!Debugger.IsAttached)
await Task.Delay(100); await Task.Delay(100);
} }
@@ -125,8 +127,7 @@ namespace CliFx
// Debug mode // Debug mode
if (_configuration.IsDebugModeAllowed && input.IsDebugDirectiveSpecified) if (_configuration.IsDebugModeAllowed && input.IsDebugDirectiveSpecified)
{ {
// Ensure debugger is attached and continue await LaunchAndWaitForDebuggerAsync();
await WaitForDebuggerAsync();
} }
// Preview mode // Preview mode
@@ -217,9 +218,10 @@ namespace CliFx
/// </remarks> /// </remarks>
public async ValueTask<int> RunAsync(IReadOnlyList<string> commandLineArguments) public async ValueTask<int> RunAsync(IReadOnlyList<string> commandLineArguments)
{ {
// Environment variable names are case-insensitive on Windows but are case-sensitive on Linux and macOS
var environmentVariables = Environment.GetEnvironmentVariables() var environmentVariables = Environment.GetEnvironmentVariables()
.Cast<DictionaryEntry>() .Cast<DictionaryEntry>()
.ToDictionary(e => (string) e.Key, e => (string) e.Value, StringComparer.OrdinalIgnoreCase); .ToDictionary(e => (string) e.Key, e => (string) e.Value, StringComparer.Ordinal);
return await RunAsync(commandLineArguments, environmentVariables); return await RunAsync(commandLineArguments, environmentVariables);
} }
@@ -252,7 +254,7 @@ namespace CliFx
public static int FromException(Exception ex) => public static int FromException(Exception ex) =>
ex is CommandException cmdEx ex is CommandException cmdEx
? cmdEx.ExitCode ? cmdEx.ExitCode
: ex.HResult; : 1;
} }
[Command] [Command]
@@ -263,4 +265,4 @@ namespace CliFx
public ValueTask ExecuteAsync(IConsole console) => default; public ValueTask ExecuteAsync(IConsole console) => default;
} }
} }
} }

View File

@@ -4,7 +4,7 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using CliFx.Domain; using CliFx.Domain;
using CliFx.Internal; using CliFx.Internal.Extensions;
namespace CliFx namespace CliFx
{ {

View File

@@ -4,7 +4,7 @@ using System.Globalization;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using CliFx.Exceptions; using CliFx.Exceptions;
using CliFx.Internal; using CliFx.Internal.Extensions;
namespace CliFx.Domain namespace CliFx.Domain
{ {

View File

@@ -2,7 +2,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using CliFx.Internal; using CliFx.Internal.Extensions;
namespace CliFx.Domain namespace CliFx.Domain
{ {

View File

@@ -1,6 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using CliFx.Internal; using CliFx.Internal.Extensions;
namespace CliFx.Domain namespace CliFx.Domain
{ {

View File

@@ -45,7 +45,7 @@ namespace CliFx.Domain
public bool MatchesEnvironmentVariableName(string environmentVariableName) => public bool MatchesEnvironmentVariableName(string environmentVariableName) =>
!string.IsNullOrWhiteSpace(EnvironmentVariableName) && !string.IsNullOrWhiteSpace(EnvironmentVariableName) &&
string.Equals(EnvironmentVariableName, environmentVariableName, StringComparison.OrdinalIgnoreCase); string.Equals(EnvironmentVariableName, environmentVariableName, StringComparison.Ordinal);
public string GetUserFacingDisplayString() public string GetUserFacingDisplayString()
{ {

View File

@@ -6,7 +6,7 @@ using System.Reflection;
using System.Text; using System.Text;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Exceptions; using CliFx.Exceptions;
using CliFx.Internal; using CliFx.Internal.Extensions;
namespace CliFx.Domain namespace CliFx.Domain
{ {
@@ -103,12 +103,16 @@ namespace CliFx.Domain
if (nonScalarParameter != null) if (nonScalarParameter != null)
{ {
// TODO: Should it verify that at least one value is passed?
var nonScalarValues = parameterInputs var nonScalarValues = parameterInputs
.Skip(scalarParameters.Length) .Skip(scalarParameters.Length)
.Select(p => p.Value) .Select(p => p.Value)
.ToArray(); .ToArray();
// Parameters are required by default and so a non-scalar parameter must
// be bound to at least one value
if(!nonScalarValues.Any())
throw CliFxException.ParameterNotSet(nonScalarParameter);
nonScalarParameter.BindOn(instance, nonScalarValues); nonScalarParameter.BindOn(instance, nonScalarValues);
remainingParameterInputs.Clear(); remainingParameterInputs.Clear();
} }

View File

@@ -3,7 +3,7 @@ using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using CliFx.Internal; using CliFx.Internal.Extensions;
namespace CliFx.Domain namespace CliFx.Domain
{ {

View File

@@ -2,7 +2,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using CliFx.Exceptions; using CliFx.Exceptions;
using CliFx.Internal; using CliFx.Internal.Extensions;
namespace CliFx.Domain namespace CliFx.Domain
{ {

View File

@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Domain; using CliFx.Domain;
using CliFx.Internal; using CliFx.Internal.Extensions;
namespace CliFx.Exceptions namespace CliFx.Exceptions
{ {

View File

@@ -9,12 +9,12 @@ namespace CliFx.Exceptions
/// </summary> /// </summary>
public class CommandException : Exception public class CommandException : Exception
{ {
private const int DefaultExitCode = -1; private const int DefaultExitCode = 1;
private readonly bool _isMessageSet; private readonly bool _isMessageSet;
/// <summary> /// <summary>
/// Returns an exit code associated with this exception. /// Exit code returned by the application when this exception is handled.
/// </summary> /// </summary>
public int ExitCode { get; } public int ExitCode { get; }
@@ -26,6 +26,9 @@ namespace CliFx.Exceptions
/// <summary> /// <summary>
/// Initializes an instance of <see cref="CommandException"/>. /// Initializes an instance of <see cref="CommandException"/>.
/// </summary> /// </summary>
/// <remarks>
/// On Unix systems an exit code is 8-bit unsigned integer so it's strongly recommended to use values between 1 and 255 to avoid overflow.
/// </remarks>
public CommandException(string? message, Exception? innerException, int exitCode = DefaultExitCode, bool showHelp = false) public CommandException(string? message, Exception? innerException, int exitCode = DefaultExitCode, bool showHelp = false)
: base(message, innerException) : base(message, innerException)
{ {
@@ -39,6 +42,9 @@ namespace CliFx.Exceptions
/// <summary> /// <summary>
/// Initializes an instance of <see cref="CommandException"/>. /// Initializes an instance of <see cref="CommandException"/>.
/// </summary> /// </summary>
/// <remarks>
/// On Unix systems an exit code is 8-bit unsigned integer so it's strongly recommended to use values between 1 and 255 to avoid overflow.
/// </remarks>
public CommandException(string? message, int exitCode = DefaultExitCode, bool showHelp = false) public CommandException(string? message, int exitCode = DefaultExitCode, bool showHelp = false)
: this(message, null, exitCode, showHelp) : this(message, null, exitCode, showHelp)
{ {
@@ -47,6 +53,9 @@ namespace CliFx.Exceptions
/// <summary> /// <summary>
/// Initializes an instance of <see cref="CommandException"/>. /// Initializes an instance of <see cref="CommandException"/>.
/// </summary> /// </summary>
/// <remarks>
/// On Unix systems an exit code is 8-bit unsigned integer so it's strongly recommended to use values between 1 and 255 to avoid overflow.
/// </remarks>
public CommandException(int exitCode = DefaultExitCode, bool showHelp = false) public CommandException(int exitCode = DefaultExitCode, bool showHelp = false)
: this(null, exitCode, showHelp) : this(null, exitCode, showHelp)
{ {

View File

@@ -1,6 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
namespace CliFx.Internal namespace CliFx.Internal.Extensions
{ {
internal static class CollectionExtensions internal static class CollectionExtensions
{ {

View File

@@ -2,7 +2,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Text; using System.Text;
namespace CliFx.Internal namespace CliFx.Internal.Extensions
{ {
internal static class StringExtensions internal static class StringExtensions
{ {

View File

@@ -4,7 +4,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
namespace CliFx.Internal namespace CliFx.Internal.Extensions
{ {
internal static class TypeExtensions internal static class TypeExtensions
{ {

View File

@@ -1,6 +1,6 @@
using System; using System;
namespace CliFx.Internal namespace CliFx.Internal.Extensions
{ {
internal static class VersionExtensions internal static class VersionExtensions
{ {

View File

@@ -168,7 +168,7 @@ Parameters
* value Value whose logarithm is to be found. * value Value whose logarithm is to be found.
Options Options
-b|--base Logarithm base. -b|--base Logarithm base. Default: "10".
-h|--help Shows help text. -h|--help Shows help text.
--version Shows version information. --version Shows version information.
``` ```
@@ -215,9 +215,9 @@ As a general guideline, prefer to use parameters for required inputs that the co
### Argument syntax ### Argument syntax
This library supports an argument syntax which is based on the POSIX standard. To be fair, nobody really knows what the standard is about and very few tools actually follow it as they're supposed to, so for the purpose of having dashes and spaces, CliFx is using the "standard command line syntax". This library supports an argument syntax which is based on the POSIX standard. To be fair, nobody really knows what the standard is about and very few tools actually follow it to the letter, so for the purpose of having dashes and spaces, CliFx is using the "standard command line syntax".
In more detail, the following examples are all valid: More specifically, the following examples are all valid:
- `myapp --foo bar` sets option `"foo"` to value `"bar"` - `myapp --foo bar` sets option `"foo"` to value `"bar"`
- `myapp -f bar` sets option `'f'` to value `"bar"` - `myapp -f bar` sets option `'f'` to value `"bar"`
@@ -227,32 +227,36 @@ In more detail, the following examples are all valid:
- `myapp -xqf bar` sets options `'x'` and `'q'` without value, and option `'f'` to value `"bar"` - `myapp -xqf bar` sets options `'x'` and `'q'` without value, and option `'f'` to value `"bar"`
- `myapp -i file1.txt file2.txt` sets option `'i'` to a sequence of values `"file1.txt"` and `"file2.txt"` - `myapp -i file1.txt file2.txt` sets option `'i'` to a sequence of values `"file1.txt"` and `"file2.txt"`
- `myapp -i file1.txt -i file2.txt` sets option `'i'` to a sequence of values `"file1.txt"` and `"file2.txt"` - `myapp -i file1.txt -i file2.txt` sets option `'i'` to a sequence of values `"file1.txt"` and `"file2.txt"`
- `myapp jar new -o cookie` sets option `'o'` to value `"cookie"` and retains two unbound arguments `"jar"` and `"new"` - `myapp cmd abc -o` routes to command `cmd` (assuming it exists) with parameter `abc` and sets option `'o'` without value
Note that CliFx purposely employs a context-free parser when consuming command line arguments. That means that every input is parsed the same way. Argument parsing in CliFx aims to be as deterministic as possible, ideally yielding the same result no matter the context. The only context-sensitive part in the parser is the command name resolution which needs to know what commands are available in order to discern between arguments that correspond to the command name and arguments which are parameters.
This also means that `myapp -i file1.txt file2.txt` will _always_ be parsed as an option with multiple values, even if the underlying bound property is not enumerable. For the same reason, unseparated arguments such as `myapp -ofile` will be treated as five distinct options `'o'`, `'f'`, `'i'`, `'l'`, `'e'`, instead of `'o'` being set to `"file"`. Options are always parsed the same way, disregarding the arity of the actual property it binds to. This means that `myapp -i file1.txt file2.txt` will _always_ be parsed as an option with multiple values, even if the underlying bound property is not enumerable. For the same reason, unseparated arguments such as `myapp -ofile` will be treated as five distinct options `'o'`, `'f'`, `'i'`, `'l'`, `'e'`, instead of `'o'` being set to `"file"`.
When it comes to command name and parameters, they must appear in a strict order, before any options. The parser can't distinguish between arguments that make up a part of the command name and arguments that belong to command parameters, which is why the non-option arguments are bound at a later stage. It is done by trying to find a command that matches the longest sequence of arguments starting from the first, binding any remaining arguments to positional parameters. Because of these rules, order of arguments is semantically important and it always goes like this:
The above design may seem like a deficiency, but it actually provides value in the fact that it's deterministic -- given a set of command line arguments, the semantics behind them always remain the same. This leads to a more consistent experience for both you as a developer, as well as for the users of your application. ```ini
{directives} {command name} {parameters} {options}
```
The above design makes the usage of your applications a lot more intuitive and predictable, providing a better end-user experience.
### Value conversion ### Value conversion
Parameters and options can have different underlying types: Parameters and options can have different underlying types:
- Standard types - Standard types
- Primitive types (`int`, `bool`, `double`, `ulong`, `char`, etc) - Primitive types (`int`, `bool`, `double`, `ulong`, `char`, etc.)
- Date and time types (`DateTime`, `DateTimeOffset`, `TimeSpan`) - Date and time types (`DateTime`, `DateTimeOffset`, `TimeSpan`)
- Enum types - Enum types (converted from either name or value)
- String-initializable types - String-initializable types
- Types with a constructor that accepts a single `string` parameter (`FileInfo`, `DirectoryInfo`, etc) - Types with a constructor that accepts a single `string` parameter (`FileInfo`, `DirectoryInfo`, etc.)
- Types with a static method `Parse` that accepts a single `string` parameter (and optionally `IFormatProvider`) - Types with a static method `Parse` that accepts a single `string` parameter (and optionally `IFormatProvider`)
- Nullable versions of all above types (`decimal?`, `TimeSpan?`, etc) - Nullable versions of all above types (`decimal?`, `TimeSpan?`, etc.)
- Collections of all above types - Collections of all above types
- Array types (`T[]`) - Array types (`T[]`)
- Types that are assignable from arrays (`IReadOnlyList<T>`, `ICollection<T>`, etc) - Types that are assignable from arrays (`IReadOnlyList<T>`, `ICollection<T>`, etc.)
- Types with a constructor that accepts a single `T[]` parameter (`HashSet<T>`, `List<T>`, etc) - Types with a constructor that accepts a single `T[]` parameter (`HashSet<T>`, `List<T>`, etc.)
When defining a parameter of an enumerable type, keep in mind that it has to be the only such parameter and it must be the last in order. Options, on the other hand, don't have this limitation. When defining a parameter of an enumerable type, keep in mind that it has to be the only such parameter and it must be the last in order. Options, on the other hand, don't have this limitation.
@@ -392,10 +396,12 @@ You can run `myapp.exe cmd1 [command] --help` to show help on a specific command
You may have noticed that commands in CliFx don't return exit codes. This is by design as exit codes are considered a higher-level concern and thus handled by `CliApplication`, not by individual commands. You may have noticed that commands in CliFx don't return exit codes. This is by design as exit codes are considered a higher-level concern and thus handled by `CliApplication`, not by individual commands.
Commands can report execution failure simply by throwing exceptions just like any other C# code. When an exception is thrown, `CliApplication` will catch it, print the error, and return an appropriate exit code to the calling process. Commands can report execution failure simply by throwing exceptions just like any other C# code. When an exception is thrown, `CliApplication` will catch it, print the error, and return `1` as the exit code to the calling process.
If you want to communicate a specific error through exit code, you can instead throw an instance of `CommandException` which takes an exit code as a parameter. When a command throws an exception of type `CommandException`, it is assumed that this was a result of a handled error and, as such, only the exception message will be printed to the error stream. If a command throws an exception of any other type, the full stack trace will be printed as well. If you want to communicate a specific error through exit code, you can instead throw an instance of `CommandException` which takes an exit code as a parameter. When a command throws an exception of type `CommandException`, it is assumed that this was a result of a handled error and, as such, only the exception message will be printed to the error stream. If a command throws an exception of any other type, the full stack trace will be printed as well.
> Note: Unix systems rely on 8-bit unsigned integers for exit codes, so it's strongly recommended to use values between `1` and `255` to avoid potential overflow issues.
```c# ```c#
[Command] [Command]
public class DivideCommand : ICommand public class DivideCommand : ICommand
@@ -410,8 +416,8 @@ public class DivideCommand : ICommand
{ {
if (Math.Abs(Divisor) < double.Epsilon) if (Math.Abs(Divisor) < double.Epsilon)
{ {
// This will print the error and set exit code to 1337 // This will print the error and set exit code to 133
throw new CommandException("Division by zero is not supported.", 1337); throw new CommandException("Division by zero is not supported.", 133);
} }
var result = Dividend / Divisor; var result = Dividend / Divisor;
@@ -430,10 +436,10 @@ Division by zero is not supported.
> $LastExitCode > $LastExitCode
1337 133
``` ```
You can also specify the `showHelp` parameter to instruct whether to show the help text after printing the error: You can also specify the `showHelp` parameter to instruct whether to show the help text for the current command after printing the error:
```c# ```c#
[Command] [Command]
@@ -448,7 +454,7 @@ public class ExampleCommand : ICommand
### Graceful cancellation ### Graceful cancellation
It is possible to gracefully cancel execution of a command and preform any necessary cleanup. By default an app gets forcefully killed when it receives an interrupt signal (Ctrl+C or Ctrl+Break), but you can easily override this behavior. It is possible to gracefully cancel execution of a command and preform any necessary cleanup. By default an app gets forcefully killed when it receives an interrupt signal (Ctrl+C or Ctrl+Break), but you can override this behavior.
In order to make a command cancellation-aware, you need to call `console.GetCancellationToken()`. This method returns a `CancellationToken` that will trigger when the user issues an interrupt signal. Note that any code that comes before the first call to `GetCancellationToken()` will not be cancellation-aware and as such will terminate instantly. Any subsequent calls to this method will return the same token. In order to make a command cancellation-aware, you need to call `console.GetCancellationToken()`. This method returns a `CancellationToken` that will trigger when the user issues an interrupt signal. Note that any code that comes before the first call to `GetCancellationToken()` will not be cancellation-aware and as such will terminate instantly. Any subsequent calls to this method will return the same token.
@@ -506,7 +512,7 @@ public static class Program
CliFx provides an easy way to write functional tests for your commands thanks to the `IConsole` interface. CliFx provides an easy way to write functional tests for your commands thanks to the `IConsole` interface.
You can use `VirtualConsole` to replace the application's stdin, stdout and stderr with your own streams. It has multiple constructor overloads allowing you to specify the exact set of streams that you want. Streams that are not provided are replaced with stubs, i.e. `VirtualConsole` doesn't leak to `System.Console` in any way. You can use `VirtualConsole` to replace the application's stdin, stdout and stderr with your own streams. It has multiple constructor overloads allowing you to specify the exact set of streams that you want. Streams which are not provided by you are replaced with stubs, i.e. `VirtualConsole` doesn't leak to `System.Console` in any way.
Let's assume you want to test a simple command such as this one. Let's assume you want to test a simple command such as this one.
@@ -633,7 +639,7 @@ for (var i = 0.0; i <= 1; i += 0.01)
### Environment variables ### Environment variables
An option can be configured to use the value of a specific environment variable as a fallback. An option can be configured to use the value of an environment variable as a fallback. If an option was not specified by the user, the value will be extracted from that environment variable instead. This also works on options which are marked as required.
```c# ```c#
[Command] [Command]