diff --git a/CliFx.Demo/Commands/BookAddCommand.cs b/CliFx.Demo/Commands/BookAddCommand.cs index 5c0c14f..eac2746 100644 --- a/CliFx.Demo/Commands/BookAddCommand.cs +++ b/CliFx.Demo/Commands/BookAddCommand.cs @@ -14,7 +14,7 @@ namespace CliFx.Demo.Commands { private readonly LibraryService _libraryService; - [CommandOption("title", 't', IsRequired = true, Description = "Book title.")] + [CommandArgument(0, Name = "title", IsRequired = true, Description = "Book title.")] public string Title { get; set; } [CommandOption("author", 'a', IsRequired = true, Description = "Book author.")] diff --git a/CliFx.Tests/CliApplicationBuilderTests.cs b/CliFx.Tests/CliApplicationBuilderTests.cs index ffb187f..420b303 100644 --- a/CliFx.Tests/CliApplicationBuilderTests.cs +++ b/CliFx.Tests/CliApplicationBuilderTests.cs @@ -32,7 +32,7 @@ namespace CliFx.Tests .UseDescription("test") .UseConsole(new VirtualConsole(TextWriter.Null)) .UseCommandFactory(schema => (ICommand) Activator.CreateInstance(schema.Type!)!) - .UseCommandOptionInputConverter(new CommandOptionInputConverter()) + .UseCommandOptionInputConverter(new CommandInputConverter()) .UseEnvironmentVariablesProvider(new EnvironmentVariablesProviderStub()) .Build(); } diff --git a/CliFx.Tests/Services/CommandArgumentSchemasValidatorTests.cs b/CliFx.Tests/Services/CommandArgumentSchemasValidatorTests.cs new file mode 100644 index 0000000..92f1eb3 --- /dev/null +++ b/CliFx.Tests/Services/CommandArgumentSchemasValidatorTests.cs @@ -0,0 +1,132 @@ +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using CliFx.Models; +using CliFx.Services; +using FluentAssertions; +using NUnit.Framework; + +namespace CliFx.Tests.Services +{ + [TestFixture] + public class CommandArgumentSchemasValidatorTests + { + private static CommandArgumentSchema GetValidArgumentSchema(string propertyName, string name, bool isRequired, int order, string? description = null) + { + return new CommandArgumentSchema(typeof(TestCommand).GetProperty(propertyName)!, name, isRequired, description, order); + } + + private static IEnumerable GetTestCases_ValidatorTest() + { + // Validation should succeed when no arguments are supplied + yield return new TestCaseData(new ValidatorTest(new List(), true)); + + // Multiple sequence arguments + yield return new TestCaseData(new ValidatorTest( + new [] + { + GetValidArgumentSchema(nameof(TestCommand.EnumerableProperty), "A", false, 0), + GetValidArgumentSchema(nameof(TestCommand.EnumerableProperty), "B", false, 1) + }, false)); + + // Argument after sequence + yield return new TestCaseData(new ValidatorTest( + new [] + { + GetValidArgumentSchema(nameof(TestCommand.EnumerableProperty), "A", false, 0), + GetValidArgumentSchema(nameof(TestCommand.StringProperty), "B", false, 1) + }, false)); + yield return new TestCaseData(new ValidatorTest( + new [] + { + GetValidArgumentSchema(nameof(TestCommand.StringProperty), "B", false, 0), + GetValidArgumentSchema(nameof(TestCommand.EnumerableProperty), "A", false, 1) + }, true)); + + // Required arguments must appear before optional arguments + yield return new TestCaseData(new ValidatorTest( + new [] + { + GetValidArgumentSchema(nameof(TestCommand.StringProperty), "A", false, 0), + GetValidArgumentSchema(nameof(TestCommand.StringProperty), "B", true, 1) + }, false)); + yield return new TestCaseData(new ValidatorTest( + new [] + { + GetValidArgumentSchema(nameof(TestCommand.StringProperty), "A", false, 0), + GetValidArgumentSchema(nameof(TestCommand.StringProperty), "B", true, 1), + GetValidArgumentSchema(nameof(TestCommand.StringProperty), "C", false, 2), + }, false)); + yield return new TestCaseData(new ValidatorTest( + new [] + { + GetValidArgumentSchema(nameof(TestCommand.StringProperty), "A", true, 0), + GetValidArgumentSchema(nameof(TestCommand.StringProperty), "B", false, 1), + GetValidArgumentSchema(nameof(TestCommand.StringProperty), "C", true, 2), + }, false)); + yield return new TestCaseData(new ValidatorTest( + new [] + { + GetValidArgumentSchema(nameof(TestCommand.StringProperty), "A", true, 0), + GetValidArgumentSchema(nameof(TestCommand.StringProperty), "B", false, 1), + GetValidArgumentSchema(nameof(TestCommand.StringProperty), "C", false, 2), + }, true)); + + // Argument order must be unique + yield return new TestCaseData(new ValidatorTest( + new [] + { + GetValidArgumentSchema(nameof(TestCommand.StringProperty), "A", false, 0), + GetValidArgumentSchema(nameof(TestCommand.StringProperty), "B", false, 1), + GetValidArgumentSchema(nameof(TestCommand.StringProperty), "C", false, 2) + }, true)); + yield return new TestCaseData(new ValidatorTest( + new [] + { + GetValidArgumentSchema(nameof(TestCommand.StringProperty), "A", false, 0), + GetValidArgumentSchema(nameof(TestCommand.StringProperty), "B", false, 1), + GetValidArgumentSchema(nameof(TestCommand.StringProperty), "C", false, 1) + }, false)); + + // No arguments with the same name + yield return new TestCaseData(new ValidatorTest( + new [] + { + GetValidArgumentSchema(nameof(TestCommand.StringProperty), "A", false, 0), + GetValidArgumentSchema(nameof(TestCommand.EnumerableProperty), "A", false, 1) + }, false)); + } + + private class TestCommand + { + public IEnumerable EnumerableProperty { get; set; } + public string StringProperty { get; set; } + } + + public class ValidatorTest + { + public ValidatorTest(IReadOnlyCollection schemas, bool succeedsValidation) + { + Schemas = schemas; + SucceedsValidation = succeedsValidation; + } + + public IReadOnlyCollection Schemas { get; } + public bool SucceedsValidation { get; } + } + + [Test] + [TestCaseSource(nameof(GetTestCases_ValidatorTest))] + public void Validation_Test(ValidatorTest testCase) + { + // Arrange + var validator = new CommandArgumentSchemasValidator(); + + // Act + var result = validator.ValidateArgumentSchemas(testCase.Schemas); + + // Assert + result.Any().Should().Be(!testCase.SucceedsValidation); + } + } +} \ No newline at end of file diff --git a/CliFx.Tests/Services/CommandFactoryTests.cs b/CliFx.Tests/Services/CommandFactoryTests.cs index 81747c3..14c2b54 100644 --- a/CliFx.Tests/Services/CommandFactoryTests.cs +++ b/CliFx.Tests/Services/CommandFactoryTests.cs @@ -13,7 +13,7 @@ namespace CliFx.Tests.Services public class CommandFactoryTests { private static CommandSchema GetCommandSchema(Type commandType) => - new CommandSchemaResolver().GetCommandSchemas(new[] {commandType}).Single(); + new CommandSchemaResolver(new CommandArgumentSchemasValidator()).GetCommandSchemas(new[] {commandType}).Single(); private static IEnumerable GetTestCases_CreateCommand() { diff --git a/CliFx.Tests/Services/CommandInitializerTests.cs b/CliFx.Tests/Services/CommandInitializerTests.cs index ad9df93..f12a478 100644 --- a/CliFx.Tests/Services/CommandInitializerTests.cs +++ b/CliFx.Tests/Services/CommandInitializerTests.cs @@ -16,144 +16,215 @@ namespace CliFx.Tests.Services public class CommandInitializerTests { private static CommandSchema GetCommandSchema(Type commandType) => - new CommandSchemaResolver().GetCommandSchemas(new[] { commandType }).Single(); + new CommandSchemaResolver(new CommandArgumentSchemasValidator()).GetCommandSchemas(new[] { commandType }).Single(); private static IEnumerable GetTestCases_InitializeCommand() { yield return new TestCaseData( new DivideCommand(), - GetCommandSchema(typeof(DivideCommand)), - new CommandInput("div", new[] - { - new CommandOptionInput("dividend", "13"), - new CommandOptionInput("divisor", "8") - }), + new CommandCandidate( + GetCommandSchema(typeof(DivideCommand)), + new string[0], + new CommandInput(new[] { "div" }, new[] + { + new CommandOptionInput("dividend", "13"), + new CommandOptionInput("divisor", "8") + })), new DivideCommand { Dividend = 13, Divisor = 8 } ); yield return new TestCaseData( new DivideCommand(), - GetCommandSchema(typeof(DivideCommand)), - new CommandInput("div", new[] - { - new CommandOptionInput("dividend", "13"), - new CommandOptionInput("d", "8") - }), + new CommandCandidate( + GetCommandSchema(typeof(DivideCommand)), + new string[0], + new CommandInput(new[] { "div" }, new[] + { + new CommandOptionInput("dividend", "13"), + new CommandOptionInput("d", "8") + })), new DivideCommand { Dividend = 13, Divisor = 8 } ); yield return new TestCaseData( new DivideCommand(), - GetCommandSchema(typeof(DivideCommand)), - new CommandInput("div", new[] - { - new CommandOptionInput("D", "13"), - new CommandOptionInput("d", "8") - }), + new CommandCandidate( + GetCommandSchema(typeof(DivideCommand)), + new string[0], + new CommandInput(new[] { "div" }, new[] + { + new CommandOptionInput("D", "13"), + new CommandOptionInput("d", "8") + })), new DivideCommand { Dividend = 13, Divisor = 8 } ); yield return new TestCaseData( new ConcatCommand(), - GetCommandSchema(typeof(ConcatCommand)), - new CommandInput("concat", new[] - { - new CommandOptionInput("i", new[] {"foo", " ", "bar"}) - }), + new CommandCandidate( + GetCommandSchema(typeof(ConcatCommand)), + new string[0], + new CommandInput(new[] { "concat" }, new[] + { + new CommandOptionInput("i", new[] { "foo", " ", "bar" }) + })), new ConcatCommand { Inputs = new[] { "foo", " ", "bar" } } ); yield return new TestCaseData( new ConcatCommand(), - GetCommandSchema(typeof(ConcatCommand)), - new CommandInput("concat", new[] - { - new CommandOptionInput("i", new[] {"foo", "bar"}), - new CommandOptionInput("s", " ") - }), + new CommandCandidate( + GetCommandSchema(typeof(ConcatCommand)), + new string[0], + new CommandInput(new[] { "concat" }, new[] + { + new CommandOptionInput("i", new[] { "foo", "bar" }), + new CommandOptionInput("s", " ") + })), new ConcatCommand { Inputs = new[] { "foo", "bar" }, Separator = " " } ); //Will read a value from environment variables because none is supplied via CommandInput yield return new TestCaseData( new EnvironmentVariableCommand(), - GetCommandSchema(typeof(EnvironmentVariableCommand)), - new CommandInput(null, new CommandOptionInput[0], EnvironmentVariablesProviderStub.EnvironmentVariables), + new CommandCandidate( + GetCommandSchema(typeof(EnvironmentVariableCommand)), + new string[0], + new CommandInput(new string[0], new CommandOptionInput[0], EnvironmentVariablesProviderStub.EnvironmentVariables)), new EnvironmentVariableCommand { Option = "A" } ); //Will read multiple values from environment variables because none is supplied via CommandInput yield return new TestCaseData( new EnvironmentVariableWithMultipleValuesCommand(), - GetCommandSchema(typeof(EnvironmentVariableWithMultipleValuesCommand)), - new CommandInput(null, new CommandOptionInput[0], EnvironmentVariablesProviderStub.EnvironmentVariables), + new CommandCandidate( + GetCommandSchema(typeof(EnvironmentVariableWithMultipleValuesCommand)), + new string[0], + new CommandInput(new string[0], new CommandOptionInput[0], EnvironmentVariablesProviderStub.EnvironmentVariables)), new EnvironmentVariableWithMultipleValuesCommand { Option = new[] { "A", "B", "C" } } ); //Will not read a value from environment variables because one is supplied via CommandInput yield return new TestCaseData( new EnvironmentVariableCommand(), - GetCommandSchema(typeof(EnvironmentVariableCommand)), - new CommandInput(null, new[] - { - new CommandOptionInput("opt", new[] { "X" }) - }, - EnvironmentVariablesProviderStub.EnvironmentVariables), + new CommandCandidate( + GetCommandSchema(typeof(EnvironmentVariableCommand)), + new string[0], + new CommandInput(new string[0], new[] + { + new CommandOptionInput("opt", new[] { "X" }) + }, + EnvironmentVariablesProviderStub.EnvironmentVariables)), new EnvironmentVariableCommand { Option = "X" } ); //Will not split environment variable values because underlying property is not a collection yield return new TestCaseData( new EnvironmentVariableWithoutCollectionPropertyCommand(), - GetCommandSchema(typeof(EnvironmentVariableWithoutCollectionPropertyCommand)), - new CommandInput(null, new CommandOptionInput[0], EnvironmentVariablesProviderStub.EnvironmentVariables), - new EnvironmentVariableWithoutCollectionPropertyCommand { Option = $"A{Path.PathSeparator}B{Path.PathSeparator}C{Path.PathSeparator}" } - ); + new CommandCandidate( + GetCommandSchema(typeof(EnvironmentVariableWithoutCollectionPropertyCommand)), + new string[0], + new CommandInput(new string[0], new CommandOptionInput[0], EnvironmentVariablesProviderStub.EnvironmentVariables)), + new EnvironmentVariableWithoutCollectionPropertyCommand { Option = $"A{Path.PathSeparator}B{Path.PathSeparator}C{Path.PathSeparator}" } + ); + + // Positional arguments + yield return new TestCaseData( + new ArgumentCommand(), + new CommandCandidate( + GetCommandSchema(typeof(ArgumentCommand)), + new [] { "abc", "123", "1", "2" }, + new CommandInput(new [] { "arg", "cmd", "abc", "123", "1", "2" }, new []{ new CommandOptionInput("o", "option value") }, new Dictionary())), + new ArgumentCommand { FirstArgument = "abc", SecondArgument = 123, ThirdArguments = new List{1, 2}, Option = "option value" } + ); + yield return new TestCaseData( + new ArgumentCommand(), + new CommandCandidate( + GetCommandSchema(typeof(ArgumentCommand)), + new [] { "abc" }, + new CommandInput(new [] { "arg", "cmd", "abc" }, new []{ new CommandOptionInput("o", "option value") }, new Dictionary())), + new ArgumentCommand { FirstArgument = "abc", Option = "option value" } + ); } private static IEnumerable GetTestCases_InitializeCommand_Negative() { yield return new TestCaseData( new DivideCommand(), - GetCommandSchema(typeof(DivideCommand)), - new CommandInput("div") - ); + new CommandCandidate( + GetCommandSchema(typeof(DivideCommand)), + new string[0], + new CommandInput(new[] { "div" }) + )); yield return new TestCaseData( new DivideCommand(), - GetCommandSchema(typeof(DivideCommand)), - new CommandInput("div", new[] - { - new CommandOptionInput("D", "13") - }) - ); + new CommandCandidate( + GetCommandSchema(typeof(DivideCommand)), + new string[0], + new CommandInput(new[] { "div" }, new[] + { + new CommandOptionInput("D", "13") + }) + )); yield return new TestCaseData( new ConcatCommand(), - GetCommandSchema(typeof(ConcatCommand)), - new CommandInput("concat") - ); + new CommandCandidate( + GetCommandSchema(typeof(ConcatCommand)), + new string[0], + new CommandInput(new[] { "concat" }) + )); yield return new TestCaseData( new ConcatCommand(), - GetCommandSchema(typeof(ConcatCommand)), - new CommandInput("concat", new[] - { - new CommandOptionInput("s", "_") - }) - ); + new CommandCandidate( + GetCommandSchema(typeof(ConcatCommand)), + new string[0], + new CommandInput(new[] { "concat" }, new[] + { + new CommandOptionInput("s", "_") + }) + )); + + // Missing required positional argument + yield return new TestCaseData( + new ArgumentCommand(), + new CommandCandidate( + GetCommandSchema(typeof(ArgumentCommand)), + new string[0], + new CommandInput(new string[0], new []{ new CommandOptionInput("o", "option value") }, new Dictionary())) + ); + + // Incorrect data type in list + yield return new TestCaseData( + new ArgumentCommand(), + new CommandCandidate( + GetCommandSchema(typeof(ArgumentCommand)), + new []{ "abc", "123", "invalid" }, + new CommandInput(new [] { "arg", "cmd", "abc", "123", "invalid" }, new []{ new CommandOptionInput("o", "option value") }, new Dictionary())) + ); + + // Extraneous unused arguments + yield return new TestCaseData( + new SimpleArgumentCommand(), + new CommandCandidate( + GetCommandSchema(typeof(SimpleArgumentCommand)), + new []{ "abc", "123", "unused" }, + new CommandInput(new [] { "arg", "cmd2", "abc", "123", "unused" }, new []{ new CommandOptionInput("o", "option value") }, new Dictionary())) + ); } [Test] [TestCaseSource(nameof(GetTestCases_InitializeCommand))] - public void InitializeCommand_Test(ICommand command, CommandSchema commandSchema, CommandInput commandInput, + public void InitializeCommand_Test(ICommand command, CommandCandidate commandCandidate, ICommand expectedCommand) { // Arrange var initializer = new CommandInitializer(); // Act - initializer.InitializeCommand(command, commandSchema, commandInput); + initializer.InitializeCommand(command, commandCandidate); // Assert command.Should().BeEquivalentTo(expectedCommand, o => o.RespectingRuntimeTypes()); @@ -161,13 +232,13 @@ namespace CliFx.Tests.Services [Test] [TestCaseSource(nameof(GetTestCases_InitializeCommand_Negative))] - public void InitializeCommand_Negative_Test(ICommand command, CommandSchema commandSchema, CommandInput commandInput) + public void InitializeCommand_Negative_Test(ICommand command, CommandCandidate commandCandidate) { // Arrange var initializer = new CommandInitializer(); // Act & Assert - initializer.Invoking(i => i.InitializeCommand(command, commandSchema, commandInput)) + initializer.Invoking(i => i.InitializeCommand(command, commandCandidate)) .Should().ThrowExactly(); } } diff --git a/CliFx.Tests/Services/CommandOptionInputConverterTests.cs b/CliFx.Tests/Services/CommandInputConverterTests.cs similarity index 98% rename from CliFx.Tests/Services/CommandOptionInputConverterTests.cs rename to CliFx.Tests/Services/CommandInputConverterTests.cs index 3eee3e7..585199d 100644 --- a/CliFx.Tests/Services/CommandOptionInputConverterTests.cs +++ b/CliFx.Tests/Services/CommandInputConverterTests.cs @@ -12,7 +12,7 @@ using NUnit.Framework; namespace CliFx.Tests.Services { [TestFixture] - public class CommandOptionInputConverterTests + public class CommandInputConverterTests { private static IEnumerable GetTestCases_ConvertOptionInput() { @@ -298,7 +298,7 @@ namespace CliFx.Tests.Services object expectedConvertedValue) { // Arrange - var converter = new CommandOptionInputConverter(); + var converter = new CommandInputConverter(); // Act var convertedValue = converter.ConvertOptionInput(optionInput, targetType); @@ -313,7 +313,7 @@ namespace CliFx.Tests.Services public void ConvertOptionInput_Negative_Test(CommandOptionInput optionInput, Type targetType) { // Arrange - var converter = new CommandOptionInputConverter(); + var converter = new CommandInputConverter(); // Act & Assert converter.Invoking(c => c.ConvertOptionInput(optionInput, targetType)) diff --git a/CliFx.Tests/Services/CommandInputParserTests.cs b/CliFx.Tests/Services/CommandInputParserTests.cs index c1a47dd..1d886aa 100644 --- a/CliFx.Tests/Services/CommandInputParserTests.cs +++ b/CliFx.Tests/Services/CommandInputParserTests.cs @@ -158,13 +158,13 @@ namespace CliFx.Tests.Services yield return new TestCaseData( new[] { "command" }, - new CommandInput("command"), + new CommandInput(new []{ "command" }), new EmptyEnvironmentVariablesProviderStub() ); yield return new TestCaseData( new[] { "command", "--option", "value" }, - new CommandInput("command", new[] + new CommandInput(new []{ "command" }, new[] { new CommandOptionInput("option", "value") }), @@ -173,13 +173,13 @@ namespace CliFx.Tests.Services yield return new TestCaseData( new[] { "long", "command", "name" }, - new CommandInput("long command name"), + new CommandInput(new []{ "long", "command", "name"}), new EmptyEnvironmentVariablesProviderStub() ); yield return new TestCaseData( new[] { "long", "command", "name", "--option", "value" }, - new CommandInput("long command name", new[] + new CommandInput(new []{ "long", "command", "name" }, new[] { new CommandOptionInput("option", "value") }), @@ -188,7 +188,7 @@ namespace CliFx.Tests.Services yield return new TestCaseData( new[] { "[debug]" }, - new CommandInput(null, + new CommandInput(new string[0], new[] { "debug" }, new CommandOptionInput[0]), new EmptyEnvironmentVariablesProviderStub() @@ -196,7 +196,7 @@ namespace CliFx.Tests.Services yield return new TestCaseData( new[] { "[debug]", "[preview]" }, - new CommandInput(null, + new CommandInput(new string[0], new[] { "debug", "preview" }, new CommandOptionInput[0]), new EmptyEnvironmentVariablesProviderStub() @@ -204,7 +204,7 @@ namespace CliFx.Tests.Services yield return new TestCaseData( new[] { "[debug]", "[preview]", "-o", "value" }, - new CommandInput(null, + new CommandInput(new string[0], new[] { "debug", "preview" }, new[] { @@ -215,7 +215,7 @@ namespace CliFx.Tests.Services yield return new TestCaseData( new[] { "command", "[debug]", "[preview]", "-o", "value" }, - new CommandInput("command", + new CommandInput(new []{"command"}, new[] { "debug", "preview" }, new[] { @@ -226,7 +226,7 @@ namespace CliFx.Tests.Services yield return new TestCaseData( new[] { "command", "[debug]", "[preview]", "-o", "value" }, - new CommandInput("command", + new CommandInput(new []{ "command"}, new[] { "debug", "preview" }, new[] { diff --git a/CliFx.Tests/Services/CommandSchemaResolverTests.cs b/CliFx.Tests/Services/CommandSchemaResolverTests.cs index d74bd3e..3203634 100644 --- a/CliFx.Tests/Services/CommandSchemaResolverTests.cs +++ b/CliFx.Tests/Services/CommandSchemaResolverTests.cs @@ -19,7 +19,7 @@ namespace CliFx.Tests.Services new[] { new CommandSchema(typeof(DivideCommand), "div", "Divide one number by another.", - new[] + new CommandArgumentSchema[0], new[] { new CommandOptionSchema(typeof(DivideCommand).GetProperty(nameof(DivideCommand.Dividend)), "dividend", 'D', true, "The number to divide.", null), @@ -27,6 +27,7 @@ namespace CliFx.Tests.Services "divisor", 'd', true, "The number to divide by.", null) }), new CommandSchema(typeof(ConcatCommand), "concat", "Concatenate strings.", + new CommandArgumentSchema[0], new[] { new CommandOptionSchema(typeof(ConcatCommand).GetProperty(nameof(ConcatCommand.Inputs)), @@ -35,6 +36,7 @@ namespace CliFx.Tests.Services null, 's', false, "String separator.", null) }), new CommandSchema(typeof(EnvironmentVariableCommand), null, "Reads option values from environment variables.", + new CommandArgumentSchema[0], new[] { new CommandOptionSchema(typeof(EnvironmentVariableCommand).GetProperty(nameof(EnvironmentVariableCommand.Option)), @@ -48,7 +50,7 @@ namespace CliFx.Tests.Services new[] { typeof(HelloWorldDefaultCommand) }, new[] { - new CommandSchema(typeof(HelloWorldDefaultCommand), null, null, new CommandOptionSchema[0]) + new CommandSchema(typeof(HelloWorldDefaultCommand), null, null, new CommandArgumentSchema[0], new CommandOptionSchema[0]) } ); } @@ -62,37 +64,192 @@ namespace CliFx.Tests.Services yield return new TestCaseData(new object[] { - new[] {typeof(NonImplementedCommand)} + new[] { typeof(NonImplementedCommand) } }); yield return new TestCaseData(new object[] { - new[] {typeof(NonAnnotatedCommand)} + new[] { typeof(NonAnnotatedCommand) } }); yield return new TestCaseData(new object[] { - new[] {typeof(DuplicateOptionNamesCommand)} + new[] { typeof(DuplicateOptionNamesCommand) } }); yield return new TestCaseData(new object[] { - new[] {typeof(DuplicateOptionShortNamesCommand)} + new[] { typeof(DuplicateOptionShortNamesCommand) } }); yield return new TestCaseData(new object[] { - new[] {typeof(ExceptionCommand), typeof(CommandExceptionCommand)} + new[] { typeof(ExceptionCommand), typeof(CommandExceptionCommand) } }); } + private static IEnumerable GetTestCases_GetTargetCommandSchema_Positive() + { + yield return new TestCaseData( + new [] + { + new CommandSchema(null, "command1", null, null, null), + new CommandSchema(null, "command2", null, null, null), + new CommandSchema(null, "command3", null, null, null) + }, + new CommandInput(new[] { "command1", "argument1", "argument2" }), + new[] { "argument1", "argument2" }, + "command1" + ); + yield return new TestCaseData( + new [] + { + new CommandSchema(null, "", null, null, null), + new CommandSchema(null, "command1", null, null, null), + new CommandSchema(null, "command2", null, null, null), + new CommandSchema(null, "command3", null, null, null) + }, + new CommandInput(new[] { "argument1", "argument2" }), + new[] { "argument1", "argument2" }, + "" + ); + yield return new TestCaseData( + new [] + { + new CommandSchema(null, "command1 subcommand1", null, null, null), + }, + new CommandInput(new[] { "command1", "subcommand1", "argument1" }), + new[] { "argument1" }, + "command1 subcommand1" + ); + yield return new TestCaseData( + new [] + { + new CommandSchema(null, "", null, null, null), + new CommandSchema(null, "a", null, null, null), + new CommandSchema(null, "a b", null, null, null), + new CommandSchema(null, "a b c", null, null, null), + new CommandSchema(null, "b", null, null, null), + new CommandSchema(null, "b c", null, null, null), + new CommandSchema(null, "c", null, null, null), + }, + new CommandInput(new[] { "a", "b", "d" }), + new[] { "d" }, + "a b" + ); + yield return new TestCaseData( + new [] + { + new CommandSchema(null, "", null, null, null), + new CommandSchema(null, "a", null, null, null), + new CommandSchema(null, "a b", null, null, null), + new CommandSchema(null, "a b c", null, null, null), + new CommandSchema(null, "b", null, null, null), + new CommandSchema(null, "b c", null, null, null), + new CommandSchema(null, "c", null, null, null), + }, + new CommandInput(new[] { "a", "b", "c", "d" }), + new[] { "d" }, + "a b c" + ); + yield return new TestCaseData( + new [] + { + new CommandSchema(null, "", null, null, null), + new CommandSchema(null, "a", null, null, null), + new CommandSchema(null, "a b", null, null, null), + new CommandSchema(null, "a b c", null, null, null), + new CommandSchema(null, "b", null, null, null), + new CommandSchema(null, "b c", null, null, null), + new CommandSchema(null, "c", null, null, null), + }, + new CommandInput(new[] { "b", "c" }), + new string[0], + "b c" + ); + yield return new TestCaseData( + new [] + { + new CommandSchema(null, "", null, null, null), + new CommandSchema(null, "a", null, null, null), + new CommandSchema(null, "a b", null, null, null), + new CommandSchema(null, "a b c", null, null, null), + new CommandSchema(null, "b", null, null, null), + new CommandSchema(null, "b c", null, null, null), + new CommandSchema(null, "c", null, null, null), + }, + new CommandInput(new[] { "d", "a", "b"}), + new[] { "d", "a", "b" }, + "" + ); + yield return new TestCaseData( + new [] + { + new CommandSchema(null, "", null, null, null), + new CommandSchema(null, "a", null, null, null), + new CommandSchema(null, "a b", null, null, null), + new CommandSchema(null, "a b c", null, null, null), + new CommandSchema(null, "b", null, null, null), + new CommandSchema(null, "b c", null, null, null), + new CommandSchema(null, "c", null, null, null), + }, + new CommandInput(new[] { "a", "b c", "d" }), + new[] { "b c", "d" }, + "a" + ); + yield return new TestCaseData( + new [] + { + new CommandSchema(null, "", null, null, null), + new CommandSchema(null, "a", null, null, null), + new CommandSchema(null, "a b", null, null, null), + new CommandSchema(null, "a b c", null, null, null), + new CommandSchema(null, "b", null, null, null), + new CommandSchema(null, "b c", null, null, null), + new CommandSchema(null, "c", null, null, null), + }, + new CommandInput(new[] { "a b", "c", "d" }), + new[] { "a b", "c", "d" }, + "" + ); + } + + private static IEnumerable GetTestCases_GetTargetCommandSchema_Negative() + { + yield return new TestCaseData( + new [] + { + new CommandSchema(null, "command1", null, null, null), + new CommandSchema(null, "command2", null, null, null), + new CommandSchema(null, "command3", null, null, null), + }, + new CommandInput(new[] { "command4", "argument1" }) + ); + yield return new TestCaseData( + new [] + { + new CommandSchema(null, "command1", null, null, null), + new CommandSchema(null, "command2", null, null, null), + new CommandSchema(null, "command3", null, null, null), + }, + new CommandInput(new[] { "argument1" }) + ); + yield return new TestCaseData( + new [] + { + new CommandSchema(null, "command1 subcommand1", null, null, null), + }, + new CommandInput(new[] { "command1", "argument1" }) + ); + } + [Test] [TestCaseSource(nameof(GetTestCases_GetCommandSchemas))] public void GetCommandSchemas_Test(IReadOnlyList commandTypes, IReadOnlyList expectedCommandSchemas) { // Arrange - var commandSchemaResolver = new CommandSchemaResolver(); + var commandSchemaResolver = new CommandSchemaResolver(new CommandArgumentSchemasValidator()); // Act var commandSchemas = commandSchemaResolver.GetCommandSchemas(commandTypes); @@ -106,11 +263,44 @@ namespace CliFx.Tests.Services public void GetCommandSchemas_Negative_Test(IReadOnlyList commandTypes) { // Arrange - var resolver = new CommandSchemaResolver(); + var resolver = new CommandSchemaResolver(new CommandArgumentSchemasValidator()); // Act & Assert resolver.Invoking(r => r.GetCommandSchemas(commandTypes)) .Should().ThrowExactly(); } + + [Test] + [TestCaseSource(nameof(GetTestCases_GetTargetCommandSchema_Positive))] + public void GetTargetCommandSchema_Positive_Test(IReadOnlyList availableCommandSchemas, + CommandInput commandInput, + IReadOnlyList expectedPositionalArguments, + string expectedCommandSchemaName) + { + // Arrange + var resolver = new CommandSchemaResolver(new CommandArgumentSchemasValidator()); + + // Act + var commandCandidate = resolver.GetTargetCommandSchema(availableCommandSchemas, commandInput); + + // Assert + commandCandidate.Should().NotBeNull(); + commandCandidate.PositionalArgumentsInput.Should().BeEquivalentTo(expectedPositionalArguments); + commandCandidate.Schema.Name.Should().Be(expectedCommandSchemaName); + } + + [Test] + [TestCaseSource(nameof(GetTestCases_GetTargetCommandSchema_Negative))] + public void GetTargetCommandSchema_Negative_Test(IReadOnlyList availableCommandSchemas, CommandInput commandInput) + { + // Arrange + var resolver = new CommandSchemaResolver(new CommandArgumentSchemasValidator()); + + // Act + var commandCandidate = resolver.GetTargetCommandSchema(availableCommandSchemas, commandInput); + + // Assert + commandCandidate.Should().BeNull(); + } } } \ No newline at end of file diff --git a/CliFx.Tests/Services/DelegateCommandFactoryTests.cs b/CliFx.Tests/Services/DelegateCommandFactoryTests.cs index 31183c5..8bc13be 100644 --- a/CliFx.Tests/Services/DelegateCommandFactoryTests.cs +++ b/CliFx.Tests/Services/DelegateCommandFactoryTests.cs @@ -13,7 +13,7 @@ namespace CliFx.Tests.Services public class DelegateCommandFactoryTests { private static CommandSchema GetCommandSchema(Type commandType) => - new CommandSchemaResolver().GetCommandSchemas(new[] {commandType}).Single(); + new CommandSchemaResolver(new CommandArgumentSchemasValidator()).GetCommandSchemas(new[] {commandType}).Single(); private static IEnumerable GetTestCases_CreateCommand() { diff --git a/CliFx.Tests/Services/HelpTextRendererTests.cs b/CliFx.Tests/Services/HelpTextRendererTests.cs index ade3af9..b29c9fc 100644 --- a/CliFx.Tests/Services/HelpTextRendererTests.cs +++ b/CliFx.Tests/Services/HelpTextRendererTests.cs @@ -15,7 +15,7 @@ namespace CliFx.Tests.Services { private static HelpTextSource CreateHelpTextSource(IReadOnlyList availableCommandTypes, Type targetCommandType) { - var commandSchemaResolver = new CommandSchemaResolver(); + var commandSchemaResolver = new CommandSchemaResolver(new CommandArgumentSchemasValidator()); var applicationMetadata = new ApplicationMetadata("TestApp", "testapp", "1.0", null); var availableCommandSchemas = commandSchemaResolver.GetCommandSchemas(availableCommandTypes); @@ -85,6 +85,27 @@ namespace CliFx.Tests.Services "-h|--help", "Shows help text." } ); + + yield return new TestCaseData( + CreateHelpTextSource( + new[] {typeof(ArgumentCommand)}, + typeof(ArgumentCommand)), + + new[] + { + "Description", + "Command using positional arguments", + "Usage", + "arg cmd", "", "[]", "[]", "[options]", + "Arguments", + "* first", + "secondargument", + "third list", "A list of numbers", + "Options", + "-o|--option", + "-h|--help", "Shows help text." + } + ); } [Test] diff --git a/CliFx.Tests/TestCommands/ArgumentCommand.cs b/CliFx.Tests/TestCommands/ArgumentCommand.cs new file mode 100644 index 0000000..4065fe3 --- /dev/null +++ b/CliFx.Tests/TestCommands/ArgumentCommand.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using CliFx.Attributes; +using CliFx.Services; + +namespace CliFx.Tests.TestCommands +{ + [Command("arg cmd", Description = "Command using positional arguments")] + public class ArgumentCommand : ICommand + { + [CommandArgument(0, IsRequired = true, Name = "first")] + public string? FirstArgument { get; set; } + + [CommandArgument(10)] + public int? SecondArgument { get; set; } + + [CommandArgument(20, Description = "A list of numbers", Name = "third list")] + public IEnumerable ThirdArguments { get; set; } + + [CommandOption("option", 'o')] + public string Option { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; + } +} \ No newline at end of file diff --git a/CliFx.Tests/TestCommands/SimpleArgumentCommand.cs b/CliFx.Tests/TestCommands/SimpleArgumentCommand.cs new file mode 100644 index 0000000..cf5a2cb --- /dev/null +++ b/CliFx.Tests/TestCommands/SimpleArgumentCommand.cs @@ -0,0 +1,21 @@ +using System.Threading.Tasks; +using CliFx.Attributes; +using CliFx.Services; + +namespace CliFx.Tests.TestCommands +{ + [Command("arg cmd2", Description = "Command using positional arguments")] + public class SimpleArgumentCommand : ICommand + { + [CommandArgument(0, IsRequired = true, Name = "first")] + public string? FirstArgument { get; set; } + + [CommandArgument(10)] + public int? SecondArgument { get; set; } + + [CommandOption("option", 'o')] + public string Option { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; + } +} \ No newline at end of file diff --git a/CliFx/Attributes/CommandArgumentAttribute.cs b/CliFx/Attributes/CommandArgumentAttribute.cs new file mode 100644 index 0000000..d6aed66 --- /dev/null +++ b/CliFx/Attributes/CommandArgumentAttribute.cs @@ -0,0 +1,42 @@ +using System; + +namespace CliFx.Attributes +{ + /// + /// Annotates a property that defines a command argument. + /// + [AttributeUsage(AttributeTargets.Property)] + public class CommandArgumentAttribute : Attribute + { + /// + /// The name of the argument, which is used in help text. + /// + public string? Name { get; set; } + + /// + /// Whether the argument is required. + /// + public bool IsRequired { get; set; } + + /// + /// Argument description, which is used in help text. + /// + public string? Description { get; set; } + + /// + /// The ordering of the argument. Lower values will appear before higher values. + /// + /// Two arguments of the same command cannot have the same . + /// + /// + public int Order { get; } + + /// + /// Initializes an instance of with a given order. + /// + public CommandArgumentAttribute(int order) + { + Order = order; + } + } +} \ No newline at end of file diff --git a/CliFx/CliApplication.cs b/CliFx/CliApplication.cs index 7159e9f..417d62a 100644 --- a/CliFx/CliApplication.cs +++ b/CliFx/CliApplication.cs @@ -1,10 +1,8 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using System.Threading.Tasks; using CliFx.Exceptions; -using CliFx.Internal; using CliFx.Models; using CliFx.Services; @@ -74,7 +72,7 @@ namespace CliFx return null; // Render command name - _console.Output.WriteLine($"Command name: {commandInput.CommandName}"); + _console.Output.WriteLine($"Arguments: {string.Join(" ", commandInput.Arguments)}"); _console.Output.WriteLine(); // Render directives @@ -103,7 +101,7 @@ namespace CliFx private int? HandleVersionOption(CommandInput commandInput) { // Version should be rendered if it was requested on a default command - var shouldRenderVersion = !commandInput.IsCommandSpecified() && commandInput.IsVersionOptionSpecified(); + var shouldRenderVersion = !commandInput.HasArguments() && commandInput.IsVersionOptionSpecified(); // If shouldn't render version, pass execution to the next handler if (!shouldRenderVersion) @@ -117,10 +115,10 @@ namespace CliFx } private int? HandleHelpOption(CommandInput commandInput, - IReadOnlyList availableCommandSchemas, CommandSchema? targetCommandSchema) + IReadOnlyList availableCommandSchemas, CommandCandidate? commandCandidate) { // Help should be rendered if it was requested, or when executing a command which isn't defined - var shouldRenderHelp = commandInput.IsHelpOptionSpecified() || targetCommandSchema == null; + var shouldRenderHelp = commandInput.IsHelpOptionSpecified() || commandCandidate == null; // If shouldn't render help, pass execution to the next handler if (!shouldRenderHelp) @@ -129,31 +127,22 @@ namespace CliFx // Keep track whether there was an error in the input var isError = false; - // If target command isn't defined, find its contextual replacement - if (targetCommandSchema == null) + // Report error if no command matched the arguments + if (commandCandidate is null) { - // If command was specified, inform the user that it's not defined - if (commandInput.IsCommandSpecified()) + // If a command was specified, inform the user that the command is not defined + if (commandInput.HasArguments()) { _console.WithForegroundColor(ConsoleColor.Red, - () => _console.Error.WriteLine($"Specified command [{commandInput.CommandName}] is not defined.")); - + () => _console.Error.WriteLine($"No command could be matched for input [{string.Join(" ", commandInput.Arguments)}]")); isError = true; } - // Replace target command with closest parent of specified command - targetCommandSchema = availableCommandSchemas.FindParent(commandInput.CommandName); - - // If there's no parent, replace with stub default command - if (targetCommandSchema == null) - { - targetCommandSchema = CommandSchema.StubDefaultCommand; - availableCommandSchemas = availableCommandSchemas.Concat(CommandSchema.StubDefaultCommand).ToArray(); - } + commandCandidate = new CommandCandidate(CommandSchema.StubDefaultCommand, new string[0], commandInput); } // Build help text source - var helpTextSource = new HelpTextSource(_metadata, availableCommandSchemas, targetCommandSchema); + var helpTextSource = new HelpTextSource(_metadata, availableCommandSchemas, commandCandidate.Schema); // Render help text _helpTextRenderer.RenderHelpText(_console, helpTextSource); @@ -162,13 +151,18 @@ namespace CliFx return isError ? -1 : 0; } - private async ValueTask HandleCommandExecutionAsync(CommandInput commandInput, CommandSchema targetCommandSchema) + private async ValueTask HandleCommandExecutionAsync(CommandCandidate? commandCandidate) { - // Create an instance of the command - var command = _commandFactory.CreateCommand(targetCommandSchema); + if (commandCandidate is null) + { + throw new ArgumentException("Cannot execute command because it was not found."); + } - // Populate command with options according to its schema - _commandInitializer.InitializeCommand(command, targetCommandSchema, commandInput); + // Create an instance of the command + var command = _commandFactory.CreateCommand(commandCandidate.Schema); + + // Populate command with options and arguments according to its schema + _commandInitializer.InitializeCommand(command, commandCandidate); // Execute command await command.ExecuteAsync(_console); @@ -189,15 +183,15 @@ namespace CliFx var availableCommandSchemas = _commandSchemaResolver.GetCommandSchemas(_configuration.CommandTypes); // Find command schema matching the name specified in the input - var targetCommandSchema = availableCommandSchemas.FindByName(commandInput.CommandName); + var commandCandidate = _commandSchemaResolver.GetTargetCommandSchema(availableCommandSchemas, commandInput); // Chain handlers until the first one that produces an exit code return await HandleDebugDirectiveAsync(commandInput) ?? HandlePreviewDirective(commandInput) ?? HandleVersionOption(commandInput) ?? - HandleHelpOption(commandInput, availableCommandSchemas, targetCommandSchema) ?? - await HandleCommandExecutionAsync(commandInput, targetCommandSchema!); + HandleHelpOption(commandInput, availableCommandSchemas, commandCandidate) ?? + await HandleCommandExecutionAsync(commandCandidate); } catch (Exception ex) { diff --git a/CliFx/CliApplicationBuilder.cs b/CliFx/CliApplicationBuilder.cs index 85bd103..999349c 100644 --- a/CliFx/CliApplicationBuilder.cs +++ b/CliFx/CliApplicationBuilder.cs @@ -25,7 +25,7 @@ namespace CliFx private string? _description; private IConsole? _console; private ICommandFactory? _commandFactory; - private ICommandOptionInputConverter? _commandOptionInputConverter; + private ICommandInputConverter? _commandInputConverter; private IEnvironmentVariablesProvider? _environmentVariablesProvider; /// @@ -107,9 +107,9 @@ namespace CliFx } /// - public ICliApplicationBuilder UseCommandOptionInputConverter(ICommandOptionInputConverter converter) + public ICliApplicationBuilder UseCommandOptionInputConverter(ICommandInputConverter converter) { - _commandOptionInputConverter = converter; + _commandInputConverter = converter; return this; } @@ -129,7 +129,7 @@ namespace CliFx _versionText ??= GetDefaultVersionText() ?? "v1.0"; _console ??= new SystemConsole(); _commandFactory ??= new CommandFactory(); - _commandOptionInputConverter ??= new CommandOptionInputConverter(); + _commandInputConverter ??= new CommandInputConverter(); _environmentVariablesProvider ??= new EnvironmentVariablesProvider(); // Project parameters to expected types @@ -137,8 +137,8 @@ namespace CliFx var configuration = new ApplicationConfiguration(_commandTypes.ToArray(), _isDebugModeAllowed, _isPreviewModeAllowed); return new CliApplication(metadata, configuration, - _console, new CommandInputParser(_environmentVariablesProvider), new CommandSchemaResolver(), - _commandFactory, new CommandInitializer(_commandOptionInputConverter, new EnvironmentVariablesParser()), new HelpTextRenderer()); + _console, new CommandInputParser(_environmentVariablesProvider), new CommandSchemaResolver(new CommandArgumentSchemasValidator()), + _commandFactory, new CommandInitializer(_commandInputConverter, new EnvironmentVariablesParser()), new HelpTextRenderer()); } } diff --git a/CliFx/ICliApplicationBuilder.cs b/CliFx/ICliApplicationBuilder.cs index 79d979d..e7aa2e2 100644 --- a/CliFx/ICliApplicationBuilder.cs +++ b/CliFx/ICliApplicationBuilder.cs @@ -60,9 +60,9 @@ namespace CliFx ICliApplicationBuilder UseCommandFactory(ICommandFactory factory); /// - /// Configures application to use specified implementation of . + /// Configures application to use specified implementation of . /// - ICliApplicationBuilder UseCommandOptionInputConverter(ICommandOptionInputConverter converter); + ICliApplicationBuilder UseCommandOptionInputConverter(ICommandInputConverter converter); /// /// Configures application to use specified implementation of . diff --git a/CliFx/Internal/Extensions.cs b/CliFx/Internal/Extensions.cs index e16f015..370f533 100644 --- a/CliFx/Internal/Extensions.cs +++ b/CliFx/Internal/Extensions.cs @@ -3,6 +3,7 @@ using System.Collections; using System.Collections.Generic; using System.Linq; using System.Text; +using CliFx.Models; namespace CliFx.Internal { @@ -66,5 +67,11 @@ namespace CliFx.Internal public static bool IsCollection(this Type type) => type != typeof(string) && type.GetEnumerableUnderlyingType() != null; + + public static IOrderedEnumerable Ordered(this IEnumerable source) + { + return source + .OrderBy(a => a.Order); + } } } \ No newline at end of file diff --git a/CliFx/Models/CommandArgumentSchema.cs b/CliFx/Models/CommandArgumentSchema.cs new file mode 100644 index 0000000..43bb674 --- /dev/null +++ b/CliFx/Models/CommandArgumentSchema.cs @@ -0,0 +1,78 @@ +using System.Globalization; +using System.Reflection; +using System.Text; + +namespace CliFx.Models +{ + /// + /// Schema of a defined command argument. + /// + public class CommandArgumentSchema + { + /// + /// Underlying property. + /// + public PropertyInfo Property { get; } + + /// + /// Argument name used for help text. + /// + public string? Name { get; } + + /// + /// Whether the argument is required. + /// + public bool IsRequired { get; } + + /// + /// Argument description. + /// + public string? Description { get; } + + /// + /// Order of the argument. + /// + public int Order { get; } + + /// + /// The display name of the argument. Returns if specified, otherwise the name of the underlying property. + /// + public string DisplayName => !string.IsNullOrWhiteSpace(Name) ? Name! : Property.Name.ToLower(CultureInfo.InvariantCulture); + + /// + /// Initializes an instance of . + /// + public CommandArgumentSchema(PropertyInfo property, string? name, bool isRequired, string? description, int order) + { + Property = property; + Name = name; + IsRequired = isRequired; + Description = description; + Order = order; + } + + /// + /// Returns the string representation of the argument schema. + /// + /// + public override string ToString() + { + var sb = new StringBuilder(); + if (!IsRequired) + { + sb.Append("["); + } + + sb.Append("<"); + sb.Append($"{DisplayName}"); + sb.Append(">"); + + if (!IsRequired) + { + sb.Append("]"); + } + + return sb.ToString(); + } + } +} \ No newline at end of file diff --git a/CliFx/Models/CommandCandidate.cs b/CliFx/Models/CommandCandidate.cs new file mode 100644 index 0000000..53915df --- /dev/null +++ b/CliFx/Models/CommandCandidate.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; + +namespace CliFx.Models +{ + /// + /// Defines the target command and the input required for initializing the command. + /// + public class CommandCandidate + { + /// + /// The command schema of the target command. + /// + public CommandSchema Schema { get; } + + /// + /// The positional arguments input for the command. + /// + public IReadOnlyList PositionalArgumentsInput { get; } + + /// + /// The command input for the command. + /// + public CommandInput CommandInput { get; } + + /// + /// Initializes and instance of + /// + public CommandCandidate(CommandSchema schema, IReadOnlyList positionalArgumentsInput, CommandInput commandInput) + { + Schema = schema; + PositionalArgumentsInput = positionalArgumentsInput; + CommandInput = commandInput; + } + } +} diff --git a/CliFx/Models/CommandInput.cs b/CliFx/Models/CommandInput.cs index 62c3003..53a81b5 100644 --- a/CliFx/Models/CommandInput.cs +++ b/CliFx/Models/CommandInput.cs @@ -10,10 +10,9 @@ namespace CliFx.Models public partial class CommandInput { /// - /// Specified command name. - /// Can be null if command was not specified. + /// Specified arguments. /// - public string? CommandName { get; } + public IReadOnlyList Arguments { get; } /// /// Specified directives. @@ -33,10 +32,10 @@ namespace CliFx.Models /// /// Initializes an instance of . /// - public CommandInput(string? commandName, IReadOnlyList directives, IReadOnlyList options, + public CommandInput(IReadOnlyList arguments, IReadOnlyList directives, IReadOnlyList options, IReadOnlyDictionary environmentVariables) { - CommandName = commandName; + Arguments = arguments; Directives = directives; Options = options; EnvironmentVariables = environmentVariables; @@ -45,24 +44,24 @@ namespace CliFx.Models /// /// Initializes an instance of . /// - public CommandInput(string? commandName, IReadOnlyList directives, IReadOnlyList options) - : this(commandName, directives, options, EmptyEnvironmentVariables) + public CommandInput(IReadOnlyList arguments, IReadOnlyList directives, IReadOnlyList options) + : this(arguments, directives, options, EmptyEnvironmentVariables) { } /// /// Initializes an instance of . /// - public CommandInput(string? commandName, IReadOnlyList options, IReadOnlyDictionary environmentVariables) - : this(commandName, EmptyDirectives, options, environmentVariables) + public CommandInput(IReadOnlyList arguments, IReadOnlyList options, IReadOnlyDictionary environmentVariables) + : this(arguments, EmptyDirectives, options, environmentVariables) { } /// /// Initializes an instance of . /// - public CommandInput(string? commandName, IReadOnlyList options) - : this(commandName, EmptyDirectives, options) + public CommandInput(IReadOnlyList arguments, IReadOnlyList options) + : this(arguments, EmptyDirectives, options) { } @@ -70,15 +69,15 @@ namespace CliFx.Models /// Initializes an instance of . /// public CommandInput(IReadOnlyList options) - : this(null, options) + : this(new string[0], options) { } /// /// Initializes an instance of . /// - public CommandInput(string? commandName) - : this(commandName, EmptyOptions) + public CommandInput(IReadOnlyList arguments) + : this(arguments, EmptyOptions) { } @@ -87,8 +86,11 @@ namespace CliFx.Models { var buffer = new StringBuilder(); - if (!string.IsNullOrWhiteSpace(CommandName)) - buffer.Append(CommandName); + foreach (var argument in Arguments) + { + buffer.AppendIfNotEmpty(' '); + buffer.Append(argument); + } foreach (var directive in Directives) { diff --git a/CliFx/Models/CommandSchema.cs b/CliFx/Models/CommandSchema.cs index 2725732..5ef58e8 100644 --- a/CliFx/Models/CommandSchema.cs +++ b/CliFx/Models/CommandSchema.cs @@ -30,15 +30,21 @@ namespace CliFx.Models /// public IReadOnlyList Options { get; } + /// + /// Command arguments. + /// + public IReadOnlyList Arguments { get; } + /// /// Initializes an instance of . /// - public CommandSchema(Type? type, string? name, string? description, IReadOnlyList options) + public CommandSchema(Type type, string name, string description, IReadOnlyList arguments, IReadOnlyList options) { Type = type; Name = name; Description = description; Options = options; + Arguments = arguments; } /// @@ -64,6 +70,6 @@ namespace CliFx.Models public partial class CommandSchema { internal static CommandSchema StubDefaultCommand { get; } = - new CommandSchema(null, null, null, new CommandOptionSchema[0]); + new CommandSchema(null, null, null, new CommandArgumentSchema[0], new CommandOptionSchema[0]); } } \ No newline at end of file diff --git a/CliFx/Models/Extensions.cs b/CliFx/Models/Extensions.cs index 1ae5260..f192fab 100644 --- a/CliFx/Models/Extensions.cs +++ b/CliFx/Models/Extensions.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; namespace CliFx.Models { @@ -90,7 +91,7 @@ namespace CliFx.Models /// /// Gets whether a command was specified in the input. /// - public static bool IsCommandSpecified(this CommandInput commandInput) => !string.IsNullOrWhiteSpace(commandInput.CommandName); + public static bool HasArguments(this CommandInput commandInput) => commandInput.Arguments.Any(); /// /// Gets whether debug directive was specified in the input. diff --git a/CliFx/Services/CommandArgumentSchemasValidator.cs b/CliFx/Services/CommandArgumentSchemasValidator.cs new file mode 100644 index 0000000..b9c7b03 --- /dev/null +++ b/CliFx/Services/CommandArgumentSchemasValidator.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CliFx.Exceptions; +using CliFx.Internal; +using CliFx.Models; + +namespace CliFx.Services +{ + /// + public class CommandArgumentSchemasValidator : ICommandArgumentSchemasValidator + { + private bool IsEnumerableArgument(CommandArgumentSchema schema) + { + return schema.Property.PropertyType != typeof(string) && schema.Property.PropertyType.GetEnumerableUnderlyingType() != null; + } + + /// + public IEnumerable ValidateArgumentSchemas(IReadOnlyCollection commandArgumentSchemas) + { + if (commandArgumentSchemas.Count == 0) + { + // No validation needed + yield break; + } + + // Make sure there are no arguments with the same name + var duplicateNameGroups = commandArgumentSchemas + .Where(x => !string.IsNullOrWhiteSpace(x.Name)) + .GroupBy(x => x.Name) + .Where(x => x.Count() > 1); + foreach (var schema in duplicateNameGroups) + { + yield return new ValidationError($"Multiple arguments with same name: \"{schema.Key}\"."); + } + + // Make sure that the order of all properties are distinct + var duplicateOrderGroups = commandArgumentSchemas + .GroupBy(x => x.Order) + .Where(x => x.Count() > 1); + foreach (var schema in duplicateOrderGroups) + { + yield return new ValidationError($"Multiple arguments with the same order: \"{schema.Key}\"."); + } + + var enumerableArguments = commandArgumentSchemas + .Where(IsEnumerableArgument) + .ToList(); + + // Verify that no more than one enumerable argument exists + if (enumerableArguments.Count > 1) + { + yield return new ValidationError($"Multiple sequence arguments found; only one is supported."); + } + + // If an enumerable argument exists, ensure that it has the highest order + if (enumerableArguments.Count == 1) + { + if (enumerableArguments.Single().Order != commandArgumentSchemas.Max(x => x.Order)) + { + yield return new ValidationError($"A sequence argument was defined with a lower order than another argument; the sequence argument must have the highest order (appear last)."); + } + } + + // Verify that all required arguments appear before optional arguments + if (commandArgumentSchemas.Any(x => x.IsRequired) && commandArgumentSchemas.Any(x => !x.IsRequired) && + commandArgumentSchemas.Where(x => x.IsRequired).Max(x => x.Order) > commandArgumentSchemas.Where(x => !x.IsRequired).Min(x => x.Order)) + { + yield return new ValidationError("One or more required arguments appear after optional arguments. Required arguments must appear before (i.e. have lower order than) optional arguments."); + } + } + } + + /// + /// Represents a failed validation. + /// + public class ValidationError + { + /// + /// Creates an instance of with a message. + /// + public ValidationError(string message) + { + Message = message; + } + + /// + /// The error message for the failed validation. + /// + public string Message { get; } + } +} \ No newline at end of file diff --git a/CliFx/Services/CommandInitializer.cs b/CliFx/Services/CommandInitializer.cs index 9cff52a..47d6c23 100644 --- a/CliFx/Services/CommandInitializer.cs +++ b/CliFx/Services/CommandInitializer.cs @@ -1,4 +1,6 @@ -using System.Linq; +using System; +using System.Collections.Generic; +using System.Linq; using CliFx.Exceptions; using CliFx.Internal; using CliFx.Models; @@ -10,15 +12,15 @@ namespace CliFx.Services /// public class CommandInitializer : ICommandInitializer { - private readonly ICommandOptionInputConverter _commandOptionInputConverter; + private readonly ICommandInputConverter _commandInputConverter; private readonly IEnvironmentVariablesParser _environmentVariablesParser; /// /// Initializes an instance of . /// - public CommandInitializer(ICommandOptionInputConverter commandOptionInputConverter, IEnvironmentVariablesParser environmentVariablesParser) + public CommandInitializer(ICommandInputConverter commandInputConverter, IEnvironmentVariablesParser environmentVariablesParser) { - _commandOptionInputConverter = commandOptionInputConverter; + _commandInputConverter = commandInputConverter; _environmentVariablesParser = environmentVariablesParser; } @@ -26,7 +28,7 @@ namespace CliFx.Services /// Initializes an instance of . /// public CommandInitializer(IEnvironmentVariablesParser environmentVariablesParser) - : this(new CommandOptionInputConverter(), environmentVariablesParser) + : this(new CommandInputConverter(), environmentVariablesParser) { } @@ -34,43 +36,47 @@ namespace CliFx.Services /// Initializes an instance of . /// public CommandInitializer() - : this(new CommandOptionInputConverter(), new EnvironmentVariablesParser()) + : this(new CommandInputConverter(), new EnvironmentVariablesParser()) { } - /// - public void InitializeCommand(ICommand command, CommandSchema commandSchema, CommandInput commandInput) + private void InitializeCommandOptions(ICommand command, CommandCandidate commandCandidate) { + if (commandCandidate.Schema is null) + { + throw new ArgumentException("Cannot initialize command without a schema."); + } + // Keep track of unset required options to report an error at a later stage - var unsetRequiredOptions = commandSchema.Options.Where(o => o.IsRequired).ToList(); + var unsetRequiredOptions = commandCandidate.Schema.Options.Where(o => o.IsRequired).ToList(); //Set command options - foreach (var optionSchema in commandSchema.Options) + foreach (var optionSchema in commandCandidate.Schema.Options) { // Ignore special options that are not backed by a property if (optionSchema.Property == null) continue; //Find matching option input - var optionInput = commandInput.Options.FindByOptionSchema(optionSchema); + var optionInput = commandCandidate.CommandInput.Options.FindByOptionSchema(optionSchema); //If no option input is available fall back to environment variable values if (optionInput == null && !string.IsNullOrWhiteSpace(optionSchema.EnvironmentVariableName)) { - var fallbackEnvironmentVariableExists = commandInput.EnvironmentVariables.ContainsKey(optionSchema.EnvironmentVariableName!); + var fallbackEnvironmentVariableExists = commandCandidate.CommandInput.EnvironmentVariables.ContainsKey(optionSchema.EnvironmentVariableName!); //If no environment variable is found or there is no valid value for this option skip it - if (!fallbackEnvironmentVariableExists || string.IsNullOrWhiteSpace(commandInput.EnvironmentVariables[optionSchema.EnvironmentVariableName!])) + if (!fallbackEnvironmentVariableExists || string.IsNullOrWhiteSpace(commandCandidate.CommandInput.EnvironmentVariables[optionSchema.EnvironmentVariableName!])) continue; - optionInput = _environmentVariablesParser.GetCommandOptionInputFromEnvironmentVariable(commandInput.EnvironmentVariables[optionSchema.EnvironmentVariableName!], optionSchema); + optionInput = _environmentVariablesParser.GetCommandOptionInputFromEnvironmentVariable(commandCandidate.CommandInput.EnvironmentVariables[optionSchema.EnvironmentVariableName!], optionSchema); } //No fallback available and no option input was specified, skip option if (optionInput == null) continue; - var convertedValue = _commandOptionInputConverter.ConvertOptionInput(optionInput, optionSchema.Property.PropertyType); + var convertedValue = _commandInputConverter.ConvertOptionInput(optionInput, optionSchema.Property.PropertyType); // Set value of the underlying property optionSchema.Property.SetValue(command, convertedValue); @@ -87,5 +93,57 @@ namespace CliFx.Services throw new CliFxException($"Some of the required options were not provided: {unsetRequiredOptionNames}."); } } + + private void InitializeCommandArguments(ICommand command, CommandCandidate commandCandidate) + { + if (commandCandidate.Schema is null) + { + throw new ArgumentException("Cannot initialize command without a schema."); + } + + // Keep track of unset required options to report an error at a later stage + var unsetRequiredArguments = commandCandidate.Schema.Arguments + .Where(o => o.IsRequired) + .ToList(); + var orderedArgumentSchemas = commandCandidate.Schema.Arguments.Ordered(); + var argumentIndex = 0; + + foreach (var argumentSchema in orderedArgumentSchemas) + { + if (argumentIndex >= commandCandidate.PositionalArgumentsInput.Count) + { + // No more positional arguments left - remaining argument properties stay unset + break; + } + + var convertedValue = _commandInputConverter.ConvertArgumentInput(commandCandidate.PositionalArgumentsInput, ref argumentIndex, argumentSchema.Property.PropertyType); + + // Set value of underlying property + argumentSchema.Property.SetValue(command, convertedValue); + + // Mark this required argument as set + if (argumentSchema.IsRequired) + unsetRequiredArguments.Remove(argumentSchema); + } + + // Throw if there are remaining input arguments + if (argumentIndex < commandCandidate.PositionalArgumentsInput.Count) + { + throw new CliFxException($"Could not map the following arguments to command name or positional arguments: {commandCandidate.PositionalArgumentsInput.Skip(argumentIndex).JoinToString(", ")}"); + } + + // Throw if any of the required arguments were not set + if (unsetRequiredArguments.Any()) + { + throw new CliFxException($"One or more required arguments were not set: {unsetRequiredArguments.JoinToString(", ")}."); + } + } + + /// + public void InitializeCommand(ICommand command, CommandCandidate commandCandidate) + { + InitializeCommandOptions(command, commandCandidate); + InitializeCommandArguments(command, commandCandidate); + } } } \ No newline at end of file diff --git a/CliFx/Services/CommandOptionInputConverter.cs b/CliFx/Services/CommandInputConverter.cs similarity index 71% rename from CliFx/Services/CommandOptionInputConverter.cs rename to CliFx/Services/CommandInputConverter.cs index c72d714..d423e78 100644 --- a/CliFx/Services/CommandOptionInputConverter.cs +++ b/CliFx/Services/CommandInputConverter.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Reflection; @@ -9,28 +10,53 @@ using CliFx.Models; namespace CliFx.Services { /// - /// Default implementation of . + /// Default implementation of . /// - public partial class CommandOptionInputConverter : ICommandOptionInputConverter + public partial class CommandInputConverter : ICommandInputConverter { private readonly IFormatProvider _formatProvider; /// - /// Initializes an instance of . + /// Initializes an instance of . /// - public CommandOptionInputConverter(IFormatProvider formatProvider) + public CommandInputConverter(IFormatProvider formatProvider) { _formatProvider = formatProvider; } /// - /// Initializes an instance of . + /// Initializes an instance of . /// - public CommandOptionInputConverter() + public CommandInputConverter() : this(CultureInfo.InvariantCulture) { } + private object? ConvertEnumerableValue(IReadOnlyList values, Type enumerableUnderlyingType, Type targetType) + { + // Convert values to the underlying enumerable type and cast it to dynamic array + var convertedValues = values + .Select(v => ConvertValue(v, enumerableUnderlyingType)) + .ToNonGenericArray(enumerableUnderlyingType); + + // Get the type of produced array + var convertedValuesType = convertedValues.GetType(); + + // Try to assign the array (works for T[], IReadOnlyList, IEnumerable, etc) + if (targetType.IsAssignableFrom(convertedValuesType)) + return convertedValues; + + // Try to inject the array into the constructor (works for HashSet, List, etc) + var arrayConstructor = targetType.GetConstructor(new[] { convertedValuesType }); + if (arrayConstructor != null) + return arrayConstructor.Invoke(new object[] { convertedValues }); + + // Throw if we can't find a way to convert the values + throw new CliFxException( + $"Can't convert a sequence of values [{values.JoinToString(", ")}] " + + $"to type [{targetType}]."); + } + /// /// Converts a single string value to specified target type. /// @@ -118,17 +144,17 @@ namespace CliFx.Services // Has a constructor that accepts a single string var stringConstructor = GetStringConstructor(targetType); if (stringConstructor != null) - return stringConstructor.Invoke(new object[] {value}); + return stringConstructor.Invoke(new object[] { value }); // Has a static parse method that accepts a single string and a format provider var parseMethodWithFormatProvider = GetStaticParseMethodWithFormatProvider(targetType); if (parseMethodWithFormatProvider != null) - return parseMethodWithFormatProvider.Invoke(null, new object[] {value, _formatProvider}); + return parseMethodWithFormatProvider.Invoke(null, new object[] { value, _formatProvider }); // Has a static parse method that accepts a single string var parseMethod = GetStaticParseMethod(targetType); if (parseMethod != null) - return parseMethod.Invoke(null, new object[] {value}); + return parseMethod.Invoke(null, new object[] { value }); } catch (Exception ex) { @@ -145,6 +171,24 @@ namespace CliFx.Services "This type is not among the list of types supported by this library."); } + /// + public virtual object? ConvertArgumentInput(IReadOnlyList arguments, ref int currentIndex, Type targetType) + { + var enumerableUnderlyingType = targetType != typeof(string) ? targetType.GetEnumerableUnderlyingType() : null; + if (enumerableUnderlyingType is null) + { + var argument = arguments[currentIndex]; + currentIndex += 1; + return ConvertValue(argument, targetType); + } + + // + var argumentSequence = arguments.Skip(currentIndex).ToList(); + currentIndex = arguments.Count; + + return ConvertEnumerableValue(argumentSequence, enumerableUnderlyingType, targetType); + } + /// public virtual object? ConvertOptionInput(CommandOptionInput optionInput, Type targetType) { @@ -170,32 +214,12 @@ namespace CliFx.Services // Convert to an enumerable type else { - // Convert values to the underlying enumerable type and cast it to dynamic array - var convertedValues = optionInput.Values - .Select(v => ConvertValue(v, enumerableUnderlyingType)) - .ToNonGenericArray(enumerableUnderlyingType); - - // Get the type of produced array - var convertedValuesType = convertedValues.GetType(); - - // Try to assign the array (works for T[], IReadOnlyList, IEnumerable, etc) - if (targetType.IsAssignableFrom(convertedValuesType)) - return convertedValues; - - // Try to inject the array into the constructor (works for HashSet, List, etc) - var arrayConstructor = targetType.GetConstructor(new[] {convertedValuesType}); - if (arrayConstructor != null) - return arrayConstructor.Invoke(new object[] {convertedValues}); - - // Throw if we can't find a way to convert the values - throw new CliFxException( - $"Can't convert a sequence of values [{optionInput.Values.JoinToString(", ")}] " + - $"to type [{targetType}]."); + return ConvertEnumerableValue(optionInput.Values, enumerableUnderlyingType, targetType); } } } - public partial class CommandOptionInputConverter + public partial class CommandInputConverter { private static ConstructorInfo? GetStringConstructor(Type type) => type.GetConstructor(new[] {typeof(string)}); diff --git a/CliFx/Services/CommandInputParser.cs b/CliFx/Services/CommandInputParser.cs index 1f3416f..541b89f 100644 --- a/CliFx/Services/CommandInputParser.cs +++ b/CliFx/Services/CommandInputParser.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text; using CliFx.Internal; using CliFx.Models; @@ -33,7 +32,7 @@ namespace CliFx.Services /// public CommandInput ParseCommandInput(IReadOnlyList commandLineArguments) { - var commandNameBuilder = new StringBuilder(); + var arguments = new List(); var directives = new List(); var optionsDic = new Dictionary>(); @@ -79,8 +78,7 @@ namespace CliFx.Services } else { - commandNameBuilder.AppendIfNotEmpty(' '); - commandNameBuilder.Append(commandLineArgument); + arguments.Add(commandLineArgument); } } @@ -91,12 +89,11 @@ namespace CliFx.Services } } - var commandName = commandNameBuilder.Length > 0 ? commandNameBuilder.ToString() : null; var options = optionsDic.Select(p => new CommandOptionInput(p.Key, p.Value)).ToArray(); var environmentVariables = _environmentVariablesProvider.GetEnvironmentVariables(); - return new CommandInput(commandName, directives, options, environmentVariables); + return new CommandInput(arguments, directives, options, environmentVariables); } } } \ No newline at end of file diff --git a/CliFx/Services/CommandSchemaResolver.cs b/CliFx/Services/CommandSchemaResolver.cs index 8798319..cbcbe8f 100644 --- a/CliFx/Services/CommandSchemaResolver.cs +++ b/CliFx/Services/CommandSchemaResolver.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Text.RegularExpressions; using CliFx.Attributes; using CliFx.Exceptions; using CliFx.Internal; @@ -14,6 +15,16 @@ namespace CliFx.Services /// public class CommandSchemaResolver : ICommandSchemaResolver { + private readonly ICommandArgumentSchemasValidator _commandArgumentSchemasValidator; + + /// + /// Creates an instance of . + /// + public CommandSchemaResolver(ICommandArgumentSchemasValidator commandArgumentSchemasValidator) + { + _commandArgumentSchemasValidator = commandArgumentSchemasValidator; + } + private IReadOnlyList GetCommandOptionSchemas(Type commandType) { var result = new List(); @@ -67,6 +78,24 @@ namespace CliFx.Services return result; } + private IReadOnlyList GetCommandArgumentSchemas(Type commandType) + { + var argumentSchemas = commandType.GetProperties() + .Select(p => new { Property = p, Attribute = p.GetCustomAttribute() }) + .Where(a => a.Attribute != null) + .Select(a => new CommandArgumentSchema(a.Property, a.Attribute.Name, a.Attribute.IsRequired, a.Attribute.Description, a.Attribute.Order)) + .ToList(); + + var validationErrors = _commandArgumentSchemasValidator.ValidateArgumentSchemas(argumentSchemas).ToList(); + if (validationErrors.Any()) + { + throw new CliFxException($"Command type [{commandType}] has invalid argument configuration:\n" + + $"{string.Join("\n", validationErrors.Select(v => v.Message))}"); + } + + return argumentSchemas; + } + /// public IReadOnlyList GetCommandSchemas(IReadOnlyList commandTypes) { @@ -108,11 +137,14 @@ namespace CliFx.Services // Get option schemas var optionSchemas = GetCommandOptionSchemas(commandType); + // Get argument schemas + var argumentSchemas = GetCommandArgumentSchemas(commandType); + // Build command schema var commandSchema = new CommandSchema(commandType, attribute.Name, attribute.Description, - optionSchemas); + argumentSchemas, optionSchemas); // Make sure there are no other commands with the same name var existingCommandWithSameName = result @@ -131,5 +163,31 @@ namespace CliFx.Services return result; } + + /// + public CommandCandidate? GetTargetCommandSchema(IReadOnlyList availableCommandSchemas, CommandInput commandInput) + { + // If no arguments are given, use the default command + CommandSchema targetSchema; + if (!commandInput.Arguments.Any()) + { + targetSchema = availableCommandSchemas.FirstOrDefault(c => c.IsDefault()); + return targetSchema is null ? null : new CommandCandidate(targetSchema, new string[0], commandInput); + } + + // Arguments can be part of the a command name as long as they are single words, i.e. no whitespace characters + var longestPossibleCommandName = string.Join(" ", commandInput.Arguments.TakeWhile(arg => !Regex.IsMatch(arg, @"\s"))); + + // Find the longest matching schema + var orderedSchemas = availableCommandSchemas.OrderByDescending(x => x.Name?.Length); + targetSchema = orderedSchemas.FirstOrDefault(c => longestPossibleCommandName.StartsWith(c.Name ?? string.Empty, StringComparison.Ordinal)) + ?? availableCommandSchemas.FirstOrDefault(c => c.IsDefault()); + + // Get remaining positional arguments + var commandArgumentsCount = targetSchema?.Name?.Split(new []{ ' ' }, StringSplitOptions.RemoveEmptyEntries).Length ?? 0; + var positionalArguments = commandInput.Arguments.Skip(commandArgumentsCount).ToList(); + + return targetSchema is null ? null : new CommandCandidate(targetSchema, positionalArguments, commandInput); + } } } \ No newline at end of file diff --git a/CliFx/Services/HelpTextRenderer.cs b/CliFx/Services/HelpTextRenderer.cs index 4efd9b4..390e51f 100644 --- a/CliFx/Services/HelpTextRenderer.cs +++ b/CliFx/Services/HelpTextRenderer.cs @@ -19,7 +19,7 @@ namespace CliFx.Services var row = 0; // Get built-in option schemas (help and version) - var builtInOptionSchemas = new List {CommandOptionSchema.HelpOption}; + var builtInOptionSchemas = new List { CommandOptionSchema.HelpOption }; if (source.TargetCommandSchema.IsDefault()) builtInOptionSchemas.Add(CommandOptionSchema.VersionOption); @@ -104,7 +104,7 @@ namespace CliFx.Services // Description if (!string.IsNullOrWhiteSpace(source.ApplicationMetadata.Description)) { - Render(source.ApplicationMetadata.Description); + Render(source.ApplicationMetadata.Description!); RenderNewLine(); } } @@ -122,7 +122,7 @@ namespace CliFx.Services // Description RenderIndent(); - Render(source.TargetCommandSchema.Description); + Render(source.TargetCommandSchema.Description!); RenderNewLine(); } @@ -142,7 +142,7 @@ namespace CliFx.Services if (!string.IsNullOrWhiteSpace(source.TargetCommandSchema.Name)) { Render(" "); - RenderWithColor(source.TargetCommandSchema.Name, ConsoleColor.Cyan); + RenderWithColor(source.TargetCommandSchema.Name!, ConsoleColor.Cyan); } // Child command @@ -152,12 +152,69 @@ namespace CliFx.Services RenderWithColor("[command]", ConsoleColor.Cyan); } + // Arguments + foreach (var argumentSchema in source.TargetCommandSchema.Arguments) + { + Render(" "); + if (!argumentSchema.IsRequired) + Render("["); + + Render($"<{argumentSchema.DisplayName}>"); + + if (!argumentSchema.IsRequired) + Render("]"); + } + // Options Render(" "); RenderWithColor("[options]", ConsoleColor.White); RenderNewLine(); } + void RenderArguments() + { + // Do not render anything if the command has no arguments + if (source.TargetCommandSchema.Arguments.Count == 0) + return; + + // Margin + RenderMargin(); + + // Header + RenderHeader("Arguments"); + + // Order arguments + var orderedArgumentSchemas = source.TargetCommandSchema.Arguments + .Ordered() + .ToArray(); + + // Arguments + foreach (var argumentSchema in orderedArgumentSchemas) + { + // Is required + if (argumentSchema.IsRequired) + { + RenderWithColor("* ", ConsoleColor.Red); + } + else + { + RenderIndent(); + } + + // Short name + RenderWithColor($"{argumentSchema.DisplayName}", ConsoleColor.White); + + // Description + if (!string.IsNullOrWhiteSpace(argumentSchema.Description)) + { + RenderColumnIndent(); + Render(argumentSchema.Description!); + } + + RenderNewLine(); + } + } + void RenderOptions() { // Margin @@ -207,7 +264,7 @@ namespace CliFx.Services if (!string.IsNullOrWhiteSpace(optionSchema.Description)) { RenderColumnIndent(); - Render(optionSchema.Description); + Render(optionSchema.Description!); } RenderNewLine(); @@ -238,7 +295,7 @@ namespace CliFx.Services if (!string.IsNullOrWhiteSpace(childCommandSchema.Description)) { RenderColumnIndent(); - Render(childCommandSchema.Description); + Render(childCommandSchema.Description!); } RenderNewLine(); @@ -275,6 +332,7 @@ namespace CliFx.Services RenderApplicationInfo(); RenderDescription(); RenderUsage(); + RenderArguments(); RenderOptions(); RenderChildCommands(); } @@ -285,6 +343,6 @@ namespace CliFx.Services private static string? GetRelativeCommandName(CommandSchema commandSchema, CommandSchema parentCommandSchema) => string.IsNullOrWhiteSpace(parentCommandSchema.Name) || string.IsNullOrWhiteSpace(commandSchema.Name) ? commandSchema.Name - : commandSchema.Name.Substring(parentCommandSchema.Name.Length + 1); + : commandSchema.Name!.Substring(parentCommandSchema.Name!.Length + 1); } } \ No newline at end of file diff --git a/CliFx/Services/ICommandArgumentSchemasValidator.cs b/CliFx/Services/ICommandArgumentSchemasValidator.cs new file mode 100644 index 0000000..2194c5a --- /dev/null +++ b/CliFx/Services/ICommandArgumentSchemasValidator.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using CliFx.Models; + +namespace CliFx.Services +{ + /// + /// Validates command arguments. + /// + public interface ICommandArgumentSchemasValidator + { + /// + /// Validate the given command arguments. + /// + IEnumerable ValidateArgumentSchemas(IReadOnlyCollection commandArgumentSchemas); + } +} \ No newline at end of file diff --git a/CliFx/Services/ICommandInitializer.cs b/CliFx/Services/ICommandInitializer.cs index f9292bf..1710f29 100644 --- a/CliFx/Services/ICommandInitializer.cs +++ b/CliFx/Services/ICommandInitializer.cs @@ -10,6 +10,6 @@ namespace CliFx.Services /// /// Populates an instance of with specified input according to specified schema. /// - void InitializeCommand(ICommand command, CommandSchema commandSchema, CommandInput commandInput); + void InitializeCommand(ICommand command, CommandCandidate commandCandidate); } } \ No newline at end of file diff --git a/CliFx/Services/ICommandOptionInputConverter.cs b/CliFx/Services/ICommandInputConverter.cs similarity index 50% rename from CliFx/Services/ICommandOptionInputConverter.cs rename to CliFx/Services/ICommandInputConverter.cs index 8e51b09..ad6d01b 100644 --- a/CliFx/Services/ICommandOptionInputConverter.cs +++ b/CliFx/Services/ICommandInputConverter.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using CliFx.Models; namespace CliFx.Services @@ -6,11 +7,16 @@ namespace CliFx.Services /// /// Converts input command options. /// - public interface ICommandOptionInputConverter + public interface ICommandInputConverter { /// /// Converts an option to specified target type. /// object? ConvertOptionInput(CommandOptionInput optionInput, Type targetType); + + /// + /// Converts an argument to specified target type, using up arguments from the given enumerator. + /// + object? ConvertArgumentInput(IReadOnlyList arguments, ref int currentIndex, Type targetType); } } \ No newline at end of file diff --git a/CliFx/Services/ICommandSchemaResolver.cs b/CliFx/Services/ICommandSchemaResolver.cs index 066c417..629850a 100644 --- a/CliFx/Services/ICommandSchemaResolver.cs +++ b/CliFx/Services/ICommandSchemaResolver.cs @@ -13,5 +13,10 @@ namespace CliFx.Services /// Resolves schemas of specified command types. /// IReadOnlyList GetCommandSchemas(IReadOnlyList commandTypes); + + /// + /// Get the target command schema. The target command is the most specific command that matches the unbound input arguments. + /// + CommandCandidate? GetTargetCommandSchema(IReadOnlyList availableCommandSchemas, CommandInput commandInput); } } \ No newline at end of file