Refactor (#56)

This commit is contained in:
Alexey Golub
2020-05-23 18:45:07 +03:00
committed by GitHub
parent 17449e0794
commit bed22b6500
38 changed files with 1624 additions and 1521 deletions

View File

@@ -6,6 +6,18 @@ namespace CliFx.Tests
{
public partial class ApplicationSpecs
{
[Command]
private class DefaultCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command]
private class AnotherDefaultCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command]
private class NonImplementedCommand
{
@@ -118,6 +130,24 @@ namespace CliFx.Tests
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command]
private class ConflictWithHelpOptionCommand : ICommand
{
[CommandOption("option-h", 'h')]
public string? OptionH { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command]
private class ConflictWithVersionOptionCommand : ICommand
{
[CommandOption("version")]
public string? Version { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command]
private class DuplicateOptionEnvironmentVariableNamesCommand : ICommand
{
@@ -130,12 +160,6 @@ namespace CliFx.Tests
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command]
private class ValidCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("hidden", Description = "Description")]
private class HiddenPropertiesCommand : ICommand
{

View File

@@ -31,10 +31,10 @@ namespace CliFx.Tests
{
// Act
var app = new CliApplicationBuilder()
.AddCommand(typeof(ValidCommand))
.AddCommandsFrom(typeof(ValidCommand).Assembly)
.AddCommands(new[] {typeof(ValidCommand)})
.AddCommandsFrom(new[] {typeof(ValidCommand).Assembly})
.AddCommand(typeof(DefaultCommand))
.AddCommandsFrom(typeof(DefaultCommand).Assembly)
.AddCommands(new[] {typeof(DefaultCommand)})
.AddCommandsFrom(new[] {typeof(DefaultCommand).Assembly})
.AddCommandsFromThisAssembly()
.AllowDebugMode()
.AllowPreviewMode()
@@ -57,7 +57,7 @@ namespace CliFx.Tests
var commandTypes = Array.Empty<Type>();
// Act & assert
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => RootSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
@@ -68,7 +68,7 @@ namespace CliFx.Tests
var commandTypes = new[] {typeof(NonImplementedCommand)};
// Act & assert
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => RootSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
@@ -79,7 +79,7 @@ namespace CliFx.Tests
var commandTypes = new[] {typeof(NonAnnotatedCommand)};
// Act & assert
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => RootSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
@@ -90,7 +90,18 @@ namespace CliFx.Tests
var commandTypes = new[] {typeof(DuplicateNameCommandA), typeof(DuplicateNameCommandB)};
// Act & assert
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => RootSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
[Fact]
public void Command_can_be_default_but_only_if_it_is_the_only_such_command()
{
// Arrange
var commandTypes = new[] {typeof(DefaultCommand), typeof(AnotherDefaultCommand)};
// Act & assert
var ex = Assert.Throws<CliFxException>(() => RootSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
@@ -101,7 +112,7 @@ namespace CliFx.Tests
var commandTypes = new[] {typeof(DuplicateParameterOrderCommand)};
// Act & assert
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => RootSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
@@ -112,7 +123,7 @@ namespace CliFx.Tests
var commandTypes = new[] {typeof(DuplicateParameterNameCommand)};
// Act & assert
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => RootSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
@@ -123,7 +134,7 @@ namespace CliFx.Tests
var commandTypes = new[] {typeof(MultipleNonScalarParametersCommand)};
// Act & assert
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => RootSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
@@ -134,7 +145,7 @@ namespace CliFx.Tests
var commandTypes = new[] {typeof(NonLastNonScalarParameterCommand)};
// Act & assert
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => RootSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
@@ -145,7 +156,7 @@ namespace CliFx.Tests
var commandTypes = new[] {typeof(EmptyOptionNameCommand)};
// Act & assert
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => RootSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
@@ -156,7 +167,7 @@ namespace CliFx.Tests
var commandTypes = new[] {typeof(SingleCharacterOptionNameCommand)};
// Act & assert
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => RootSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
@@ -167,7 +178,7 @@ namespace CliFx.Tests
var commandTypes = new[] {typeof(DuplicateOptionNamesCommand)};
// Act & assert
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => RootSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
@@ -178,7 +189,29 @@ namespace CliFx.Tests
var commandTypes = new[] {typeof(DuplicateOptionShortNamesCommand)};
// Act & assert
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => RootSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
[Fact]
public void Command_options_must_not_have_conflicts_with_the_implicit_help_option()
{
// Arrange
var commandTypes = new[] {typeof(ConflictWithHelpOptionCommand)};
// Act & assert
var ex = Assert.Throws<CliFxException>(() => RootSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
[Fact]
public void Command_options_must_not_have_conflicts_with_the_implicit_version_option()
{
// Arrange
var commandTypes = new[] {typeof(ConflictWithVersionOptionCommand)};
// Act & assert
var ex = Assert.Throws<CliFxException>(() => RootSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
@@ -189,7 +222,7 @@ namespace CliFx.Tests
var commandTypes = new[] {typeof(DuplicateOptionEnvironmentVariableNamesCommand)};
// Act & assert
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => RootSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
@@ -200,10 +233,10 @@ namespace CliFx.Tests
var commandTypes = new[] {typeof(HiddenPropertiesCommand)};
// Act
var schema = ApplicationSchema.Resolve(commandTypes);
var schema = RootSchema.Resolve(commandTypes);
// Assert
schema.Should().BeEquivalentTo(new ApplicationSchema(new[]
schema.Should().BeEquivalentTo(new RootSchema(new[]
{
new CommandSchema(
typeof(HiddenPropertiesCommand),
@@ -225,7 +258,8 @@ namespace CliFx.Tests
'o',
"ENV",
false,
"Option description")
"Option description"),
CommandOptionSchema.HelpOption
})
}));

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using CliFx.Domain;
using CliFx.Tests.Internal;
using FluentAssertions;
using Xunit;
@@ -11,13 +13,14 @@ namespace CliFx.Tests
public void Input_is_empty_if_no_arguments_are_provided()
{
// Arrange
var args = Array.Empty<string>();
var arguments = Array.Empty<string>();
var commandNames = Array.Empty<string>();
// Act
var input = CommandLineInput.Parse(args);
var input = CommandInput.Parse(arguments, commandNames);
// Assert
input.Should().BeEquivalentTo(CommandLineInput.Empty);
input.Should().BeEquivalentTo(CommandInput.Empty);
}
public static object[][] DirectivesTestData => new[]
@@ -25,7 +28,7 @@ namespace CliFx.Tests
new object[]
{
new[] {"[preview]"},
new CommandLineInputBuilder()
new CommandInputBuilder()
.AddDirective("preview")
.Build()
},
@@ -33,7 +36,7 @@ namespace CliFx.Tests
new object[]
{
new[] {"[preview]", "[debug]"},
new CommandLineInputBuilder()
new CommandInputBuilder()
.AddDirective("preview")
.AddDirective("debug")
.Build()
@@ -42,10 +45,13 @@ namespace CliFx.Tests
[Theory]
[MemberData(nameof(DirectivesTestData))]
internal void Directive_can_be_enabled_by_specifying_its_name_in_square_brackets(string[] arguments, CommandLineInput expectedInput)
internal void Directive_can_be_enabled_by_specifying_its_name_in_square_brackets(IReadOnlyList<string> arguments, CommandInput expectedInput)
{
// Arrange
var commandNames = Array.Empty<string>();
// Act
var input = CommandLineInput.Parse(arguments);
var input = CommandInput.Parse(arguments, commandNames);
// Assert
input.Should().BeEquivalentTo(expectedInput);
@@ -56,7 +62,7 @@ namespace CliFx.Tests
new object[]
{
new[] {"--option"},
new CommandLineInputBuilder()
new CommandInputBuilder()
.AddOption("option")
.Build()
},
@@ -64,7 +70,7 @@ namespace CliFx.Tests
new object[]
{
new[] {"--option", "value"},
new CommandLineInputBuilder()
new CommandInputBuilder()
.AddOption("option", "value")
.Build()
},
@@ -72,7 +78,7 @@ namespace CliFx.Tests
new object[]
{
new[] {"--option", "value1", "value2"},
new CommandLineInputBuilder()
new CommandInputBuilder()
.AddOption("option", "value1", "value2")
.Build()
},
@@ -80,7 +86,7 @@ namespace CliFx.Tests
new object[]
{
new[] {"--option", "same value"},
new CommandLineInputBuilder()
new CommandInputBuilder()
.AddOption("option", "same value")
.Build()
},
@@ -88,7 +94,7 @@ namespace CliFx.Tests
new object[]
{
new[] {"--option1", "--option2"},
new CommandLineInputBuilder()
new CommandInputBuilder()
.AddOption("option1")
.AddOption("option2")
.Build()
@@ -97,7 +103,7 @@ namespace CliFx.Tests
new object[]
{
new[] {"--option1", "value1", "--option2", "value2"},
new CommandLineInputBuilder()
new CommandInputBuilder()
.AddOption("option1", "value1")
.AddOption("option2", "value2")
.Build()
@@ -106,7 +112,7 @@ namespace CliFx.Tests
new object[]
{
new[] {"--option1", "value1", "value2", "--option2", "value3", "value4"},
new CommandLineInputBuilder()
new CommandInputBuilder()
.AddOption("option1", "value1", "value2")
.AddOption("option2", "value3", "value4")
.Build()
@@ -115,7 +121,7 @@ namespace CliFx.Tests
new object[]
{
new[] {"--option1", "value1", "value2", "--option2"},
new CommandLineInputBuilder()
new CommandInputBuilder()
.AddOption("option1", "value1", "value2")
.AddOption("option2")
.Build()
@@ -124,10 +130,13 @@ namespace CliFx.Tests
[Theory]
[MemberData(nameof(OptionsTestData))]
internal void Option_can_be_set_by_specifying_its_name_after_two_dashes(string[] arguments, CommandLineInput expectedInput)
internal void Option_can_be_set_by_specifying_its_name_after_two_dashes(IReadOnlyList<string> arguments, CommandInput expectedInput)
{
// Arrange
var commandNames = Array.Empty<string>();
// Act
var input = CommandLineInput.Parse(arguments);
var input = CommandInput.Parse(arguments, commandNames);
// Assert
input.Should().BeEquivalentTo(expectedInput);
@@ -138,7 +147,7 @@ namespace CliFx.Tests
new object[]
{
new[] {"-o"},
new CommandLineInputBuilder()
new CommandInputBuilder()
.AddOption("o")
.Build()
},
@@ -146,7 +155,7 @@ namespace CliFx.Tests
new object[]
{
new[] {"-o", "value"},
new CommandLineInputBuilder()
new CommandInputBuilder()
.AddOption("o", "value")
.Build()
},
@@ -154,7 +163,7 @@ namespace CliFx.Tests
new object[]
{
new[] {"-o", "value1", "value2"},
new CommandLineInputBuilder()
new CommandInputBuilder()
.AddOption("o", "value1", "value2")
.Build()
},
@@ -162,7 +171,7 @@ namespace CliFx.Tests
new object[]
{
new[] {"-o", "same value"},
new CommandLineInputBuilder()
new CommandInputBuilder()
.AddOption("o", "same value")
.Build()
},
@@ -170,7 +179,7 @@ namespace CliFx.Tests
new object[]
{
new[] {"-a", "-b"},
new CommandLineInputBuilder()
new CommandInputBuilder()
.AddOption("a")
.AddOption("b")
.Build()
@@ -179,7 +188,7 @@ namespace CliFx.Tests
new object[]
{
new[] {"-a", "value1", "-b", "value2"},
new CommandLineInputBuilder()
new CommandInputBuilder()
.AddOption("a", "value1")
.AddOption("b", "value2")
.Build()
@@ -188,7 +197,7 @@ namespace CliFx.Tests
new object[]
{
new[] {"-a", "value1", "value2", "-b", "value3", "value4"},
new CommandLineInputBuilder()
new CommandInputBuilder()
.AddOption("a", "value1", "value2")
.AddOption("b", "value3", "value4")
.Build()
@@ -197,7 +206,7 @@ namespace CliFx.Tests
new object[]
{
new[] {"-a", "value1", "value2", "-b"},
new CommandLineInputBuilder()
new CommandInputBuilder()
.AddOption("a", "value1", "value2")
.AddOption("b")
.Build()
@@ -206,7 +215,7 @@ namespace CliFx.Tests
new object[]
{
new[] {"-abc"},
new CommandLineInputBuilder()
new CommandInputBuilder()
.AddOption("a")
.AddOption("b")
.AddOption("c")
@@ -216,7 +225,7 @@ namespace CliFx.Tests
new object[]
{
new[] {"-abc", "value"},
new CommandLineInputBuilder()
new CommandInputBuilder()
.AddOption("a")
.AddOption("b")
.AddOption("c", "value")
@@ -226,7 +235,7 @@ namespace CliFx.Tests
new object[]
{
new[] {"-abc", "value1", "value2"},
new CommandLineInputBuilder()
new CommandInputBuilder()
.AddOption("a")
.AddOption("b")
.AddOption("c", "value1", "value2")
@@ -236,48 +245,51 @@ namespace CliFx.Tests
[Theory]
[MemberData(nameof(ShortOptionsTestData))]
internal void Option_can_be_set_by_specifying_its_short_name_after_a_single_dash(string[] arguments, CommandLineInput expectedInput)
internal void Option_can_be_set_by_specifying_its_short_name_after_a_single_dash(IReadOnlyList<string> arguments, CommandInput expectedInput)
{
// Arrange
var commandNames = Array.Empty<string>();
// Act
var input = CommandLineInput.Parse(arguments);
var input = CommandInput.Parse(arguments, commandNames);
// Assert
input.Should().BeEquivalentTo(expectedInput);
}
public static object[][] UnboundArgumentsTestData => new[]
public static object[][] ParametersTestData => new[]
{
new object[]
{
new[] {"foo"},
new CommandLineInputBuilder()
.AddUnboundArgument("foo")
new CommandInputBuilder()
.AddParameter("foo")
.Build()
},
new object[]
{
new[] {"foo", "bar"},
new CommandLineInputBuilder()
.AddUnboundArgument("foo")
.AddUnboundArgument("bar")
new CommandInputBuilder()
.AddParameter("foo")
.AddParameter("bar")
.Build()
},
new object[]
{
new[] {"[preview]", "foo"},
new CommandLineInputBuilder()
new CommandInputBuilder()
.AddDirective("preview")
.AddUnboundArgument("foo")
.AddParameter("foo")
.Build()
},
new object[]
{
new[] {"foo", "--option", "value", "-abc"},
new CommandLineInputBuilder()
.AddUnboundArgument("foo")
new CommandInputBuilder()
.AddParameter("foo")
.AddOption("option", "value")
.AddOption("a")
.AddOption("b")
@@ -288,11 +300,11 @@ namespace CliFx.Tests
new object[]
{
new[] {"[preview]", "[debug]", "foo", "bar", "--option", "value", "-abc"},
new CommandLineInputBuilder()
new CommandInputBuilder()
.AddDirective("preview")
.AddDirective("debug")
.AddUnboundArgument("foo")
.AddUnboundArgument("bar")
.AddParameter("foo")
.AddParameter("bar")
.AddOption("option", "value")
.AddOption("a")
.AddOption("b")
@@ -302,11 +314,62 @@ namespace CliFx.Tests
};
[Theory]
[MemberData(nameof(UnboundArgumentsTestData))]
internal void Any_remaining_arguments_are_treated_as_unbound_arguments(string[] arguments, CommandLineInput expectedInput)
[MemberData(nameof(ParametersTestData))]
internal void Parameter_can_be_set_by_specifying_the_value_directly(IReadOnlyList<string> arguments, CommandInput expectedInput)
{
// Arrange
var commandNames = Array.Empty<string>();
// Act
var input = CommandInput.Parse(arguments, commandNames);
// Assert
input.Should().BeEquivalentTo(expectedInput);
}
public static object[][] CommandNameTestData => new[]
{
new object[]
{
new[] {"cmd"},
new[] {"cmd"},
new CommandInputBuilder()
.SetCommandName("cmd")
.Build()
},
new object[]
{
new[] {"cmd"},
new[] {"cmd", "foo", "bar", "-o", "value"},
new CommandInputBuilder()
.SetCommandName("cmd")
.AddParameter("foo")
.AddParameter("bar")
.AddOption("o", "value")
.Build()
},
new object[]
{
new[] {"cmd", "cmd sub"},
new[] {"cmd", "sub", "foo"},
new CommandInputBuilder()
.SetCommandName("cmd sub")
.AddParameter("foo")
.Build()
}
};
[Theory]
[MemberData(nameof(CommandNameTestData))]
internal void Command_name_is_matched_from_arguments_that_come_before_parameters(
IReadOnlyList<string> commandNames,
IReadOnlyList<string> arguments,
CommandInput expectedInput)
{
// Act
var input = CommandLineInput.Parse(arguments);
var input = CommandInput.Parse(arguments, commandNames);
// Assert
input.Should().BeEquivalentTo(expectedInput);

View File

@@ -13,6 +13,8 @@ namespace CliFx.Tests
[Fact]
public async Task Command_can_perform_additional_cleanup_if_cancellation_is_requested()
{
// Can't test it with a real console because CliWrap can't send Ctrl+C
// Arrange
using var cts = new CancellationTokenSource();

View File

@@ -1,6 +1,12 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using CliFx.Tests.Internal;
using CliWrap;
using CliWrap.Buffered;
using FluentAssertions;
using Xunit;
@@ -9,7 +15,31 @@ namespace CliFx.Tests
public partial class DirectivesSpecs
{
[Fact]
public async Task Preview_directive_can_be_enabled_to_print_provided_arguments_as_they_were_parsed()
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]
public async Task Preview_directive_can_be_specified_to_print_provided_arguments_as_they_were_parsed()
{
// Arrange
await using var stdOut = new MemoryStream();
@@ -30,7 +60,7 @@ namespace CliFx.Tests
// Assert
exitCode.Should().Be(0);
stdOutData.Should().ContainAll("cmd", "<param>", "[-a]", "[-b]", "[-c]", "[--option foo]");
stdOutData.Should().ContainAll("cmd", "<param>", "[-a]", "[-b]", "[-c]", "[--option \"foo\"]");
}
}
}

View File

@@ -2,6 +2,7 @@
using System.IO;
using System.Threading.Tasks;
using CliFx.Domain;
using CliFx.Tests.Internal;
using CliWrap;
using CliWrap.Buffered;
using FluentAssertions;
@@ -53,19 +54,17 @@ namespace CliFx.Tests
public void Option_of_non_scalar_type_can_take_multiple_separated_values_from_an_environment_variable()
{
// Arrange
var schema = ApplicationSchema.Resolve(new[] {typeof(EnvironmentVariableCollectionCommand)});
var input = CommandLineInput.Empty;
var input = CommandInput.Empty;
var envVars = new Dictionary<string, string>
{
["ENV_OPT"] = $"foo{Path.PathSeparator}bar"
};
// Act
var command = schema.InitializeEntryPoint(input, envVars);
var instance = CommandHelper.ResolveCommand<EnvironmentVariableCollectionCommand>(input, envVars);
// Assert
command.Should().BeEquivalentTo(new EnvironmentVariableCollectionCommand
instance.Should().BeEquivalentTo(new EnvironmentVariableCollectionCommand
{
Option = new[] {"foo", "bar"}
});
@@ -75,19 +74,17 @@ namespace CliFx.Tests
public void Option_of_scalar_type_can_only_take_a_single_value_from_an_environment_variable_even_if_it_contains_separators()
{
// Arrange
var schema = ApplicationSchema.Resolve(new[] {typeof(EnvironmentVariableCommand)});
var input = CommandLineInput.Empty;
var input = CommandInput.Empty;
var envVars = new Dictionary<string, string>
{
["ENV_OPT"] = $"foo{Path.PathSeparator}bar"
};
// Act
var command = schema.InitializeEntryPoint(input, envVars);
var instance = CommandHelper.ResolveCommand<EnvironmentVariableCommand>(input, envVars);
// Assert
command.Should().BeEquivalentTo(new EnvironmentVariableCommand
instance.Should().BeEquivalentTo(new EnvironmentVariableCommand
{
Option = $"foo{Path.PathSeparator}bar"
});

View File

@@ -25,53 +25,10 @@ namespace CliFx.Tests
[CommandOption("msg", 'm')]
public string? Message { get; set; }
public ValueTask ExecuteAsync(IConsole console) => throw new CommandException(Message, ExitCode);
}
[CommandOption("show-help")]
public bool ShowHelp { get; set; }
[Command("exc")]
private class ShowHelpTextOnlyCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => throw new CommandException(null, showHelp: true);
}
[Command("exc sub")]
private class ShowHelpTextOnlySubCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("exc")]
private class ShowErrorMessageThenHelpTextCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) =>
throw new CommandException("Error message.", showHelp: true);
}
[Command("exc sub")]
private class ShowErrorMessageThenHelpTextSubCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("exc")]
private class StackTraceOnlyCommand : ICommand
{
[CommandOption("msg", 'm')]
public string? Message { get; set; }
public ValueTask ExecuteAsync(IConsole console) => throw new CommandException(null);
}
[Command("inv")]
private class InvalidUserInputCommand : ICommand
{
[CommandOption("required", 'r')]
public string? RequiredOption { get; }
public ValueTask ExecuteAsync(IConsole console)
{
throw new NotImplementedException();
}
public ValueTask ExecuteAsync(IConsole console) => throw new CommandException(Message, ExitCode, ShowHelp);
}
}
}

View File

@@ -82,104 +82,7 @@ namespace CliFx.Tests
// Act
var exitCode = await application.RunAsync(
new[] {"exc", "-m", "Kaput"},
new Dictionary<string, string>());
var stdErrData = console.Error.Encoding.GetString(stdErr.ToArray()).TrimEnd();
// Assert
exitCode.Should().NotBe(0);
stdErrData.Should().NotBeEmpty();
_output.WriteLine(stdErrData);
}
[Fact]
public async Task Command_may_throw_a_specialized_exception_which_shows_only_the_help_text()
{
// Arrange
await using var stdOut = new MemoryStream();
await using var stdErr = new MemoryStream();
var console = new VirtualConsole(output: stdOut);
var application = new CliApplicationBuilder()
.AddCommand(typeof(ShowHelpTextOnlyCommand))
.AddCommand(typeof(ShowHelpTextOnlySubCommand))
.UseConsole(console)
.Build();
// Act
await application.RunAsync(new[] {"exc"});
var stdOutData = console.Output.Encoding.GetString(stdOut.ToArray()).TrimEnd();
var stdErrData = console.Output.Encoding.GetString(stdErr.ToArray()).TrimEnd();
// Assert
stdErrData.Should().BeEmpty();
stdOutData.Should().ContainAll(
"Usage",
"[command]",
"Options",
"-h|--help", "Shows help text.",
"Commands",
"sub",
"You can run", "to show help on a specific command."
);
_output.WriteLine(stdOutData);
_output.WriteLine(stdErrData);
}
[Fact]
public async Task Command_may_throw_specialized_exception_which_shows_the_error_message_then_the_help_text()
{
// Arrange
await using var stdOut = new MemoryStream();
await using var stdErr = new MemoryStream();
var console = new VirtualConsole(output: stdOut, error: stdErr);
var application = new CliApplicationBuilder()
.AddCommand(typeof(ShowErrorMessageThenHelpTextCommand))
.AddCommand(typeof(ShowErrorMessageThenHelpTextSubCommand))
.UseConsole(console)
.Build();
// Act
await application.RunAsync(new[] {"exc"});
var stdErrData = console.Error.Encoding.GetString(stdErr.ToArray()).TrimEnd();
var stdOutData = console.Output.Encoding.GetString(stdOut.ToArray()).TrimEnd();
// Assert
stdErrData.Should().Be("Error message.");
stdOutData.Should().ContainAll(
"Usage",
"[command]",
"Options",
"-h|--help", "Shows help text.",
"Commands",
"sub",
"You can run", "to show help on a specific command."
);
_output.WriteLine(stdOutData);
_output.WriteLine(stdErrData);
}
[Fact]
public async Task Command_may_throw_a_specialized_exception_which_shows_only_a_stack_trace_and_no_help_text()
{
// Arrange
await using var stdErr = new MemoryStream();
var console = new VirtualConsole(error: stdErr);
var application = new CliApplicationBuilder()
.AddCommand(typeof(GenericExceptionCommand))
.UseConsole(console)
.Build();
// Act
var exitCode = await application.RunAsync(
new[] {"exc", "-m", "Kaput"},
new[] {"exc"},
new Dictionary<string, string>());
var stdErrData = console.Error.Encoding.GetString(stdErr.ToArray()).TrimEnd();
@@ -187,15 +90,51 @@ namespace CliFx.Tests
// Assert
exitCode.Should().NotBe(0);
stdErrData.Should().ContainAll(
"System.Exception:",
"Kaput", "at",
"CliFx.Exceptions.CommandException:",
"at",
"CliFx.Tests");
_output.WriteLine(stdErrData);
}
[Fact]
public async Task Command_shows_help_text_on_exceptions_related_to_invalid_user_input()
public async Task Command_may_throw_a_specialized_exception_which_exits_and_prints_help_text()
{
// Arrange
await using var stdOut = new MemoryStream();
await using var stdErr = new MemoryStream();
var console = new VirtualConsole(output: stdOut, error: stdErr);
var application = new CliApplicationBuilder()
.AddCommand(typeof(CommandExceptionCommand))
.UseConsole(console)
.Build();
// Act
var exitCode = await application.RunAsync(
new[] {"exc", "-m", "Kaput", "--show-help"},
new Dictionary<string, string>());
var stdErrData = console.Error.Encoding.GetString(stdErr.ToArray()).TrimEnd();
var stdOutData = console.Output.Encoding.GetString(stdOut.ToArray()).TrimEnd();
// Assert
exitCode.Should().NotBe(0);
stdErrData.Should().Be("Kaput");
stdOutData.Should().ContainAll(
"Usage",
"Options",
"-h|--help", "Shows help text."
);
_output.WriteLine(stdErrData);
_output.WriteLine(stdOutData);
}
[Fact]
public async Task Command_shows_help_text_on_invalid_user_input()
{
// Arrange
await using var stdOut = new MemoryStream();
@@ -203,7 +142,7 @@ namespace CliFx.Tests
var console = new VirtualConsole(output: stdOut, error: stdErr);
var application = new CliApplicationBuilder()
.AddCommand(typeof(InvalidUserInputCommand))
.AddCommand(typeof(CommandExceptionCommand))
.UseConsole(console)
.Build();
@@ -217,22 +156,17 @@ namespace CliFx.Tests
// Assert
exitCode.Should().NotBe(0);
stdErrData.Should().ContainAll(
"Can't find a command that matches the following arguments:",
"not-a-valid-command"
);
stdErrData.Should().NotBeNullOrWhiteSpace();
stdOutData.Should().ContainAll(
"Usage",
"[command]",
"Options",
"-h|--help", "Shows help text.",
"Commands",
"inv",
"You can run", "to show help on a specific command."
"-h|--help", "Shows help text."
);
_output.WriteLine(stdOutData);
_output.WriteLine(stdErrData);
_output.WriteLine(stdOutData);
}
}
}

View File

@@ -70,14 +70,14 @@ namespace CliFx.Tests
[Command("cmd-with-req-opts")]
private class RequiredOptionsCommand : ICommand
{
[CommandOption("option-f", 'f', IsRequired = true)]
public string? OptionF { get; set; }
[CommandOption("option-a", 'a', IsRequired = true)]
public string? OptionA { get; set; }
[CommandOption("option-g", 'g', IsRequired = true)]
public IEnumerable<int>? OptionG { get; set; }
[CommandOption("option-b", 'b', IsRequired = true)]
public IEnumerable<int>? OptionB { get; set; }
[CommandOption("option-h", 'h')]
public string? OptionH { get; set; }
[CommandOption("option-c", 'c')]
public string? OptionC { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}

View File

@@ -230,18 +230,18 @@ namespace CliFx.Tests
// Assert
stdOutData.Should().ContainAll(
"Usage",
"cmd-with-req-opts", "--option-f <value>", "--option-g <values...>", "[options]",
"cmd-with-req-opts", "--option-a <value>", "--option-b <values...>", "[options]",
"Options",
"* -f|--option-f",
"* -g|--option-g",
"-h|--option-h"
"* -a|--option-a",
"* -b|--option-b",
"-c|--option-c"
);
_output.WriteLine(stdOutData);
}
[Fact]
public async Task Help_text_shows_usage_format_which_lists_all_valid_values_for_enum_arguments()
public async Task Help_text_lists_all_valid_values_for_enum_arguments()
{
// Arrange
await using var stdOut = new MemoryStream();
@@ -253,7 +253,7 @@ namespace CliFx.Tests
.Build();
// Act
await application.RunAsync(new[] { "cmd-with-enum-args", "--help" });
await application.RunAsync(new[] {"cmd-with-enum-args", "--help"});
var stdOutData = console.Output.Encoding.GetString(stdOut.ToArray()).TrimEnd();
// Assert
@@ -261,10 +261,10 @@ namespace CliFx.Tests
"Usage",
"cmd-with-enum-args", "[options]",
"Parameters",
"value", "Valid values: Value1, Value2, Value3.",
"value", "Valid values: \"Value1\", \"Value2\", \"Value3\".",
"Options",
"* --value", "Enum option.", "Valid values: Value1, Value2, Value3.",
"--nullable-value", "Nullable enum option.", "Valid values: Value1, Value2, Value3."
"* --value", "Enum option.", "Valid values: \"Value1\", \"Value2\", \"Value3\".",
"--nullable-value", "Nullable enum option.", "Valid values: \"Value1\", \"Value2\", \"Value3\"."
);
_output.WriteLine(stdOutData);
@@ -297,7 +297,7 @@ namespace CliFx.Tests
}
[Fact]
public async Task Help_text_shows_usage_format_which_lists_all_default_values_for_non_required_options()
public async Task Help_text_shows_default_values_for_non_required_options()
{
// Arrange
await using var stdOut = new MemoryStream();
@@ -309,7 +309,7 @@ namespace CliFx.Tests
.Build();
// Act
await application.RunAsync(new[] { "cmd-with-defaults", "--help" });
await application.RunAsync(new[] {"cmd-with-defaults", "--help"});
var stdOutData = console.Output.Encoding.GetString(stdOut.ToArray()).TrimEnd();
// Assert

View File

@@ -0,0 +1,23 @@
using System.Collections.Generic;
using CliFx.Domain;
namespace CliFx.Tests.Internal
{
internal static class CommandHelper
{
public static TCommand ResolveCommand<TCommand>(CommandInput input, IReadOnlyDictionary<string, string> environmentVariables)
where TCommand : ICommand, new()
{
var schema = CommandSchema.TryResolve(typeof(TCommand))!;
var instance = new TCommand();
schema.Bind(instance, input, environmentVariables);
return instance;
}
public static TCommand ResolveCommand<TCommand>(CommandInput input)
where TCommand : ICommand, new() =>
ResolveCommand<TCommand>(input, new Dictionary<string, string>());
}
}

View File

@@ -0,0 +1,45 @@
using System.Collections.Generic;
using CliFx.Domain;
namespace CliFx.Tests.Internal
{
internal class CommandInputBuilder
{
private readonly List<CommandDirectiveInput> _directives = new List<CommandDirectiveInput>();
private readonly List<CommandParameterInput> _parameters = new List<CommandParameterInput>();
private readonly List<CommandOptionInput> _options = new List<CommandOptionInput>();
private string? _commandName;
public CommandInputBuilder SetCommandName(string commandName)
{
_commandName = commandName;
return this;
}
public CommandInputBuilder AddDirective(string directive)
{
_directives.Add(new CommandDirectiveInput(directive));
return this;
}
public CommandInputBuilder AddParameter(string parameter)
{
_parameters.Add(new CommandParameterInput(parameter));
return this;
}
public CommandInputBuilder AddOption(string alias, params string[] values)
{
_options.Add(new CommandOptionInput(alias, values));
return this;
}
public CommandInput Build() => new CommandInput(
_directives,
_commandName,
_parameters,
_options
);
}
}

View File

@@ -0,0 +1,19 @@
using System;
using System.Threading.Tasks;
namespace CliFx.Tests.Internal
{
internal static class TaskExtensions
{
public static async Task IgnoreCancellation(this Task task)
{
try
{
await task;
}
catch (OperationCanceledException)
{
}
}
}
}

View File

@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Domain;
using CliFx.Exceptions;
using CliFx.Internal;
@@ -13,7 +14,7 @@ namespace CliFx
/// <summary>
/// Command line application facade.
/// </summary>
public class CliApplication
public partial class CliApplication
{
private readonly ApplicationMetadata _metadata;
private readonly ApplicationConfiguration _configuration;
@@ -34,15 +35,14 @@ namespace CliFx
_console = console;
_typeActivator = typeActivator;
_helpTextWriter = new HelpTextWriter(metadata, console, typeActivator);
_helpTextWriter = new HelpTextWriter(metadata, console);
}
private async ValueTask<int?> HandleDebugDirectiveAsync(CommandLineInput commandLineInput)
{
var isDebugMode = _configuration.IsDebugModeAllowed && commandLineInput.IsDebugDirectiveSpecified;
if (!isDebugMode)
return null;
private void WriteError(string message) => _console.WithForegroundColor(ConsoleColor.Red, () =>
_console.Error.WriteLine(message));
private async ValueTask WaitForDebuggerAsync()
{
var processId = ProcessEx.GetCurrentProcessId();
_console.WithForegroundColor(ConsoleColor.Green, () =>
@@ -50,31 +50,21 @@ namespace CliFx
while (!Debugger.IsAttached)
await Task.Delay(100);
return null;
}
private int? HandlePreviewDirective(ApplicationSchema applicationSchema, CommandLineInput commandLineInput)
private void WriteCommandLineInput(CommandInput input)
{
var isPreviewMode = _configuration.IsPreviewModeAllowed && commandLineInput.IsPreviewDirectiveSpecified;
if (!isPreviewMode)
return null;
var commandSchema = applicationSchema.TryFindCommand(commandLineInput, out var argumentOffset);
_console.Output.WriteLine("Parser preview:");
// Command name
if (commandSchema != null && argumentOffset > 0)
if (!string.IsNullOrWhiteSpace(input.CommandName))
{
_console.WithForegroundColor(ConsoleColor.Cyan, () =>
_console.Output.Write(commandSchema.Name));
_console.Output.Write(input.CommandName));
_console.Output.Write(' ');
}
// Parameters
foreach (var parameter in commandLineInput.UnboundArguments.Skip(argumentOffset))
foreach (var parameter in input.Parameters)
{
_console.Output.Write('<');
@@ -86,118 +76,133 @@ namespace CliFx
}
// Options
foreach (var option in commandLineInput.Options)
foreach (var option in input.Options)
{
_console.Output.Write('[');
_console.WithForegroundColor(ConsoleColor.White, () =>
_console.Output.Write(option));
{
// Alias
_console.Output.Write(option.GetRawAlias());
// Values
if (option.Values.Any())
{
_console.Output.Write(' ');
_console.Output.Write(option.GetRawValues());
}
});
_console.Output.Write(']');
_console.Output.Write(' ');
}
_console.Output.WriteLine();
return 0;
}
private int? HandleVersionOption(CommandLineInput commandLineInput)
{
// Version option is available only on the default command (i.e. when arguments are not specified)
var shouldRenderVersion = !commandLineInput.UnboundArguments.Any() && commandLineInput.IsVersionOptionSpecified;
if (!shouldRenderVersion)
return null;
_console.Output.WriteLine(_metadata.VersionText);
return 0;
}
private int? HandleHelpOption(ApplicationSchema applicationSchema, CommandLineInput commandLineInput)
{
// Help is rendered either when it's requested or when the user provides no arguments and there is no default command
var shouldRenderHelp =
commandLineInput.IsHelpOptionSpecified ||
!applicationSchema.Commands.Any(c => c.IsDefault) && !commandLineInput.UnboundArguments.Any() && !commandLineInput.Options.Any();
if (!shouldRenderHelp)
return null;
// Get the command schema that matches the input or use a dummy default command as a fallback
var commandSchema =
applicationSchema.TryFindCommand(commandLineInput) ??
CommandSchema.StubDefaultCommand.Schema;
_helpTextWriter.Write(applicationSchema, commandSchema);
return 0;
}
private async ValueTask<int> HandleCommandExecutionAsync(
ApplicationSchema applicationSchema,
CommandLineInput commandLineInput,
IReadOnlyDictionary<string, string> environmentVariables)
{
await applicationSchema
.InitializeEntryPoint(commandLineInput, environmentVariables, _typeActivator)
.ExecuteAsync(_console);
return 0;
}
private int HandleCliFxException(IReadOnlyList<string> commandLineArguments, CliFxException ex)
{
var showHelp = ex.ShowHelp;
var errorMessage = ex.HasMessage
? ex.Message
: ex.ToString();
_console.WithForegroundColor(ConsoleColor.Red, () => _console.Error.WriteLine(errorMessage));
if (showHelp)
{
var applicationSchema = ApplicationSchema.Resolve(_configuration.CommandTypes);
var commandLineInput = CommandLineInput.Parse(commandLineArguments);
var commandSchema = applicationSchema.TryFindCommand(commandLineInput) ??
CommandSchema.StubDefaultCommand.Schema;
_helpTextWriter.Write(applicationSchema, commandSchema);
}
return ex.ExitCode;
}
private ICommand GetCommandInstance(CommandSchema command) =>
command != StubDefaultCommand.Schema
? (ICommand) _typeActivator.CreateInstance(command.Type)
: new StubDefaultCommand();
/// <summary>
/// Runs the application with specified command line arguments and environment variables, and returns the exit code.
/// </summary>
/// <remarks>
/// If a <see cref="CommandException"/> is thrown during command execution, it will be handled and routed to the console.
/// Additionally, if the debugger is not attached (i.e. the app is running in production), all other exceptions thrown within
/// this method will be handled and routed to the console as well.
/// </remarks>
public async ValueTask<int> RunAsync(
IReadOnlyList<string> commandLineArguments,
IReadOnlyDictionary<string, string> environmentVariables)
{
try
{
var applicationSchema = ApplicationSchema.Resolve(_configuration.CommandTypes);
var commandLineInput = CommandLineInput.Parse(commandLineArguments);
var root = RootSchema.Resolve(_configuration.CommandTypes);
var input = CommandInput.Parse(commandLineArguments, root.GetCommandNames());
return
await HandleDebugDirectiveAsync(commandLineInput) ??
HandlePreviewDirective(applicationSchema, commandLineInput) ??
HandleVersionOption(commandLineInput) ??
HandleHelpOption(applicationSchema, commandLineInput) ??
await HandleCommandExecutionAsync(applicationSchema, commandLineInput, environmentVariables);
// Debug mode
if (_configuration.IsDebugModeAllowed && input.IsDebugDirectiveSpecified)
{
// Ensure debugger is attached and continue
await WaitForDebuggerAsync();
}
// Preview mode
if (_configuration.IsPreviewModeAllowed && input.IsPreviewDirectiveSpecified)
{
WriteCommandLineInput(input);
return ExitCode.Success;
}
// Try to get the command matching the input or fallback to default
var command =
root.TryFindCommand(input.CommandName) ??
root.TryFindDefaultCommand() ??
StubDefaultCommand.Schema;
// Version option
if (command.IsVersionOptionAvailable && input.IsVersionOptionSpecified)
{
_console.Output.WriteLine(_metadata.VersionText);
return ExitCode.Success;
}
// Get command instance (also used in help text)
var instance = GetCommandInstance(command);
// To avoid instantiating the command twice, we need to get default values
// before the arguments are bound to the properties
var defaultValues = command.GetArgumentValues(instance);
// Help option
if (command.IsHelpOptionAvailable && input.IsHelpOptionSpecified ||
command == StubDefaultCommand.Schema && !input.Parameters.Any() && !input.Options.Any())
{
_helpTextWriter.Write(root, command, defaultValues);
return ExitCode.Success;
}
// Bind arguments
try
{
command.Bind(instance, input, environmentVariables);
}
// This may throw exceptions which are useful only to the end-user
catch (CliFxException ex)
{
WriteError(ex.ToString());
_helpTextWriter.Write(root, command, defaultValues);
return ExitCode.FromException(ex);
}
// Execute the command
try
{
await instance.ExecuteAsync(_console);
return ExitCode.Success;
}
// Swallow command exceptions and route them to the console
catch (CommandException ex)
{
WriteError(ex.ToString());
if (ex.ShowHelp)
_helpTextWriter.Write(root, command, defaultValues);
return ex.ExitCode;
}
}
catch (CliFxException ex)
// To prevent the app from showing the annoying Windows troubleshooting dialog,
// we handle all exceptions and route them to the console nicely.
// However, we don't want to swallow unhandled exceptions when the debugger is attached,
// because we still want the IDE to show them to the developer.
catch (Exception ex) when (!Debugger.IsAttached)
{
// Some exceptions may specify exit code or request help
return HandleCliFxException(commandLineArguments, ex);
}
catch (Exception ex)
{
// We want to catch all exceptions in order to print errors and return correct exit codes.
// Doing this also gets rid of the annoying Windows troubleshooting dialog that shows up on unhandled exceptions.
_console.WithForegroundColor(ConsoleColor.Red, () => _console.Error.WriteLine(ex.ToString()));
return ex.HResult;
WriteError(ex.ToString());
return ExitCode.FromException(ex);
}
}
@@ -205,6 +210,11 @@ namespace CliFx
/// Runs the application with specified command line arguments and returns the exit code.
/// Environment variables are retrieved automatically.
/// </summary>
/// <remarks>
/// If a <see cref="CommandException"/> is thrown during command execution, it will be handled and routed to the console.
/// Additionally, if the debugger is not attached (i.e. the app is running in production), all other exceptions thrown within
/// this method will be handled and routed to the console as well.
/// </remarks>
public async ValueTask<int> RunAsync(IReadOnlyList<string> commandLineArguments)
{
var environmentVariables = Environment.GetEnvironmentVariables()
@@ -218,6 +228,11 @@ namespace CliFx
/// Runs the application and returns the exit code.
/// Command line arguments and environment variables are retrieved automatically.
/// </summary>
/// <remarks>
/// If a <see cref="CommandException"/> is thrown during command execution, it will be handled and routed to the console.
/// Additionally, if the debugger is not attached (i.e. the app is running in production), all other exceptions thrown within
/// this method will be handled and routed to the console as well.
/// </remarks>
public async ValueTask<int> RunAsync()
{
var commandLineArguments = Environment.GetCommandLineArgs()
@@ -227,4 +242,25 @@ namespace CliFx
return await RunAsync(commandLineArguments);
}
}
public partial class CliApplication
{
private static class ExitCode
{
public const int Success = 0;
public static int FromException(Exception ex) =>
ex is CommandException cmdEx
? cmdEx.ExitCode
: ex.HResult;
}
[Command]
private class StubDefaultCommand : ICommand
{
public static CommandSchema Schema { get; } = CommandSchema.TryResolve(typeof(StubDefaultCommand))!;
public ValueTask ExecuteAsync(IConsole console) => default;
}
}
}

View File

@@ -159,9 +159,9 @@ namespace CliFx
/// </summary>
public CliApplication Build()
{
_title ??= GetDefaultTitle() ?? "App";
_executableName ??= GetDefaultExecutableName() ?? "app";
_versionText ??= GetDefaultVersionText() ?? "v1.0";
_title ??= TryGetDefaultTitle() ?? "App";
_executableName ??= TryGetDefaultExecutableName() ?? "app";
_versionText ??= TryGetDefaultVersionText() ?? "v1.0";
_console ??= new SystemConsole();
_typeActivator ??= new DefaultTypeActivator();
@@ -179,9 +179,9 @@ namespace CliFx
// Entry assembly is null in tests
private static Assembly? EntryAssembly => LazyEntryAssembly.Value;
private static string? GetDefaultTitle() => EntryAssembly?.GetName().Name;
private static string? TryGetDefaultTitle() => EntryAssembly?.GetName().Name;
private static string? GetDefaultExecutableName()
private static string? TryGetDefaultExecutableName()
{
var entryAssemblyLocation = EntryAssembly?.Location;
@@ -193,7 +193,7 @@ namespace CliFx
: Path.GetFileNameWithoutExtension(entryAssemblyLocation);
}
private static string? GetDefaultVersionText() =>
private static string? TryGetDefaultVersionText() =>
EntryAssembly != null
? $"v{EntryAssembly.GetName().Version.ToSemanticString()}"
: null;

View File

@@ -1,5 +1,4 @@
using System;
using System.Text;
using CliFx.Exceptions;
namespace CliFx

View File

@@ -1,5 +1,4 @@
using System;
using System.Text;
using CliFx.Exceptions;
namespace CliFx

View File

@@ -1,252 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CliFx.Exceptions;
namespace CliFx.Domain
{
internal partial class ApplicationSchema
{
public IReadOnlyList<CommandSchema> Commands { get; }
public ApplicationSchema(IReadOnlyList<CommandSchema> commands)
{
Commands = commands;
}
public CommandSchema? TryFindParentCommand(string? childCommandName)
{
// Default command has no parent
if (string.IsNullOrWhiteSpace(childCommandName))
return null;
// Try to find the parent command by repeatedly biting off chunks of its name
var route = childCommandName.Split(' ');
for (var i = route.Length - 1; i >= 1; i--)
{
var potentialParentCommandName = string.Join(" ", route.Take(i));
var matchingParentCommand = Commands.FirstOrDefault(c => c.MatchesName(potentialParentCommandName));
if (matchingParentCommand != null)
return matchingParentCommand;
}
// If there's no parent - fall back to default command
return Commands.FirstOrDefault(c => c.IsDefault);
}
public IReadOnlyList<CommandSchema> GetChildCommands(string? parentCommandName) =>
!string.IsNullOrWhiteSpace(parentCommandName) || Commands.Any(c => c.IsDefault)
? Commands.Where(c => TryFindParentCommand(c.Name)?.MatchesName(parentCommandName) == true).ToArray()
: Commands.Where(c => !string.IsNullOrWhiteSpace(c.Name) && TryFindParentCommand(c.Name) == null).ToArray();
// TODO: this out parameter is not a really nice design
public CommandSchema? TryFindCommand(CommandLineInput commandLineInput, out int argumentOffset)
{
// Try to find the command that contains the most of the input arguments in its name
for (var i = commandLineInput.UnboundArguments.Count; i >= 0; i--)
{
var potentialCommandName = string.Join(" ", commandLineInput.UnboundArguments.Take(i));
var matchingCommand = Commands.FirstOrDefault(c => c.MatchesName(potentialCommandName));
if (matchingCommand != null)
{
argumentOffset = i;
return matchingCommand;
}
}
argumentOffset = 0;
return Commands.FirstOrDefault(c => c.IsDefault);
}
public CommandSchema? TryFindCommand(CommandLineInput commandLineInput) =>
TryFindCommand(commandLineInput, out _);
public ICommand InitializeEntryPoint(
CommandLineInput commandLineInput,
IReadOnlyDictionary<string, string> environmentVariables,
ITypeActivator activator)
{
var command = TryFindCommand(commandLineInput, out var argumentOffset) ??
throw CliFxException.CannotFindMatchingCommand(commandLineInput);
var parameterInputs = argumentOffset == 0
? commandLineInput.UnboundArguments.ToArray()
: commandLineInput.UnboundArguments.Skip(argumentOffset).ToArray();
return command.CreateInstance(parameterInputs, commandLineInput.Options, environmentVariables, activator);
}
public ICommand InitializeEntryPoint(
CommandLineInput commandLineInput,
IReadOnlyDictionary<string, string> environmentVariables) =>
InitializeEntryPoint(commandLineInput, environmentVariables, new DefaultTypeActivator());
public ICommand InitializeEntryPoint(CommandLineInput commandLineInput) =>
InitializeEntryPoint(commandLineInput, new Dictionary<string, string>());
public override string ToString() => string.Join(Environment.NewLine, Commands);
}
internal partial class ApplicationSchema
{
private static void ValidateParameters(CommandSchema command)
{
var duplicateOrderGroup = command.Parameters
.GroupBy(a => a.Order)
.FirstOrDefault(g => g.Count() > 1);
if (duplicateOrderGroup != null)
{
throw CliFxException.CommandParametersDuplicateOrder(
command,
duplicateOrderGroup.Key,
duplicateOrderGroup.ToArray());
}
var duplicateNameGroup = command.Parameters
.Where(a => !string.IsNullOrWhiteSpace(a.Name))
.GroupBy(a => a.Name!, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(g => g.Count() > 1);
if (duplicateNameGroup != null)
{
throw CliFxException.CommandParametersDuplicateName(
command,
duplicateNameGroup.Key,
duplicateNameGroup.ToArray());
}
var nonScalarParameters = command.Parameters
.Where(p => !p.IsScalar)
.ToArray();
if (nonScalarParameters.Length > 1)
{
throw CliFxException.CommandParametersTooManyNonScalar(
command,
nonScalarParameters);
}
var nonLastNonScalarParameter = command.Parameters
.OrderByDescending(a => a.Order)
.Skip(1)
.LastOrDefault(p => !p.IsScalar);
if (nonLastNonScalarParameter != null)
{
throw CliFxException.CommandParametersNonLastNonScalar(
command,
nonLastNonScalarParameter);
}
}
private static void ValidateOptions(CommandSchema command)
{
var noNameGroup = command.Options
.Where(o => o.ShortName == null && string.IsNullOrWhiteSpace(o.Name))
.ToArray();
if (noNameGroup.Any())
{
throw CliFxException.CommandOptionsNoName(
command,
noNameGroup.ToArray());
}
var invalidLengthNameGroup = command.Options
.Where(o => !string.IsNullOrWhiteSpace(o.Name))
.Where(o => o.Name!.Length <= 1)
.ToArray();
if (invalidLengthNameGroup.Any())
{
throw CliFxException.CommandOptionsInvalidLengthName(
command,
invalidLengthNameGroup);
}
var duplicateNameGroup = command.Options
.Where(o => !string.IsNullOrWhiteSpace(o.Name))
.GroupBy(o => o.Name!, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(g => g.Count() > 1);
if (duplicateNameGroup != null)
{
throw CliFxException.CommandOptionsDuplicateName(
command,
duplicateNameGroup.Key,
duplicateNameGroup.ToArray());
}
var duplicateShortNameGroup = command.Options
.Where(o => o.ShortName != null)
.GroupBy(o => o.ShortName!.Value)
.FirstOrDefault(g => g.Count() > 1);
if (duplicateShortNameGroup != null)
{
throw CliFxException.CommandOptionsDuplicateShortName(
command,
duplicateShortNameGroup.Key,
duplicateShortNameGroup.ToArray());
}
var duplicateEnvironmentVariableNameGroup = command.Options
.Where(o => !string.IsNullOrWhiteSpace(o.EnvironmentVariableName))
.GroupBy(o => o.EnvironmentVariableName!, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(g => g.Count() > 1);
if (duplicateEnvironmentVariableNameGroup != null)
{
throw CliFxException.CommandOptionsDuplicateEnvironmentVariableName(
command,
duplicateEnvironmentVariableNameGroup.Key,
duplicateEnvironmentVariableNameGroup.ToArray());
}
}
private static void ValidateCommands(IReadOnlyList<CommandSchema> commands)
{
if (!commands.Any())
{
throw CliFxException.CommandsNotRegistered();
}
var duplicateNameGroup = commands
.GroupBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(g => g.Count() > 1);
if (duplicateNameGroup != null)
{
if (!string.IsNullOrWhiteSpace(duplicateNameGroup.Key))
throw CliFxException.CommandsDuplicateName(
duplicateNameGroup.Key,
duplicateNameGroup.ToArray());
throw CliFxException.CommandsTooManyDefaults(duplicateNameGroup.ToArray());
}
}
public static ApplicationSchema Resolve(IReadOnlyList<Type> commandTypes)
{
var commands = new List<CommandSchema>();
foreach (var commandType in commandTypes)
{
var command = CommandSchema.TryResolve(commandType) ??
throw CliFxException.InvalidCommandType(commandType);
ValidateParameters(command);
ValidateOptions(command);
commands.Add(command);
}
ValidateCommands(commands);
return new ApplicationSchema(commands);
}
}
}

View File

@@ -1,5 +1,4 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -11,20 +10,21 @@ namespace CliFx.Domain
{
internal abstract partial class CommandArgumentSchema
{
public PropertyInfo Property { get; }
// Property can be null on built-in arguments (help and version options)
public PropertyInfo? Property { get; }
public string? Description { get; }
public bool IsScalar => TryGetEnumerableArgumentUnderlyingType() == null;
protected CommandArgumentSchema(PropertyInfo property, string? description)
protected CommandArgumentSchema(PropertyInfo? property, string? description)
{
Property = property;
Description = description;
}
private Type? TryGetEnumerableArgumentUnderlyingType() =>
Property.PropertyType != typeof(string)
Property != null && Property.PropertyType != typeof(string)
? Property.PropertyType.GetEnumerableUnderlyingType()
: null;
@@ -93,6 +93,10 @@ namespace CliFx.Domain
private object? Convert(IReadOnlyList<string> values)
{
// Short-circuit built-in arguments
if (Property == null)
return null;
var targetType = Property.PropertyType;
var enumerableUnderlyingType = TryGetEnumerableArgumentUnderlyingType();
@@ -110,61 +114,26 @@ namespace CliFx.Domain
}
}
public void Inject(ICommand command, IReadOnlyList<string> values) =>
Property.SetValue(command, Convert(values));
public void BindOn(ICommand command, IReadOnlyList<string> values) =>
Property?.SetValue(command, Convert(values));
public void Inject(ICommand command, params string[] values) =>
Inject(command, (IReadOnlyList<string>) values);
public void BindOn(ICommand command, params string[] values) =>
BindOn(command, (IReadOnlyList<string>) values);
public IReadOnlyList<string> GetValidValues()
{
var result = new List<string>();
// Some arguments may have this as null due to a hack that enables built-in options
// TODO fix this
if (Property == null)
return result;
return Array.Empty<string>();
var underlyingType =
Property.PropertyType.GetNullableUnderlyingType() ?? Property.PropertyType;
Property.PropertyType.GetNullableUnderlyingType() ??
Property.PropertyType;
// Enum
if (underlyingType.IsEnum)
result.AddRange(Enum.GetNames(underlyingType));
return Enum.GetNames(underlyingType);
return result;
}
public string? TryGetDefaultValue(ICommand instance)
{
// Some arguments may have this as null due to a hack that enables built-in options
// TODO fix this
if (Property == null)
return null;
var rawDefaultValue = Property.GetValue(instance);
if (!(rawDefaultValue is string) && rawDefaultValue is IEnumerable rawDefaultValues)
{
var elementType = rawDefaultValues.GetType().GetEnumerableUnderlyingType() ?? typeof(object);
return elementType.IsToStringOverriden()
? rawDefaultValues
.Cast<object?>()
.Where(o => o != null)
.Select(o => o!.ToFormattableString(FormatProvider).Quote())
.JoinToString(" ")
: null;
}
if (rawDefaultValue != null && !Equals(rawDefaultValue, rawDefaultValue.GetType().GetDefaultValue()))
{
return rawDefaultValue.GetType().IsToStringOverriden()
? rawDefaultValue.ToFormattableString(FormatProvider).Quote()
: null;
}
return null;
return Array.Empty<string>();
}
}

View File

@@ -0,0 +1,238 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using CliFx.Internal;
namespace CliFx.Domain
{
internal partial class CommandInput
{
public IReadOnlyList<CommandDirectiveInput> Directives { get; }
public string? CommandName { get; }
public IReadOnlyList<CommandParameterInput> Parameters { get; }
public IReadOnlyList<CommandOptionInput> Options { get; }
public bool IsDebugDirectiveSpecified => Directives.Any(d => d.IsDebugDirective);
public bool IsPreviewDirectiveSpecified => Directives.Any(d => d.IsPreviewDirective);
public bool IsHelpOptionSpecified => Options.Any(o => o.IsHelpOption);
public bool IsVersionOptionSpecified => Options.Any(o => o.IsVersionOption);
public CommandInput(
IReadOnlyList<CommandDirectiveInput> directives,
string? commandName,
IReadOnlyList<CommandParameterInput> parameters,
IReadOnlyList<CommandOptionInput> options)
{
Directives = directives;
CommandName = commandName;
Parameters = parameters;
Options = options;
}
public override string ToString()
{
var buffer = new StringBuilder();
foreach (var directive in Directives)
{
buffer
.AppendIfNotEmpty(' ')
.Append(directive);
}
if (!string.IsNullOrWhiteSpace(CommandName))
{
buffer
.AppendIfNotEmpty(' ')
.Append(CommandName);
}
foreach (var parameter in Parameters)
{
buffer
.AppendIfNotEmpty(' ')
.Append(parameter);
}
foreach (var option in Options)
{
buffer
.AppendIfNotEmpty(' ')
.Append(option);
}
return buffer.ToString();
}
}
internal partial class CommandInput
{
private static IReadOnlyList<CommandDirectiveInput> ParseDirectives(
IReadOnlyList<string> commandLineArguments,
ref int index)
{
var result = new List<CommandDirectiveInput>();
for (; index < commandLineArguments.Count; index++)
{
var argument = commandLineArguments[index];
if (!argument.StartsWith('[') || !argument.EndsWith(']'))
break;
var name = argument.Substring(1, argument.Length - 2);
result.Add(new CommandDirectiveInput(name));
}
return result;
}
private static string? ParseCommandName(
IReadOnlyList<string> commandLineArguments,
ISet<string> commandNames,
ref int index)
{
var buffer = new List<string>();
var commandName = default(string?);
var lastIndex = index;
// We need to look ahead to see if we can match as many consecutive arguments to a command name as possible
for (var i = index; i < commandLineArguments.Count; i++)
{
var argument = commandLineArguments[i];
buffer.Add(argument);
var potentialCommandName = buffer.JoinToString(" ");
if (commandNames.Contains(potentialCommandName))
{
commandName = potentialCommandName;
lastIndex = i;
}
}
// Update the index only if command name was found in the arguments
if (!string.IsNullOrWhiteSpace(commandName))
index = lastIndex + 1;
return commandName;
}
private static IReadOnlyList<CommandParameterInput> ParseParameters(
IReadOnlyList<string> commandLineArguments,
ref int index)
{
var result = new List<CommandParameterInput>();
for (; index < commandLineArguments.Count; index++)
{
var argument = commandLineArguments[index];
if (argument.StartsWith('-'))
break;
result.Add(new CommandParameterInput(argument));
}
return result;
}
private static IReadOnlyList<CommandOptionInput> ParseOptions(
IReadOnlyList<string> commandLineArguments,
ref int index)
{
var result = new List<CommandOptionInput>();
var currentOptionAlias = default(string?);
var currentOptionValues = new List<string>();
for (; index < commandLineArguments.Count; index++)
{
var argument = commandLineArguments[index];
// Name
if (argument.StartsWith("--", StringComparison.Ordinal))
{
// Flush previous
if (!string.IsNullOrWhiteSpace(currentOptionAlias))
result.Add(new CommandOptionInput(currentOptionAlias, currentOptionValues));
currentOptionAlias = argument.Substring(2);
currentOptionValues = new List<string>();
}
// Short name
else if (argument.StartsWith('-'))
{
foreach (var alias in argument.Substring(1))
{
// Flush previous
if (!string.IsNullOrWhiteSpace(currentOptionAlias))
result.Add(new CommandOptionInput(currentOptionAlias, currentOptionValues));
currentOptionAlias = alias.AsString();
currentOptionValues = new List<string>();
}
}
// Value
else if (!string.IsNullOrWhiteSpace(currentOptionAlias))
{
currentOptionValues.Add(argument);
}
}
// Flush last option
if (!string.IsNullOrWhiteSpace(currentOptionAlias))
result.Add(new CommandOptionInput(currentOptionAlias, currentOptionValues));
return result;
}
public static CommandInput Parse(IReadOnlyList<string> commandLineArguments, IReadOnlyList<string> availableCommandNames)
{
var availableCommandNamesSet = availableCommandNames.ToHashSet(StringComparer.OrdinalIgnoreCase);
var index = 0;
var directives = ParseDirectives(
commandLineArguments,
ref index
);
var commandName = ParseCommandName(
commandLineArguments,
availableCommandNamesSet,
ref index
);
var parameters = ParseParameters(
commandLineArguments,
ref index
);
var options = ParseOptions(
commandLineArguments,
ref index
);
return new CommandInput(directives, commandName, parameters, options);
}
}
internal partial class CommandInput
{
public static CommandInput Empty { get; } = new CommandInput(
Array.Empty<CommandDirectiveInput>(),
null,
Array.Empty<CommandParameterInput>(),
Array.Empty<CommandOptionInput>()
);
}
}

View File

@@ -1,163 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using CliFx.Internal;
namespace CliFx.Domain
{
internal partial class CommandLineInput
{
public IReadOnlyList<CommandDirectiveInput> Directives { get; }
public IReadOnlyList<CommandUnboundArgumentInput> UnboundArguments { get; }
public IReadOnlyList<CommandOptionInput> Options { get; }
public bool IsDebugDirectiveSpecified => Directives.Any(d => d.IsDebugDirective);
public bool IsPreviewDirectiveSpecified => Directives.Any(d => d.IsPreviewDirective);
public bool IsHelpOptionSpecified => Options.Any(o => o.IsHelpOption);
public bool IsVersionOptionSpecified => Options.Any(o => o.IsVersionOption);
public CommandLineInput(
IReadOnlyList<CommandDirectiveInput> directives,
IReadOnlyList<CommandUnboundArgumentInput> unboundArguments,
IReadOnlyList<CommandOptionInput> options)
{
Directives = directives;
UnboundArguments = unboundArguments;
Options = options;
}
public override string ToString()
{
var buffer = new StringBuilder();
foreach (var directive in Directives)
{
buffer.AppendIfNotEmpty(' ');
buffer.Append(directive);
}
foreach (var argument in UnboundArguments)
{
buffer.AppendIfNotEmpty(' ');
buffer.Append(argument);
}
foreach (var option in Options)
{
buffer.AppendIfNotEmpty(' ');
buffer.Append(option);
}
return buffer.ToString();
}
}
internal partial class CommandLineInput
{
public static CommandLineInput Parse(IReadOnlyList<string> commandLineArguments)
{
var builder = new CommandLineInputBuilder();
var currentOptionAlias = "";
var currentOptionValues = new List<string>();
bool TryParseDirective(string argument)
{
if (!string.IsNullOrWhiteSpace(currentOptionAlias))
return false;
if (!argument.StartsWith("[", StringComparison.OrdinalIgnoreCase) ||
!argument.EndsWith("]", StringComparison.OrdinalIgnoreCase))
return false;
var directive = argument.Substring(1, argument.Length - 2);
builder.AddDirective(directive);
return true;
}
bool TryParseArgument(string argument)
{
if (!string.IsNullOrWhiteSpace(currentOptionAlias))
return false;
builder.AddUnboundArgument(argument);
return true;
}
bool TryParseOptionName(string argument)
{
if (!argument.StartsWith("--", StringComparison.OrdinalIgnoreCase))
return false;
if (!string.IsNullOrWhiteSpace(currentOptionAlias))
builder.AddOption(currentOptionAlias, currentOptionValues);
currentOptionAlias = argument.Substring(2);
currentOptionValues = new List<string>();
return true;
}
bool TryParseOptionShortName(string argument)
{
if (!argument.StartsWith("-", StringComparison.OrdinalIgnoreCase))
return false;
foreach (var c in argument.Substring(1))
{
if (!string.IsNullOrWhiteSpace(currentOptionAlias))
builder.AddOption(currentOptionAlias, currentOptionValues);
currentOptionAlias = c.AsString();
currentOptionValues = new List<string>();
}
return true;
}
bool TryParseOptionValue(string argument)
{
if (string.IsNullOrWhiteSpace(currentOptionAlias))
return false;
currentOptionValues.Add(argument);
return true;
}
foreach (var argument in commandLineArguments)
{
var _ =
TryParseOptionName(argument) ||
TryParseOptionShortName(argument) ||
TryParseDirective(argument) ||
TryParseArgument(argument) ||
TryParseOptionValue(argument);
}
if (!string.IsNullOrWhiteSpace(currentOptionAlias))
builder.AddOption(currentOptionAlias, currentOptionValues);
return builder.Build();
}
}
internal partial class CommandLineInput
{
private static IReadOnlyList<CommandDirectiveInput> EmptyDirectives { get; } = new CommandDirectiveInput[0];
private static IReadOnlyList<CommandUnboundArgumentInput> EmptyUnboundArguments { get; } = new CommandUnboundArgumentInput[0];
private static IReadOnlyList<CommandOptionInput> EmptyOptions { get; } = new CommandOptionInput[0];
public static CommandLineInput Empty { get; } = new CommandLineInput(EmptyDirectives, EmptyUnboundArguments, EmptyOptions);
}
}

View File

@@ -1,43 +0,0 @@
using System.Collections.Generic;
namespace CliFx.Domain
{
internal class CommandLineInputBuilder
{
private readonly List<CommandDirectiveInput> _directives = new List<CommandDirectiveInput>();
private readonly List<CommandUnboundArgumentInput> _unboundArguments = new List<CommandUnboundArgumentInput>();
private readonly List<CommandOptionInput> _options = new List<CommandOptionInput>();
public CommandLineInputBuilder AddDirective(CommandDirectiveInput directive)
{
_directives.Add(directive);
return this;
}
public CommandLineInputBuilder AddDirective(string directive) =>
AddDirective(new CommandDirectiveInput(directive));
public CommandLineInputBuilder AddUnboundArgument(CommandUnboundArgumentInput unboundArgument)
{
_unboundArguments.Add(unboundArgument);
return this;
}
public CommandLineInputBuilder AddUnboundArgument(string unboundArgument) =>
AddUnboundArgument(new CommandUnboundArgumentInput(unboundArgument));
public CommandLineInputBuilder AddOption(CommandOptionInput option)
{
_options.Add(option);
return this;
}
public CommandLineInputBuilder AddOption(string optionAlias, IReadOnlyList<string> values) =>
AddOption(new CommandOptionInput(optionAlias, values));
public CommandLineInputBuilder AddOption(string optionAlias, params string[] values) =>
AddOption(optionAlias, (IReadOnlyList<string>) values);
public CommandLineInput Build() => new CommandLineInput(_directives, _unboundArguments, _options);
}
}

View File

@@ -1,5 +1,5 @@
using System.Collections.Generic;
using System.Text;
using System.Linq;
using CliFx.Internal;
namespace CliFx.Domain
@@ -8,10 +8,6 @@ namespace CliFx.Domain
{
public string Alias { get; }
public string RawAlias => Alias.Length > 1
? $"--{Alias}"
: $"-{Alias}";
public IReadOnlyList<string> Values { get; }
public bool IsHelpOption => CommandOptionSchema.HelpOption.MatchesNameOrShortName(Alias);
@@ -24,28 +20,15 @@ namespace CliFx.Domain
Values = values;
}
public override string ToString()
public string GetRawAlias() => Alias switch
{
var buffer = new StringBuilder();
{Length: 0} => Alias,
{Length: 1} => $"-{Alias}",
_ => $"--{Alias}"
};
buffer.Append(RawAlias);
public string GetRawValues() => Values.Select(v => v.Quote()).JoinToString(" ");
foreach (var value in Values)
{
buffer.AppendIfNotEmpty(' ');
var isEscaped = value.Contains(" ");
if (isEscaped)
buffer.Append('"');
buffer.Append(value);
if (isEscaped)
buffer.Append('"');
}
return buffer.ToString();
}
public override string ToString() => $"{GetRawAlias()} {GetRawValues()}";
}
}

View File

@@ -17,7 +17,7 @@ namespace CliFx.Domain
public bool IsRequired { get; }
public CommandOptionSchema(
PropertyInfo property,
PropertyInfo? property,
string? name,
char? shortName,
string? environmentVariableName,
@@ -73,7 +73,7 @@ namespace CliFx.Domain
return buffer.ToString();
}
public string GetInternalDisplayString() => $"{Property.Name} ('{GetUserFacingDisplayString()}')";
public string GetInternalDisplayString() => $"{Property?.Name ?? "<implicit>"} ('{GetUserFacingDisplayString()}')";
public override string ToString() => GetInternalDisplayString();
}
@@ -86,9 +86,12 @@ namespace CliFx.Domain
if (attribute == null)
return null;
// The user may mistakenly specify dashes, thinking it's required, so trim them
var name = attribute.Name?.TrimStart('-');
return new CommandOptionSchema(
property,
attribute.Name,
name,
attribute.ShortName,
attribute.EnvironmentVariableName,
attribute.IsRequired,
@@ -100,9 +103,9 @@ namespace CliFx.Domain
internal partial class CommandOptionSchema
{
public static CommandOptionSchema HelpOption { get; } =
new CommandOptionSchema(null!, "help", 'h', null, false, "Shows help text.");
new CommandOptionSchema(null, "help", 'h', null, false, "Shows help text.");
public static CommandOptionSchema VersionOption { get; } =
new CommandOptionSchema(null!, "version", null, null, false, "Shows version information.");
new CommandOptionSchema(null, "version", null, null, false, "Shows version information.");
}
}

View File

@@ -1,10 +1,10 @@
namespace CliFx.Domain
{
internal class CommandUnboundArgumentInput
internal class CommandParameterInput
{
public string Value { get; }
public CommandUnboundArgumentInput(string value) => Value = value;
public CommandParameterInput(string value) => Value = value;
public override string ToString() => Value;
}

View File

@@ -10,7 +10,7 @@ namespace CliFx.Domain
public string Name { get; }
public CommandParameterSchema(PropertyInfo property, int order, string name, string? description)
public CommandParameterSchema(PropertyInfo? property, int order, string name, string? description)
: base(property, description)
{
Order = order;
@@ -29,7 +29,7 @@ namespace CliFx.Domain
return buffer.ToString();
}
public string GetInternalDisplayString() => $"{Property.Name} ([{Order}] {GetUserFacingDisplayString()})";
public string GetInternalDisplayString() => $"{Property?.Name ?? "<implicit>"} ([{Order}] {GetUserFacingDisplayString()})";
public override string ToString() => GetInternalDisplayString();
}

View File

@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using System.Text;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Internal;
@@ -24,6 +24,10 @@ namespace CliFx.Domain
public IReadOnlyList<CommandOptionSchema> Options { get; }
public bool IsHelpOptionAvailable => Options.Contains(CommandOptionSchema.HelpOption);
public bool IsVersionOptionAvailable => Options.Contains(CommandOptionSchema.VersionOption);
public CommandSchema(
Type type,
string? name,
@@ -34,28 +38,42 @@ namespace CliFx.Domain
Type = type;
Name = name;
Description = description;
Options = options;
Parameters = parameters;
Options = options;
}
public bool MatchesName(string? name) => string.Equals(name, Name, StringComparison.OrdinalIgnoreCase);
public bool MatchesName(string? name) =>
!string.IsNullOrWhiteSpace(Name)
? string.Equals(name, Name, StringComparison.OrdinalIgnoreCase)
: string.IsNullOrWhiteSpace(name);
public IReadOnlyList<CommandOptionSchema> GetBuiltInOptions()
public IEnumerable<CommandArgumentSchema> GetArguments()
{
var result = new List<CommandOptionSchema>(2);
foreach (var parameter in Parameters)
yield return parameter;
var helpOption = CommandOptionSchema.HelpOption;
var versionOption = CommandOptionSchema.VersionOption;
foreach (var option in Options)
yield return option;
}
result.Add(helpOption);
public IReadOnlyDictionary<CommandArgumentSchema, object?> GetArgumentValues(ICommand instance)
{
var result = new Dictionary<CommandArgumentSchema, object?>();
if (IsDefault)
result.Add(versionOption);
foreach (var argument in GetArguments())
{
// Skip built-in arguments
if (argument.Property == null)
continue;
var value = argument.Property.GetValue(instance);
result[argument] = value;
}
return result;
}
private void InjectParameters(ICommand command, IReadOnlyList<CommandUnboundArgumentInput> parameterInputs)
private void BindParameters(ICommand instance, IReadOnlyList<CommandParameterInput> parameterInputs)
{
// All inputs must be bound
var remainingParameterInputs = parameterInputs.ToList();
@@ -68,14 +86,14 @@ namespace CliFx.Domain
for (var i = 0; i < scalarParameters.Length; i++)
{
var scalarParameter = scalarParameters[i];
var parameter = scalarParameters[i];
var scalarParameterInput = i < parameterInputs.Count
var scalarInput = i < parameterInputs.Count
? parameterInputs[i]
: throw CliFxException.ParameterNotSet(scalarParameter);
: throw CliFxException.ParameterNotSet(parameter);
scalarParameter.Inject(command, scalarParameterInput.Value);
remainingParameterInputs.Remove(scalarParameterInput);
parameter.BindOn(instance, scalarInput.Value);
remainingParameterInputs.Remove(scalarInput);
}
// Non-scalar parameter (only one is allowed)
@@ -85,21 +103,23 @@ namespace CliFx.Domain
if (nonScalarParameter != null)
{
var nonScalarParameterValues = parameterInputs.Skip(scalarParameters.Length).Select(i => i.Value).ToArray();
// TODO: Should it verify that at least one value is passed?
var nonScalarValues = parameterInputs
.Skip(scalarParameters.Length)
.Select(p => p.Value)
.ToArray();
nonScalarParameter.Inject(command, nonScalarParameterValues);
nonScalarParameter.BindOn(instance, nonScalarValues);
remainingParameterInputs.Clear();
}
// Ensure all inputs were bound
if (remainingParameterInputs.Any())
{
throw CliFxException.UnrecognizedParametersProvided(remainingParameterInputs);
}
}
private void InjectOptions(
ICommand command,
private void BindOptions(
ICommand instance,
IReadOnlyList<CommandOptionInput> optionInputs,
IReadOnlyDictionary<string, string> environmentVariables)
{
@@ -113,19 +133,17 @@ namespace CliFx.Domain
foreach (var (name, value) in environmentVariables)
{
var option = Options.FirstOrDefault(o => o.MatchesEnvironmentVariableName(name));
if (option == null)
continue;
if (option != null)
{
var values = option.IsScalar
? new[] {value}
: value.Split(Path.PathSeparator);
var values = option.IsScalar
? new[] {value}
: value.Split(Path.PathSeparator);
option.Inject(command, values);
unsetRequiredOptions.Remove(option);
}
option.BindOn(instance, values);
unsetRequiredOptions.Remove(option);
}
// TODO: refactor this part? I wrote this while sick
// Direct input
foreach (var option in Options)
{
@@ -133,49 +151,57 @@ namespace CliFx.Domain
.Where(i => option.MatchesNameOrShortName(i.Alias))
.ToArray();
if (inputs.Any())
{
var inputValues = inputs.SelectMany(i => i.Values).ToArray();
option.Inject(command, inputValues);
// Skip if the inputs weren't provided for this option
if (!inputs.Any())
continue;
foreach (var input in inputs)
remainingOptionInputs.Remove(input);
var inputValues = inputs.SelectMany(i => i.Values).ToArray();
option.BindOn(instance, inputValues);
if (inputValues.Any())
unsetRequiredOptions.Remove(option);
}
}
remainingOptionInputs.RemoveRange(inputs);
// Ensure all required options were set
if (unsetRequiredOptions.Any())
{
throw CliFxException.RequiredOptionsNotSet(unsetRequiredOptions);
// Required option implies that the value has to be set and also be non-empty
if (inputValues.Any())
unsetRequiredOptions.Remove(option);
}
// Ensure all inputs were bound
if (remainingOptionInputs.Any())
{
throw CliFxException.UnrecognizedOptionsProvided(remainingOptionInputs);
}
// Ensure all required options were set
if (unsetRequiredOptions.Any())
throw CliFxException.RequiredOptionsNotSet(unsetRequiredOptions);
}
public ICommand CreateInstance(
IReadOnlyList<CommandUnboundArgumentInput> parameterInputs,
IReadOnlyList<CommandOptionInput> optionInputs,
IReadOnlyDictionary<string, string> environmentVariables,
ITypeActivator activator)
public void Bind(
ICommand instance,
CommandInput input,
IReadOnlyDictionary<string, string> environmentVariables)
{
var command = (ICommand) activator.CreateInstance(Type);
InjectParameters(command, parameterInputs);
InjectOptions(command, optionInputs, environmentVariables);
return command;
BindParameters(instance, input.Parameters);
BindOptions(instance, input.Options, environmentVariables);
}
public string GetUserFacingDisplayString() => Name ?? "";
public string GetInternalDisplayString()
{
var buffer = new StringBuilder();
public string GetInternalDisplayString() => $"{Type.FullName} ('{GetUserFacingDisplayString()}')";
// Type
buffer.Append(Type.FullName);
// Name
buffer
.Append(' ')
.Append('(')
.Append(IsDefault
? "<default command>"
: $"'{Name}'"
)
.Append(')');
return buffer.ToString();
}
public override string ToString() => GetInternalDisplayString();
}
@@ -195,6 +221,12 @@ namespace CliFx.Domain
var attribute = type.GetCustomAttribute<CommandAttribute>();
var name = attribute?.Name;
var builtInOptions = string.IsNullOrWhiteSpace(name)
? new[] {CommandOptionSchema.HelpOption, CommandOptionSchema.VersionOption}
: new[] {CommandOptionSchema.HelpOption};
var parameters = type.GetProperties()
.Select(CommandParameterSchema.TryResolve)
.Where(p => p != null)
@@ -203,27 +235,16 @@ namespace CliFx.Domain
var options = type.GetProperties()
.Select(CommandOptionSchema.TryResolve)
.Where(o => o != null)
.Concat(builtInOptions)
.ToArray();
return new CommandSchema(
type,
attribute?.Name,
name,
attribute?.Description,
parameters!,
options!
);
}
}
internal partial class CommandSchema
{
// TODO: won't work with dep injection
[Command]
public class StubDefaultCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
public static CommandSchema Schema { get; } = TryResolve(typeof(StubDefaultCommand))!;
}
}
}

View File

@@ -1,26 +1,26 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using CliFx.Internal;
namespace CliFx.Domain
{
internal class HelpTextWriter
internal partial class HelpTextWriter
{
private readonly ApplicationMetadata _metadata;
private readonly IConsole _console;
private readonly ITypeActivator _typeActivator;
private int _column;
private int _row;
private bool IsEmpty => _column == 0 && _row == 0;
public HelpTextWriter(ApplicationMetadata metadata, IConsole console, ITypeActivator typeActivator)
public HelpTextWriter(ApplicationMetadata metadata, IConsole console)
{
_metadata = metadata;
_console = console;
_typeActivator = typeActivator;
}
private void Write(char value)
@@ -49,23 +49,17 @@ namespace CliFx.Domain
private void WriteVerticalMargin(int size = 1)
{
if (IsEmpty)
return;
for (var i = 0; i < size; i++)
WriteLine();
}
private void WriteHorizontalMargin(int size = 2)
{
if (IsEmpty)
return;
for (var i = 0; i < size; i++)
Write(' ');
}
private void WriteHorizontalColumnMargin(int columnSize = 20, int offsetSize = 2)
private void WriteColumnMargin(int columnSize = 20, int offsetSize = 2)
{
if (_column + offsetSize < columnSize)
WriteHorizontalMargin(columnSize - _column);
@@ -79,11 +73,8 @@ namespace CliFx.Domain
WriteLine();
}
private void WriteApplicationInfo(CommandSchema commandSchema)
private void WriteApplicationInfo()
{
if (!commandSchema.IsDefault)
return;
// Title and version
Write(ConsoleColor.Yellow, _metadata.Title);
Write(' ');
@@ -99,24 +90,26 @@ namespace CliFx.Domain
}
}
private void WriteCommandDescription(CommandSchema commandSchema)
private void WriteCommandDescription(CommandSchema command)
{
if (string.IsNullOrWhiteSpace(commandSchema.Description))
if (string.IsNullOrWhiteSpace(command.Description))
return;
WriteVerticalMargin();
if (!IsEmpty)
WriteVerticalMargin();
WriteHeader("Description");
WriteHorizontalMargin();
Write(commandSchema.Description);
Write(command.Description);
WriteLine();
}
private void WriteCommandUsage(
CommandSchema commandSchema,
IReadOnlyList<CommandSchema> childCommandSchemas)
private void WriteCommandUsage(CommandSchema command, IReadOnlyList<CommandSchema> childCommands)
{
WriteVerticalMargin();
if (!IsEmpty)
WriteVerticalMargin();
WriteHeader("Usage");
// Exe name
@@ -124,40 +117,40 @@ namespace CliFx.Domain
Write(_metadata.ExecutableName);
// Command name
if (!string.IsNullOrWhiteSpace(commandSchema.Name))
if (!string.IsNullOrWhiteSpace(command.Name))
{
Write(' ');
Write(ConsoleColor.Cyan, commandSchema.Name);
Write(ConsoleColor.Cyan, command.Name);
}
// Child command placeholder
if (childCommandSchemas.Any())
if (childCommands.Any())
{
Write(' ');
Write(ConsoleColor.Cyan, "[command]");
}
// Parameters
foreach (var parameterSchema in commandSchema.Parameters)
foreach (var parameter in command.Parameters)
{
Write(' ');
Write(parameterSchema.IsScalar
? $"<{parameterSchema.Name}>"
: $"<{parameterSchema.Name}...>"
Write(parameter.IsScalar
? $"<{parameter.Name}>"
: $"<{parameter.Name}...>"
);
}
// Required options
foreach (var optionSchema in commandSchema.Options.Where(o => o.IsRequired))
foreach (var option in command.Options.Where(o => o.IsRequired))
{
Write(' ');
Write(ConsoleColor.White, !string.IsNullOrWhiteSpace(optionSchema.Name)
? $"--{optionSchema.Name}"
: $"-{optionSchema.ShortName}"
Write(ConsoleColor.White, !string.IsNullOrWhiteSpace(option.Name)
? $"--{option.Name}"
: $"-{option.ShortName}"
);
Write(' ');
Write(optionSchema.IsScalar
Write(option.IsScalar
? "<value>"
: "<values...>"
);
@@ -170,51 +163,53 @@ namespace CliFx.Domain
WriteLine();
}
private void WriteCommandParameters(CommandSchema commandSchema)
private void WriteCommandParameters(CommandSchema command)
{
if (!commandSchema.Parameters.Any())
if (!command.Parameters.Any())
return;
WriteVerticalMargin();
if (!IsEmpty)
WriteVerticalMargin();
WriteHeader("Parameters");
foreach (var parameterSchema in commandSchema.Parameters.OrderBy(p => p.Order))
foreach (var parameter in command.Parameters.OrderBy(p => p.Order))
{
Write(ConsoleColor.Red, "* ");
Write(ConsoleColor.White, $"{parameterSchema.Name}");
Write(ConsoleColor.White, $"{parameter.Name}");
WriteHorizontalColumnMargin();
WriteColumnMargin();
// Description
if (!string.IsNullOrWhiteSpace(parameterSchema.Description))
if (!string.IsNullOrWhiteSpace(parameter.Description))
{
Write(parameterSchema.Description);
Write(parameter.Description);
Write(' ');
}
// Valid values
var validValues = parameterSchema.GetValidValues();
var validValues = parameter.GetValidValues();
if (validValues.Any())
{
Write($"Valid values: {string.Join(", ", validValues)}.");
Write($"Valid values: {FormatValidValues(validValues)}.");
}
WriteLine();
}
}
private void WriteCommandOptions(CommandSchema commandSchema, ICommand command)
private void WriteCommandOptions(
CommandSchema command,
IReadOnlyDictionary<CommandArgumentSchema, object?> argumentDefaultValues)
{
WriteVerticalMargin();
if (!IsEmpty)
WriteVerticalMargin();
WriteHeader("Options");
var actualOptionSchemas = commandSchema.Options
.OrderByDescending(o => o.IsRequired)
.Concat(commandSchema.GetBuiltInOptions());
foreach (var optionSchema in actualOptionSchemas)
foreach (var option in command.Options.OrderByDescending(o => o.IsRequired))
{
if (optionSchema.IsRequired)
if (option.IsRequired)
{
Write(ConsoleColor.Red, "* ");
}
@@ -224,55 +219,55 @@ namespace CliFx.Domain
}
// Short name
if (optionSchema.ShortName != null)
if (option.ShortName != null)
{
Write(ConsoleColor.White, $"-{optionSchema.ShortName}");
Write(ConsoleColor.White, $"-{option.ShortName}");
}
// Delimiter
if (!string.IsNullOrWhiteSpace(optionSchema.Name) && optionSchema.ShortName != null)
// Separator
if (!string.IsNullOrWhiteSpace(option.Name) && option.ShortName != null)
{
Write('|');
}
// Name
if (!string.IsNullOrWhiteSpace(optionSchema.Name))
if (!string.IsNullOrWhiteSpace(option.Name))
{
Write(ConsoleColor.White, $"--{optionSchema.Name}");
Write(ConsoleColor.White, $"--{option.Name}");
}
WriteHorizontalColumnMargin();
WriteColumnMargin();
// Description
if (!string.IsNullOrWhiteSpace(optionSchema.Description))
if (!string.IsNullOrWhiteSpace(option.Description))
{
Write(optionSchema.Description);
Write(option.Description);
Write(' ');
}
// Valid values
var validValues = optionSchema.GetValidValues();
var validValues = option.GetValidValues();
if (validValues.Any())
{
Write($"Valid values: {validValues.Select(v => v.Quote()).JoinToString(", ")}.");
Write($"Valid values: {FormatValidValues(validValues)}.");
Write(' ');
}
// Environment variable
if (!string.IsNullOrWhiteSpace(optionSchema.EnvironmentVariableName))
if (!string.IsNullOrWhiteSpace(option.EnvironmentVariableName))
{
Write($"Environment variable: \"{optionSchema.EnvironmentVariableName}\".");
Write($"Environment variable: \"{option.EnvironmentVariableName}\".");
Write(' ');
}
// Default value
if (!optionSchema.IsRequired)
if (!option.IsRequired)
{
// TODO: move quoting logic here?
var defaultValue = optionSchema.TryGetDefaultValue(command);
if (defaultValue != null)
var defaultValue = argumentDefaultValues.GetValueOrDefault(option);
var defaultValueFormatted = FormatDefaultValue(defaultValue);
if (defaultValueFormatted != null)
{
Write($"Default: {defaultValue}.");
Write($"Default: {defaultValueFormatted}.");
}
}
@@ -281,30 +276,32 @@ namespace CliFx.Domain
}
private void WriteCommandChildren(
CommandSchema commandSchema,
IReadOnlyList<CommandSchema> childCommandSchemas)
CommandSchema command,
IReadOnlyList<CommandSchema> childCommands)
{
if (!childCommandSchemas.Any())
if (!childCommands.Any())
return;
WriteVerticalMargin();
if (!IsEmpty)
WriteVerticalMargin();
WriteHeader("Commands");
foreach (var childCommandSchema in childCommandSchemas)
foreach (var childCommand in childCommands)
{
var relativeCommandName = !string.IsNullOrWhiteSpace(commandSchema.Name)
? childCommandSchema.Name!.Substring(commandSchema.Name.Length + 1)
: childCommandSchema.Name!;
var relativeCommandName = !string.IsNullOrWhiteSpace(command.Name)
? childCommand.Name!.Substring(command.Name.Length).Trim()
: childCommand.Name!;
// Name
WriteHorizontalMargin();
Write(ConsoleColor.Cyan, relativeCommandName);
// Description
if (!string.IsNullOrWhiteSpace(childCommandSchema.Description))
if (!string.IsNullOrWhiteSpace(childCommand.Description))
{
WriteHorizontalColumnMargin();
Write(childCommandSchema.Description);
WriteColumnMargin();
Write(childCommand.Description);
}
WriteLine();
@@ -315,10 +312,10 @@ namespace CliFx.Domain
Write("You can run `");
Write(_metadata.ExecutableName);
if (!string.IsNullOrWhiteSpace(commandSchema.Name))
if (!string.IsNullOrWhiteSpace(command.Name))
{
Write(' ');
Write(ConsoleColor.Cyan, commandSchema.Name);
Write(ConsoleColor.Cyan, command.Name);
}
Write(' ');
@@ -332,19 +329,60 @@ namespace CliFx.Domain
WriteLine();
}
public void Write(ApplicationSchema applicationSchema, CommandSchema commandSchema)
public void Write(
RootSchema root,
CommandSchema command,
IReadOnlyDictionary<CommandArgumentSchema, object?> defaultValues)
{
var childCommandSchemas = applicationSchema.GetChildCommands(commandSchema.Name);
var command = (ICommand) _typeActivator.CreateInstance(commandSchema.Type);
var childCommands = root.GetChildCommands(command.Name);
_console.ResetColor();
WriteApplicationInfo(commandSchema);
WriteCommandDescription(commandSchema);
WriteCommandUsage(commandSchema, childCommandSchemas);
WriteCommandParameters(commandSchema);
WriteCommandOptions(commandSchema, command);
WriteCommandChildren(commandSchema, childCommandSchemas);
if (command.IsDefault)
WriteApplicationInfo();
WriteCommandDescription(command);
WriteCommandUsage(command, childCommands);
WriteCommandParameters(command);
WriteCommandOptions(command, defaultValues);
WriteCommandChildren(command, childCommands);
}
}
internal partial class HelpTextWriter
{
private static string FormatValidValues(IReadOnlyList<string> values) =>
values.Select(v => v.Quote()).JoinToString(", ");
private static string? FormatDefaultValue(object? defaultValue)
{
if (defaultValue == null)
return null;
// Enumerable
if (!(defaultValue is string) && defaultValue is IEnumerable defaultValues)
{
var elementType = defaultValues.GetType().GetEnumerableUnderlyingType() ?? typeof(object);
// If the ToString() method is not overriden, the default value can't be formatted nicely
if (!elementType.IsToStringOverriden())
return null;
return defaultValues
.Cast<object?>()
.Where(o => o != null)
.Select(o => o!.ToFormattableString(CultureInfo.InvariantCulture).Quote())
.JoinToString(" ");
}
// Non-enumerable
else
{
// If the ToString() method is not overriden, the default value can't be formatted nicely
if (!defaultValue.GetType().IsToStringOverriden())
return null;
return defaultValue.ToFormattableString(CultureInfo.InvariantCulture).Quote();
}
}
}
}

232
CliFx/Domain/RootSchema.cs Normal file
View File

@@ -0,0 +1,232 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CliFx.Exceptions;
using CliFx.Internal;
namespace CliFx.Domain
{
internal partial class RootSchema
{
public IReadOnlyList<CommandSchema> Commands { get; }
public RootSchema(IReadOnlyList<CommandSchema> commands)
{
Commands = commands;
}
public IReadOnlyList<string> GetCommandNames() => Commands
.Select(c => c.Name)
.Where(n => !string.IsNullOrWhiteSpace(n))
.ToArray()!;
public CommandSchema? TryFindDefaultCommand() =>
Commands.FirstOrDefault(c => c.IsDefault);
public CommandSchema? TryFindCommand(string? commandName) =>
Commands.FirstOrDefault(c => c.MatchesName(commandName));
private IReadOnlyList<CommandSchema> GetDescendantCommands(
IReadOnlyList<CommandSchema> potentialParentCommands,
string? parentCommandName) =>
potentialParentCommands
// Default commands can't be children of anything
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
// Command can't be its own child
.Where(c => !c.MatchesName(parentCommandName))
.Where(c =>
string.IsNullOrWhiteSpace(parentCommandName) ||
c.Name!.StartsWith(parentCommandName + ' ', StringComparison.OrdinalIgnoreCase))
.ToArray();
public IReadOnlyList<CommandSchema> GetDescendantCommands(string? parentCommandName) =>
GetDescendantCommands(Commands, parentCommandName);
public IReadOnlyList<CommandSchema> GetChildCommands(string? parentCommandName)
{
var descendants = GetDescendantCommands(parentCommandName);
// Filter out descendants of descendants, leave only children
var result = new List<CommandSchema>(descendants);
foreach (var descendant in descendants)
{
var descendantDescendants = GetDescendantCommands(descendants, descendant.Name);
result.RemoveRange(descendantDescendants);
}
return result;
}
}
internal partial class RootSchema
{
private static void ValidateParameters(CommandSchema command)
{
var duplicateOrderGroup = command.Parameters
.GroupBy(a => a.Order)
.FirstOrDefault(g => g.Count() > 1);
if (duplicateOrderGroup != null)
{
throw CliFxException.ParametersWithSameOrder(
command,
duplicateOrderGroup.Key,
duplicateOrderGroup.ToArray()
);
}
var duplicateNameGroup = command.Parameters
.Where(a => !string.IsNullOrWhiteSpace(a.Name))
.GroupBy(a => a.Name!, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(g => g.Count() > 1);
if (duplicateNameGroup != null)
{
throw CliFxException.ParametersWithSameName(
command,
duplicateNameGroup.Key,
duplicateNameGroup.ToArray()
);
}
var nonScalarParameters = command.Parameters
.Where(p => !p.IsScalar)
.ToArray();
if (nonScalarParameters.Length > 1)
{
throw CliFxException.TooManyNonScalarParameters(
command,
nonScalarParameters
);
}
var nonLastNonScalarParameter = command.Parameters
.OrderByDescending(a => a.Order)
.Skip(1)
.LastOrDefault(p => !p.IsScalar);
if (nonLastNonScalarParameter != null)
{
throw CliFxException.NonLastNonScalarParameter(
command,
nonLastNonScalarParameter
);
}
}
private static void ValidateOptions(CommandSchema command)
{
var noNameGroup = command.Options
.Where(o => o.ShortName == null && string.IsNullOrWhiteSpace(o.Name))
.ToArray();
if (noNameGroup.Any())
{
throw CliFxException.OptionsWithNoName(
command,
noNameGroup.ToArray()
);
}
var invalidLengthNameGroup = command.Options
.Where(o => !string.IsNullOrWhiteSpace(o.Name))
.Where(o => o.Name!.Length <= 1)
.ToArray();
if (invalidLengthNameGroup.Any())
{
throw CliFxException.OptionsWithInvalidLengthName(
command,
invalidLengthNameGroup
);
}
var duplicateNameGroup = command.Options
.Where(o => !string.IsNullOrWhiteSpace(o.Name))
.GroupBy(o => o.Name!, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(g => g.Count() > 1);
if (duplicateNameGroup != null)
{
throw CliFxException.OptionsWithSameName(
command,
duplicateNameGroup.Key,
duplicateNameGroup.ToArray()
);
}
var duplicateShortNameGroup = command.Options
.Where(o => o.ShortName != null)
.GroupBy(o => o.ShortName!.Value)
.FirstOrDefault(g => g.Count() > 1);
if (duplicateShortNameGroup != null)
{
throw CliFxException.OptionsWithSameShortName(
command,
duplicateShortNameGroup.Key,
duplicateShortNameGroup.ToArray()
);
}
var duplicateEnvironmentVariableNameGroup = command.Options
.Where(o => !string.IsNullOrWhiteSpace(o.EnvironmentVariableName))
.GroupBy(o => o.EnvironmentVariableName!, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(g => g.Count() > 1);
if (duplicateEnvironmentVariableNameGroup != null)
{
throw CliFxException.OptionsWithSameEnvironmentVariableName(
command,
duplicateEnvironmentVariableNameGroup.Key,
duplicateEnvironmentVariableNameGroup.ToArray()
);
}
}
private static void ValidateCommands(IReadOnlyList<CommandSchema> commands)
{
if (!commands.Any())
{
throw CliFxException.NoCommandsDefined();
}
var duplicateNameGroup = commands
.GroupBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(g => g.Count() > 1);
if (duplicateNameGroup != null)
{
throw !string.IsNullOrWhiteSpace(duplicateNameGroup.Key)
? CliFxException.CommandsWithSameName(
duplicateNameGroup.Key,
duplicateNameGroup.ToArray()
)
: CliFxException.TooManyDefaultCommands(duplicateNameGroup.ToArray());
}
}
public static RootSchema Resolve(IReadOnlyList<Type> commandTypes)
{
var commands = new List<CommandSchema>();
foreach (var commandType in commandTypes)
{
var command =
CommandSchema.TryResolve(commandType) ??
throw CliFxException.InvalidCommandType(commandType);
ValidateParameters(command);
ValidateOptions(command);
commands.Add(command);
}
ValidateCommands(commands);
return new RootSchema(commands);
}
}
}

View File

@@ -12,54 +12,25 @@ namespace CliFx.Exceptions
/// </summary>
public partial class CliFxException : Exception
{
/// <summary>
/// Represents the default exit code assigned to exceptions in CliFx.
/// </summary>
protected const int DefaultExitCode = -100;
/// <summary>
/// Whether to show the help text after handling this exception.
/// </summary>
public bool ShowHelp { get; }
/// <summary>
/// Whether this exception was constructed with a message.
/// </summary>
/// <remarks>
/// We cannot check against the 'Message' property because it will always return
/// a default message if it was constructed with a null value or is currently null.
/// </remarks>
public bool HasMessage { get; }
/// <summary>
/// Returns an exit code associated with this exception.
/// </summary>
public int ExitCode { get; }
private readonly bool _isMessageSet;
/// <summary>
/// Initializes an instance of <see cref="CliFxException"/>.
/// </summary>
public CliFxException(string? message, Exception? innerException, int exitCode = DefaultExitCode, bool showHelp = false)
public CliFxException(string? message, Exception? innerException = null)
: base(message, innerException)
{
ExitCode = exitCode != 0
? exitCode
: throw new ArgumentException("Exit code must not be zero in order to signify failure.");
HasMessage = !string.IsNullOrWhiteSpace(message);
ShowHelp = showHelp;
// Message property has a fallback so it's never empty, hence why we need this check
_isMessageSet = !string.IsNullOrWhiteSpace(message);
}
/// <summary>
/// Initializes an instance of <see cref="CliFxException"/>.
/// </summary>
public CliFxException(string? message, bool showHelp = false)
: this(message, null, showHelp: showHelp)
{
}
/// <inheritdoc />
public override string ToString() => _isMessageSet
? Message
: base.ToString();
}
// Mid-user-facing exceptions
// Internal exceptions
// Provide more diagnostic information here
public partial class CliFxException
{
@@ -83,7 +54,7 @@ Refer to the readme to learn how to integrate a dependency container of your cho
Failed to create an instance of type '{type.FullName}', received <null> instead.
To fix this, ensure that the provided type activator was configured correctly, as it's not expected to return <null>.
If you are using a dependency container, ensure this type is registered, because it may return <null> otherwise.";
If you are using a dependency container, this error may signify that the type wasn't registered.";
return new CliFxException(message.Trim());
}
@@ -103,7 +74,7 @@ If you're experiencing problems, please refer to the readme for a quickstart exa
return new CliFxException(message.Trim());
}
internal static CliFxException CommandsNotRegistered()
internal static CliFxException NoCommandsDefined()
{
var message = $@"
There are no commands configured in the application.
@@ -114,11 +85,11 @@ If you're experiencing problems, please refer to the readme for a quickstart exa
return new CliFxException(message.Trim());
}
internal static CliFxException CommandsTooManyDefaults(IReadOnlyList<CommandSchema> invalidCommandSchemas)
internal static CliFxException TooManyDefaultCommands(IReadOnlyList<CommandSchema> invalidCommands)
{
var message = $@"
Application configuration is invalid because there are {invalidCommandSchemas.Count} default commands:
{invalidCommandSchemas.JoinToString(Environment.NewLine)}
Application configuration is invalid because there are {invalidCommands.Count} default commands:
{invalidCommands.JoinToString(Environment.NewLine)}
There can only be one default command (i.e. command with no name) in an application.
Other commands must have unique non-empty names that identify them.";
@@ -126,13 +97,13 @@ Other commands must have unique non-empty names that identify them.";
return new CliFxException(message.Trim());
}
internal static CliFxException CommandsDuplicateName(
internal static CliFxException CommandsWithSameName(
string name,
IReadOnlyList<CommandSchema> invalidCommandSchemas)
IReadOnlyList<CommandSchema> invalidCommands)
{
var message = $@"
Application configuration is invalid because there are {invalidCommandSchemas.Count} commands with the same name ('{name}'):
{invalidCommandSchemas.JoinToString(Environment.NewLine)}
Application configuration is invalid because there are {invalidCommands.Count} commands with the same name ('{name}'):
{invalidCommands.JoinToString(Environment.NewLine)}
Commands must have unique names.
Names are not case-sensitive.";
@@ -140,28 +111,28 @@ Names are not case-sensitive.";
return new CliFxException(message.Trim());
}
internal static CliFxException CommandParametersDuplicateOrder(
CommandSchema commandSchema,
internal static CliFxException ParametersWithSameOrder(
CommandSchema command,
int order,
IReadOnlyList<CommandParameterSchema> invalidParameterSchemas)
IReadOnlyList<CommandParameterSchema> invalidParameters)
{
var message = $@"
Command '{commandSchema.Type.FullName}' is invalid because it contains {invalidParameterSchemas.Count} parameters with the same order ({order}):
{invalidParameterSchemas.JoinToString(Environment.NewLine)}
Command '{command.Type.FullName}' is invalid because it contains {invalidParameters.Count} parameters with the same order ({order}):
{invalidParameters.JoinToString(Environment.NewLine)}
Parameters must have unique order.";
return new CliFxException(message.Trim());
}
internal static CliFxException CommandParametersDuplicateName(
CommandSchema commandSchema,
internal static CliFxException ParametersWithSameName(
CommandSchema command,
string name,
IReadOnlyList<CommandParameterSchema> invalidParameterSchemas)
IReadOnlyList<CommandParameterSchema> invalidParameters)
{
var message = $@"
Command '{commandSchema.Type.FullName}' is invalid because it contains {invalidParameterSchemas.Count} parameters with the same name ('{name}'):
{invalidParameterSchemas.JoinToString(Environment.NewLine)}
Command '{command.Type.FullName}' is invalid because it contains {invalidParameters.Count} parameters with the same name ('{name}'):
{invalidParameters.JoinToString(Environment.NewLine)}
Parameters must have unique names to avoid potential confusion in the help text.
Names are not case-sensitive.";
@@ -169,15 +140,15 @@ Names are not case-sensitive.";
return new CliFxException(message.Trim());
}
internal static CliFxException CommandParametersTooManyNonScalar(
CommandSchema commandSchema,
IReadOnlyList<CommandParameterSchema> invalidParameterSchemas)
internal static CliFxException TooManyNonScalarParameters(
CommandSchema command,
IReadOnlyList<CommandParameterSchema> invalidParameters)
{
var message = $@"
Command '{commandSchema.Type.FullName}' is invalid because it contains {invalidParameterSchemas.Count} non-scalar parameters:
{invalidParameterSchemas.JoinToString(Environment.NewLine)}
Command '{command.Type.FullName}' is invalid because it contains {invalidParameters.Count} non-scalar parameters:
{invalidParameters.JoinToString(Environment.NewLine)}
Non-scalar parameter is such that is bound from more than one value (e.g. array or a complex object).
Non-scalar parameter is such that is bound from more than one value (e.g. array).
Only one parameter in a command may be non-scalar and it must be the last one in order.
If it's not feasible to fit into these constraints, consider using options instead as they don't have these limitations.";
@@ -185,15 +156,15 @@ If it's not feasible to fit into these constraints, consider using options inste
return new CliFxException(message.Trim());
}
internal static CliFxException CommandParametersNonLastNonScalar(
CommandSchema commandSchema,
CommandParameterSchema invalidParameterSchema)
internal static CliFxException NonLastNonScalarParameter(
CommandSchema command,
CommandParameterSchema invalidParameter)
{
var message = $@"
Command '{commandSchema.Type.FullName}' is invalid because it contains a non-scalar parameter which is not the last in order:
{invalidParameterSchema}
Command '{command.Type.FullName}' is invalid because it contains a non-scalar parameter which is not the last in order:
{invalidParameter}
Non-scalar parameter is such that is bound from more than one value (e.g. array or a complex object).
Non-scalar parameter is such that is bound from more than one value (e.g. array).
Only one parameter in a command may be non-scalar and it must be the last one in order.
If it's not feasible to fit into these constraints, consider using options instead as they don't have these limitations.";
@@ -201,26 +172,26 @@ If it's not feasible to fit into these constraints, consider using options inste
return new CliFxException(message.Trim());
}
internal static CliFxException CommandOptionsNoName(
CommandSchema commandSchema,
IReadOnlyList<CommandOptionSchema> invalidOptionSchemas)
internal static CliFxException OptionsWithNoName(
CommandSchema command,
IReadOnlyList<CommandOptionSchema> invalidOptions)
{
var message = $@"
Command '{commandSchema.Type.FullName}' is invalid because it contains one or more options without a name:
{invalidOptionSchemas.JoinToString(Environment.NewLine)}
Command '{command.Type.FullName}' is invalid because it contains one or more options without a name:
{invalidOptions.JoinToString(Environment.NewLine)}
Options must have either a name or a short name or both.";
return new CliFxException(message.Trim());
}
internal static CliFxException CommandOptionsInvalidLengthName(
CommandSchema commandSchema,
IReadOnlyList<CommandOptionSchema> invalidOptionSchemas)
internal static CliFxException OptionsWithInvalidLengthName(
CommandSchema command,
IReadOnlyList<CommandOptionSchema> invalidOptions)
{
var message = $@"
Command '{commandSchema.Type.FullName}' is invalid because it contains one or more options whose names are too short:
{invalidOptionSchemas.JoinToString(Environment.NewLine)}
Command '{command.Type.FullName}' is invalid because it contains one or more options whose names are too short:
{invalidOptions.JoinToString(Environment.NewLine)}
Option names must be at least 2 characters long to avoid confusion with short names.
If you intended to set the short name instead, use the attribute overload that accepts a char.";
@@ -228,14 +199,14 @@ If you intended to set the short name instead, use the attribute overload that a
return new CliFxException(message.Trim());
}
internal static CliFxException CommandOptionsDuplicateName(
CommandSchema commandSchema,
internal static CliFxException OptionsWithSameName(
CommandSchema command,
string name,
IReadOnlyList<CommandOptionSchema> invalidOptionSchemas)
IReadOnlyList<CommandOptionSchema> invalidOptions)
{
var message = $@"
Command '{commandSchema.Type.FullName}' is invalid because it contains {invalidOptionSchemas.Count} options with the same name ('{name}'):
{invalidOptionSchemas.JoinToString(Environment.NewLine)}
Command '{command.Type.FullName}' is invalid because it contains {invalidOptions.Count} options with the same name ('{name}'):
{invalidOptions.JoinToString(Environment.NewLine)}
Options must have unique names.
Names are not case-sensitive.";
@@ -243,14 +214,14 @@ Names are not case-sensitive.";
return new CliFxException(message.Trim());
}
internal static CliFxException CommandOptionsDuplicateShortName(
CommandSchema commandSchema,
internal static CliFxException OptionsWithSameShortName(
CommandSchema command,
char shortName,
IReadOnlyList<CommandOptionSchema> invalidOptionSchemas)
IReadOnlyList<CommandOptionSchema> invalidOptions)
{
var message = $@"
Command '{commandSchema.Type.FullName}' is invalid because it contains {invalidOptionSchemas.Count} options with the same short name ('{shortName}'):
{invalidOptionSchemas.JoinToString(Environment.NewLine)}
Command '{command.Type.FullName}' is invalid because it contains {invalidOptions.Count} options with the same short name ('{shortName}'):
{invalidOptions.JoinToString(Environment.NewLine)}
Options must have unique short names.
Short names are case-sensitive (i.e. 'a' and 'A' are different short names).";
@@ -258,14 +229,14 @@ Short names are case-sensitive (i.e. 'a' and 'A' are different short names).";
return new CliFxException(message.Trim());
}
internal static CliFxException CommandOptionsDuplicateEnvironmentVariableName(
CommandSchema commandSchema,
internal static CliFxException OptionsWithSameEnvironmentVariableName(
CommandSchema command,
string environmentVariableName,
IReadOnlyList<CommandOptionSchema> invalidOptionSchemas)
IReadOnlyList<CommandOptionSchema> invalidOptions)
{
var message = $@"
Command '{commandSchema.Type.FullName}' is invalid because it contains {invalidOptionSchemas.Count} options with the same fallback environment variable name ('{environmentVariableName}'):
{invalidOptionSchemas.JoinToString(Environment.NewLine)}
Command '{command.Type.FullName}' is invalid because it contains {invalidOptions.Count} options with the same fallback environment variable name ('{environmentVariableName}'):
{invalidOptions.JoinToString(Environment.NewLine)}
Options cannot share the same environment variable as a fallback.
Environment variable names are not case-sensitive.";
@@ -278,154 +249,145 @@ Environment variable names are not case-sensitive.";
// Avoid internal details and fix recommendations here
public partial class CliFxException
{
internal static CliFxException CannotFindMatchingCommand(CommandLineInput input)
{
var message = $@"
Can't find a command that matches the following arguments:
{input.UnboundArguments.JoinToString(" ")}";
return new CliFxException(message.Trim(), showHelp: true);
}
internal static CliFxException CannotConvertMultipleValuesToNonScalar(
CommandParameterSchema parameterSchema,
CommandParameterSchema parameter,
IReadOnlyList<string> values)
{
var message = $@"
Parameter {parameterSchema.GetUserFacingDisplayString()} expects a single value, but provided with multiple:
Parameter {parameter.GetUserFacingDisplayString()} expects a single value, but provided with multiple:
{values.Select(v => v.Quote()).JoinToString(" ")}";
return new CliFxException(message.Trim(), showHelp: true);
return new CliFxException(message.Trim());
}
internal static CliFxException CannotConvertMultipleValuesToNonScalar(
CommandOptionSchema optionSchema,
CommandOptionSchema option,
IReadOnlyList<string> values)
{
var message = $@"
Option {optionSchema.GetUserFacingDisplayString()} expects a single value, but provided with multiple:
Option {option.GetUserFacingDisplayString()} expects a single value, but provided with multiple:
{values.Select(v => v.Quote()).JoinToString(" ")}";
return new CliFxException(message.Trim(), showHelp: true);
return new CliFxException(message.Trim());
}
internal static CliFxException CannotConvertMultipleValuesToNonScalar(
CommandArgumentSchema argumentSchema,
IReadOnlyList<string> values) => argumentSchema switch
CommandArgumentSchema argument,
IReadOnlyList<string> values) => argument switch
{
CommandParameterSchema parameterSchema => CannotConvertMultipleValuesToNonScalar(parameterSchema, values),
CommandOptionSchema optionSchema => CannotConvertMultipleValuesToNonScalar(optionSchema, values),
_ => throw new ArgumentOutOfRangeException(nameof(argumentSchema))
CommandParameterSchema parameter => CannotConvertMultipleValuesToNonScalar(parameter, values),
CommandOptionSchema option => CannotConvertMultipleValuesToNonScalar(option, values),
_ => throw new ArgumentOutOfRangeException(nameof(argument))
};
internal static CliFxException CannotConvertToType(
CommandParameterSchema parameterSchema,
CommandParameterSchema parameter,
string? value,
Type type,
Exception? innerException = null)
{
var message = $@"
Can't convert value ""{value ?? "<null>"}"" to type '{type.Name}' for parameter {parameterSchema.GetUserFacingDisplayString()}.
Can't convert value ""{value ?? "<null>"}"" to type '{type.Name}' for parameter {parameter.GetUserFacingDisplayString()}.
{innerException?.Message ?? "This type is not supported."}";
return new CliFxException(message.Trim(), innerException, showHelp: true);
return new CliFxException(message.Trim(), innerException);
}
internal static CliFxException CannotConvertToType(
CommandOptionSchema optionSchema,
CommandOptionSchema option,
string? value,
Type type,
Exception? innerException = null)
{
var message = $@"
Can't convert value ""{value ?? "<null>"}"" to type '{type.Name}' for option {optionSchema.GetUserFacingDisplayString()}.
Can't convert value ""{value ?? "<null>"}"" to type '{type.Name}' for option {option.GetUserFacingDisplayString()}.
{innerException?.Message ?? "This type is not supported."}";
return new CliFxException(message.Trim(), innerException, showHelp: true);
return new CliFxException(message.Trim(), innerException);
}
internal static CliFxException CannotConvertToType(
CommandArgumentSchema argumentSchema,
CommandArgumentSchema argument,
string? value,
Type type,
Exception? innerException = null) => argumentSchema switch
Exception? innerException = null) => argument switch
{
CommandParameterSchema parameterSchema => CannotConvertToType(parameterSchema, value, type, innerException),
CommandOptionSchema optionSchema => CannotConvertToType(optionSchema, value, type, innerException),
_ => throw new ArgumentOutOfRangeException(nameof(argumentSchema))
CommandParameterSchema parameter => CannotConvertToType(parameter, value, type, innerException),
CommandOptionSchema option => CannotConvertToType(option, value, type, innerException),
_ => throw new ArgumentOutOfRangeException(nameof(argument))
};
internal static CliFxException CannotConvertNonScalar(
CommandParameterSchema parameterSchema,
CommandParameterSchema parameter,
IReadOnlyList<string> values,
Type type)
{
var message = $@"
Can't convert provided values to type '{type.Name}' for parameter {parameterSchema.GetUserFacingDisplayString()}:
Can't convert provided values to type '{type.Name}' for parameter {parameter.GetUserFacingDisplayString()}:
{values.Select(v => v.Quote()).JoinToString(" ")}
Target type is not assignable from array and doesn't have a public constructor that takes an array.";
return new CliFxException(message.Trim(), showHelp: true);
return new CliFxException(message.Trim());
}
internal static CliFxException CannotConvertNonScalar(
CommandOptionSchema optionSchema,
CommandOptionSchema option,
IReadOnlyList<string> values,
Type type)
{
var message = $@"
Can't convert provided values to type '{type.Name}' for option {optionSchema.GetUserFacingDisplayString()}:
Can't convert provided values to type '{type.Name}' for option {option.GetUserFacingDisplayString()}:
{values.Select(v => v.Quote()).JoinToString(" ")}
Target type is not assignable from array and doesn't have a public constructor that takes an array.";
return new CliFxException(message.Trim(), showHelp: true);
return new CliFxException(message.Trim());
}
internal static CliFxException CannotConvertNonScalar(
CommandArgumentSchema argumentSchema,
CommandArgumentSchema argument,
IReadOnlyList<string> values,
Type type) => argumentSchema switch
Type type) => argument switch
{
CommandParameterSchema parameterSchema => CannotConvertNonScalar(parameterSchema, values, type),
CommandOptionSchema optionSchema => CannotConvertNonScalar(optionSchema, values, type),
_ => throw new ArgumentOutOfRangeException(nameof(argumentSchema))
CommandParameterSchema parameter => CannotConvertNonScalar(parameter, values, type),
CommandOptionSchema option => CannotConvertNonScalar(option, values, type),
_ => throw new ArgumentOutOfRangeException(nameof(argument))
};
internal static CliFxException ParameterNotSet(CommandParameterSchema parameterSchema)
internal static CliFxException ParameterNotSet(CommandParameterSchema parameter)
{
var message = $@"
Missing value for parameter {parameterSchema.GetUserFacingDisplayString()}.";
Missing value for parameter {parameter.GetUserFacingDisplayString()}.";
return new CliFxException(message.Trim(), showHelp: true);
return new CliFxException(message.Trim());
}
internal static CliFxException RequiredOptionsNotSet(IReadOnlyList<CommandOptionSchema> optionSchemas)
internal static CliFxException RequiredOptionsNotSet(IReadOnlyList<CommandOptionSchema> options)
{
var message = $@"
Missing values for one or more required options:
{optionSchemas.Select(o => o.GetUserFacingDisplayString()).JoinToString(Environment.NewLine)}";
{options.Select(o => o.GetUserFacingDisplayString()).JoinToString(Environment.NewLine)}";
return new CliFxException(message.Trim(), showHelp: true);
return new CliFxException(message.Trim());
}
internal static CliFxException UnrecognizedParametersProvided(IReadOnlyList<CommandUnboundArgumentInput> argumentInputs)
internal static CliFxException UnrecognizedParametersProvided(IReadOnlyList<CommandParameterInput> parameterInputs)
{
var message = $@"
Unrecognized parameters provided:
{argumentInputs.Select(a => a.Value.Quote()).JoinToString(" ")}";
{parameterInputs.Select(p => p.Value).JoinToString(Environment.NewLine)}";
return new CliFxException(message.Trim(), showHelp: true);
return new CliFxException(message.Trim());
}
internal static CliFxException UnrecognizedOptionsProvided(IReadOnlyList<CommandOptionInput> optionInputs)
{
var message = $@"
Unrecognized options provided:
{optionInputs.Select(o => o.RawAlias).JoinToString(Environment.NewLine)}";
{optionInputs.Select(o => o.GetRawAlias()).JoinToString(Environment.NewLine)}";
return new CliFxException(message.Trim(), showHelp: true);
return new CliFxException(message.Trim());
}
}
}

View File

@@ -4,18 +4,36 @@ namespace CliFx.Exceptions
{
/// <summary>
/// Thrown when a command cannot proceed with normal execution due to an error.
/// Use this exception if you want to report an error that occured during execution of a command.
/// Use this exception if you want to report an error that occured during the execution of a command.
/// This exception also allows specifying exit code which will be returned to the calling process.
/// </summary>
public class CommandException : CliFxException
public class CommandException : Exception
{
private const int DefaultExitCode = -1;
private readonly bool _isMessageSet;
/// <summary>
/// Returns an exit code associated with this exception.
/// </summary>
public int ExitCode { get; }
/// <summary>
/// Whether to show the help text after handling this exception.
/// </summary>
public bool ShowHelp { get; }
/// <summary>
/// Initializes an instance of <see cref="CommandException"/>.
/// </summary>
public CommandException(string? message, Exception? innerException,
int exitCode = DefaultExitCode, bool showHelp = false)
: base(message, innerException, exitCode, showHelp)
public CommandException(string? message, Exception? innerException, int exitCode = DefaultExitCode, bool showHelp = false)
: base(message, innerException)
{
ExitCode = exitCode;
ShowHelp = showHelp;
// Message property has a fallback so it's never empty, hence why we need this check
_isMessageSet = !string.IsNullOrWhiteSpace(message);
}
/// <summary>
@@ -33,5 +51,10 @@ namespace CliFx.Exceptions
: this(null, exitCode, showHelp)
{
}
/// <inheritdoc />
public override string ToString() => _isMessageSet
? Message
: base.ToString();
}
}

View File

@@ -65,10 +65,15 @@ namespace CliFx
int CursorTop { get; set; }
/// <summary>
/// Provides a token that signals when application cancellation is requested.
/// Subsequent calls return the same token.
/// When working with system console, the user can request cancellation by issuing an interrupt signal (Ctrl+C).
/// Defers the application termination in case of a cancellation request and returns the token that represents it.
/// Subsequent calls to this method return the same token.
/// </summary>
/// <remarks>
/// When working with <see cref="SystemConsole"/>:<br/>
/// - Cancellation can be requested by the user by pressing Ctrl+C.<br/>
/// - Cancellation can only be deferred once, subsequent requests to cancel by the user will result in instant termination.<br/>
/// - Any code executing prior to calling this method is not cancellation-aware and as such will terminate instantly when cancellation is requested.
/// </remarks>
CancellationToken GetCancellationToken();
}

View File

@@ -0,0 +1,13 @@
using System.Collections.Generic;
namespace CliFx.Internal
{
internal static class CollectionExtensions
{
public static void RemoveRange<T>(this ICollection<T> source, IEnumerable<T> items)
{
foreach (var item in items)
source.Remove(item);
}
}
}

View File

@@ -3,6 +3,23 @@
// Polyfills to bridge the missing APIs in older versions of the framework/standard.
#if NETSTANDARD2_0
namespace System
{
using Linq;
internal static class Extensions
{
public static bool Contains(this string str, char c) =>
str.Any(i => i == c);
public static bool StartsWith(this string str, char c) =>
str.Length > 0 && str[0] == c;
public static bool EndsWith(this string str, char c) =>
str.Length > 0 && str[str.Length - 1] == c;
}
}
namespace System.Collections.Generic
{
internal static class Extensions
@@ -17,4 +34,15 @@ namespace System.Collections.Generic
dic.TryGetValue(key, out var result) ? result! : default!;
}
}
namespace System.Linq
{
using Collections.Generic;
internal static class Extensions
{
public static HashSet<T> ToHashSet<T>(this IEnumerable<T> source, IEqualityComparer<T> comparer) =>
new HashSet<T>(source, comparer);
}
}
#endif

View File

@@ -8,11 +8,6 @@ namespace CliFx.Internal
{
internal static class TypeExtensions
{
public static object? GetDefaultValue(this Type type) =>
type.IsValueType
? Activator.CreateInstance(type)
: null;
public static bool Implements(this Type type, Type interfaceType) => type.GetInterfaces().Contains(interfaceType);
public static Type? GetNullableUnderlyingType(this Type type) => Nullable.GetUnderlyingType(type);

View File

@@ -94,6 +94,7 @@ namespace CliFx
public partial class SystemConsole
{
// TODO: use StreamWriter.Synchronized?
private static StreamReader WrapInput(Stream? stream) =>
stream != null
? new StreamReader(stream, Console.InputEncoding, false)

View File

@@ -5,7 +5,7 @@ using System.Threading;
namespace CliFx
{
/// <summary>
/// Implementation of <see cref="IConsole"/> that routes data to specified streams.
/// Implementation of <see cref="IConsole"/> that routes all data to preconfigured streams.
/// Does not leak to system console in any way.
/// Use this class as a substitute for system console when running tests.
/// </summary>