From 63d798977d9e89372b244f18b951a0a3fe47b22f Mon Sep 17 00:00:00 2001 From: Alexey Golub Date: Sun, 9 Jun 2019 21:57:30 +0300 Subject: [PATCH] Enhance option converter and add support for array options --- CliFx.Tests.Dummy/Commands/AddCommand.cs | 11 +- CliFx.Tests.Dummy/Commands/DefaultCommand.cs | 4 +- CliFx.Tests.Dummy/Commands/LogCommand.cs | 4 +- CliFx.Tests/CommandOptionConverterTests.cs | 162 ++++++++++++++++--- CliFx.Tests/CommandOptionParserTests.cs | 107 ++++++++---- CliFx.Tests/CommandResolverTests.cs | 30 ++-- CliFx.Tests/DummyTests.cs | 13 +- CliFx.Tests/TestObjects/TestCommand.cs | 4 +- CliFx/Attributes/CommandOptionAttribute.cs | 20 ++- CliFx/Internal/CommandOptionProperty.cs | 4 +- CliFx/Internal/Extensions.cs | 29 ++++ CliFx/Models/CommandOption.cs | 27 ++++ CliFx/Models/CommandOptionSet.cs | 12 +- CliFx/Models/Extensions.cs | 21 +++ CliFx/Services/CommandOptionConverter.cs | 26 ++- CliFx/Services/CommandOptionParser.cs | 22 ++- CliFx/Services/CommandResolver.cs | 15 +- CliFx/Services/ICommandOptionConverter.cs | 3 +- 18 files changed, 399 insertions(+), 115 deletions(-) create mode 100644 CliFx/Models/CommandOption.cs create mode 100644 CliFx/Models/Extensions.cs diff --git a/CliFx.Tests.Dummy/Commands/AddCommand.cs b/CliFx.Tests.Dummy/Commands/AddCommand.cs index e0649f5..66634f1 100644 --- a/CliFx.Tests.Dummy/Commands/AddCommand.cs +++ b/CliFx.Tests.Dummy/Commands/AddCommand.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.Globalization; +using System.Linq; using CliFx.Attributes; using CliFx.Models; @@ -8,15 +10,12 @@ namespace CliFx.Tests.Dummy.Commands [Command("add")] public class AddCommand : Command { - [CommandOption("a", IsRequired = true, Description = "Left operand.")] - public double A { get; set; } - - [CommandOption("b", IsRequired = true, Description = "Right operand.")] - public double B { get; set; } + [CommandOption("values", 'v', IsRequired = true, Description = "Values.")] + public IReadOnlyList Values { get; set; } public override ExitCode Execute() { - var result = A + B; + var result = Values.Sum(); Console.WriteLine(result.ToString(CultureInfo.InvariantCulture)); return ExitCode.Success; diff --git a/CliFx.Tests.Dummy/Commands/DefaultCommand.cs b/CliFx.Tests.Dummy/Commands/DefaultCommand.cs index 69b7f46..d3fb8e1 100644 --- a/CliFx.Tests.Dummy/Commands/DefaultCommand.cs +++ b/CliFx.Tests.Dummy/Commands/DefaultCommand.cs @@ -8,10 +8,10 @@ namespace CliFx.Tests.Dummy.Commands [DefaultCommand] public class DefaultCommand : Command { - [CommandOption("target", ShortName = 't', Description = "Greeting target.")] + [CommandOption("target", 't', Description = "Greeting target.")] public string Target { get; set; } = "world"; - [CommandOption("enthusiastic", ShortName = 'e', Description = "Whether the greeting should be enthusiastic.")] + [CommandOption('e', Description = "Whether the greeting should be enthusiastic.")] public bool IsEnthusiastic { get; set; } public override ExitCode Execute() diff --git a/CliFx.Tests.Dummy/Commands/LogCommand.cs b/CliFx.Tests.Dummy/Commands/LogCommand.cs index 38532e6..690b434 100644 --- a/CliFx.Tests.Dummy/Commands/LogCommand.cs +++ b/CliFx.Tests.Dummy/Commands/LogCommand.cs @@ -8,10 +8,10 @@ namespace CliFx.Tests.Dummy.Commands [Command("log")] public class LogCommand : Command { - [CommandOption("value", IsRequired = true, Description = "Value whose logarithm is to be found.")] + [CommandOption("value", 'v', IsRequired = true, Description = "Value whose logarithm is to be found.")] public double Value { get; set; } - [CommandOption("base", Description = "Logarithm base.")] + [CommandOption("base", 'b', Description = "Logarithm base.")] public double Base { get; set; } = 10; public override ExitCode Execute() diff --git a/CliFx.Tests/CommandOptionConverterTests.cs b/CliFx.Tests/CommandOptionConverterTests.cs index dec6473..81f2ebb 100644 --- a/CliFx.Tests/CommandOptionConverterTests.cs +++ b/CliFx.Tests/CommandOptionConverterTests.cs @@ -1,5 +1,7 @@ using System; +using System.Collections; using System.Collections.Generic; +using CliFx.Models; using CliFx.Services; using CliFx.Tests.TestObjects; using NUnit.Framework; @@ -11,54 +13,172 @@ namespace CliFx.Tests { private static IEnumerable GetData_ConvertOption() { - yield return new TestCaseData("value", typeof(string), "value"); + yield return new TestCaseData( + new CommandOption("option", "value"), + typeof(string), + "value" + ); - yield return new TestCaseData("value", typeof(object), "value"); + yield return new TestCaseData( + new CommandOption("option", "value"), + typeof(object), + "value" + ); - yield return new TestCaseData("true", typeof(bool), true); + yield return new TestCaseData( + new CommandOption("option", "true"), + typeof(bool), + true + ); - yield return new TestCaseData("false", typeof(bool), false); + yield return new TestCaseData( + new CommandOption("option", "false"), + typeof(bool), + false + ); - yield return new TestCaseData(null, typeof(bool), true); + yield return new TestCaseData( + new CommandOption("option"), + typeof(bool), + true + ); - yield return new TestCaseData("123", typeof(int), 123); + yield return new TestCaseData( + new CommandOption("option", "123"), + typeof(int), + 123 + ); - yield return new TestCaseData("123.45", typeof(double), 123.45); + yield return new TestCaseData( + new CommandOption("option", "123.45"), + typeof(double), + 123.45 + ); - yield return new TestCaseData("28 Apr 1995", typeof(DateTime), new DateTime(1995, 04, 28)); + yield return new TestCaseData( + new CommandOption("option", "28 Apr 1995"), + typeof(DateTime), + new DateTime(1995, 04, 28) + ); - yield return new TestCaseData("28 Apr 1995", typeof(DateTimeOffset), new DateTimeOffset(new DateTime(1995, 04, 28))); + yield return new TestCaseData( + new CommandOption("option", "28 Apr 1995"), + typeof(DateTimeOffset), + new DateTimeOffset(new DateTime(1995, 04, 28)) + ); - yield return new TestCaseData("00:14:59", typeof(TimeSpan), new TimeSpan(00, 14, 59)); + yield return new TestCaseData( + new CommandOption("option", "00:14:59"), + typeof(TimeSpan), + new TimeSpan(00, 14, 59) + ); - yield return new TestCaseData("value2", typeof(TestEnum), TestEnum.Value2); + yield return new TestCaseData( + new CommandOption("option", "value2"), + typeof(TestEnum), + TestEnum.Value2 + ); - yield return new TestCaseData("666", typeof(int?), 666); + yield return new TestCaseData( + new CommandOption("option", "666"), + typeof(int?), + 666 + ); - yield return new TestCaseData(null, typeof(int?), null); + yield return new TestCaseData( + new CommandOption("option"), + typeof(int?), + null + ); - yield return new TestCaseData("value3", typeof(TestEnum?), TestEnum.Value3); + yield return new TestCaseData( + new CommandOption("option", "value3"), + typeof(TestEnum?), + TestEnum.Value3 + ); - yield return new TestCaseData(null, typeof(TestEnum?), null); + yield return new TestCaseData( + new CommandOption("option"), + typeof(TestEnum?), + null + ); - yield return new TestCaseData("01:00:00", typeof(TimeSpan?), new TimeSpan(01, 00, 00)); + yield return new TestCaseData( + new CommandOption("option", "01:00:00"), + typeof(TimeSpan?), + new TimeSpan(01, 00, 00) + ); - yield return new TestCaseData(null, typeof(TimeSpan?), null); + yield return new TestCaseData( + new CommandOption("option"), + typeof(TimeSpan?), + null + ); - yield return new TestCaseData("value", typeof(TestStringConstructable), new TestStringConstructable("value")); + yield return new TestCaseData( + new CommandOption("option", "value"), + typeof(TestStringConstructable), + new TestStringConstructable("value") + ); - yield return new TestCaseData("value", typeof(TestStringParseable), TestStringParseable.Parse("value")); + yield return new TestCaseData( + new CommandOption("option", "value"), + typeof(TestStringParseable), + TestStringParseable.Parse("value") + ); + + yield return new TestCaseData( + new CommandOption("option", new[] {"value1", "value2"}), + typeof(string[]), + new[] {"value1", "value2"} + ); + + yield return new TestCaseData( + new CommandOption("option", new[] {"value1", "value2"}), + typeof(object[]), + new[] {"value1", "value2"} + ); + + yield return new TestCaseData( + new CommandOption("option", new[] {"47", "69"}), + typeof(int[]), + new[] {47, 69} + ); + + yield return new TestCaseData( + new CommandOption("option", new[] {"value1", "value3"}), + typeof(TestEnum[]), + new[] {TestEnum.Value1, TestEnum.Value3} + ); + + yield return new TestCaseData( + new CommandOption("option", new[] {"value1", "value2"}), + typeof(IEnumerable), + new[] {"value1", "value2"} + ); + + yield return new TestCaseData( + new CommandOption("option", new[] {"value1", "value2"}), + typeof(IEnumerable), + new[] {"value1", "value2"} + ); + + yield return new TestCaseData( + new CommandOption("option", new[] {"value1", "value2"}), + typeof(IReadOnlyList), + new[] {"value1", "value2"} + ); } [Test] [TestCaseSource(nameof(GetData_ConvertOption))] - public void ConvertOption_Test(string value, Type targetType, object expectedConvertedValue) + public void ConvertOption_Test(CommandOption option, Type targetType, object expectedConvertedValue) { // Arrange var converter = new CommandOptionConverter(); // Act - var convertedValue = converter.ConvertOption(value, targetType); + var convertedValue = converter.ConvertOption(option, targetType); // Assert Assert.That(convertedValue, Is.EqualTo(expectedConvertedValue)); diff --git a/CliFx.Tests/CommandOptionParserTests.cs b/CliFx.Tests/CommandOptionParserTests.cs index d4d33a8..f7f3af3 100644 --- a/CliFx.Tests/CommandOptionParserTests.cs +++ b/CliFx.Tests/CommandOptionParserTests.cs @@ -14,96 +14,128 @@ namespace CliFx.Tests yield return new TestCaseData( new[] {"--option", "value"}, - new CommandOptionSet(new Dictionary + new CommandOptionSet(new[] { - {"option", "value"} + new CommandOption("option", "value") }) ); yield return new TestCaseData( new[] {"--option1", "value1", "--option2", "value2"}, - new CommandOptionSet(new Dictionary + new CommandOptionSet(new[] { - {"option1", "value1"}, - {"option2", "value2"} + new CommandOption("option1", "value1"), + new CommandOption("option2", "value2") + }) + ); + + yield return new TestCaseData( + new[] {"--option", "value1", "value2"}, + new CommandOptionSet(new[] + { + new CommandOption("option", new[] {"value1", "value2"}) + }) + ); + + yield return new TestCaseData( + new[] {"--option", "value1", "--option", "value2"}, + new CommandOptionSet(new[] + { + new CommandOption("option", new[] {"value1", "value2"}) }) ); yield return new TestCaseData( new[] {"-a", "value"}, - new CommandOptionSet(new Dictionary + new CommandOptionSet(new[] { - {"a", "value"} + new CommandOption("a", "value") }) ); yield return new TestCaseData( new[] {"-a", "value1", "-b", "value2"}, - new CommandOptionSet(new Dictionary + new CommandOptionSet(new[] { - {"a", "value1"}, - {"b", "value2"} + new CommandOption("a", "value1"), + new CommandOption("b", "value2") + }) + ); + + yield return new TestCaseData( + new[] {"-a", "value1", "value2"}, + new CommandOptionSet(new[] + { + new CommandOption("a", new[] {"value1", "value2"}) + }) + ); + + yield return new TestCaseData( + new[] {"-a", "value1", "-a", "value2"}, + new CommandOptionSet(new[] + { + new CommandOption("a", new[] {"value1", "value2"}) }) ); yield return new TestCaseData( new[] {"--option1", "value1", "-b", "value2"}, - new CommandOptionSet(new Dictionary + new CommandOptionSet(new[] { - {"option1", "value1"}, - {"b", "value2"} + new CommandOption("option1", "value1"), + new CommandOption("b", "value2") }) ); yield return new TestCaseData( new[] {"--switch"}, - new CommandOptionSet(new Dictionary + new CommandOptionSet(new[] { - {"switch", null} + new CommandOption("switch") }) ); yield return new TestCaseData( new[] {"--switch1", "--switch2"}, - new CommandOptionSet(new Dictionary + new CommandOptionSet(new[] { - {"switch1", null}, - {"switch2", null} + new CommandOption("switch1"), + new CommandOption("switch2") }) ); yield return new TestCaseData( new[] {"-s"}, - new CommandOptionSet(new Dictionary + new CommandOptionSet(new[] { - {"s", null} + new CommandOption("s") }) ); yield return new TestCaseData( new[] {"-a", "-b"}, - new CommandOptionSet(new Dictionary + new CommandOptionSet(new[] { - {"a", null}, - {"b", null} + new CommandOption("a"), + new CommandOption("b") }) ); yield return new TestCaseData( new[] {"-ab"}, - new CommandOptionSet(new Dictionary + new CommandOptionSet(new[] { - {"a", null}, - {"b", null} + new CommandOption("a"), + new CommandOption("b") }) ); yield return new TestCaseData( new[] {"-ab", "value"}, - new CommandOptionSet(new Dictionary + new CommandOptionSet(new[] { - {"a", null}, - {"b", "value"} + new CommandOption("a"), + new CommandOption("b", "value") }) ); @@ -114,9 +146,9 @@ namespace CliFx.Tests yield return new TestCaseData( new[] {"command", "--option", "value"}, - new CommandOptionSet("command", new Dictionary + new CommandOptionSet("command", new[] { - {"option", "value"} + new CommandOption("option", "value") }) ); } @@ -132,8 +164,17 @@ namespace CliFx.Tests var optionSet = parser.ParseOptions(commandLineArguments); // Assert - Assert.That(optionSet.CommandName, Is.EqualTo(expectedCommandOptionSet.CommandName), nameof(optionSet.CommandName)); - Assert.That(optionSet.Options, Is.EqualTo(expectedCommandOptionSet.Options), nameof(optionSet.Options)); + Assert.That(optionSet.CommandName, Is.EqualTo(expectedCommandOptionSet.CommandName), "Command name"); + Assert.That(optionSet.Options.Count, Is.EqualTo(expectedCommandOptionSet.Options.Count), "Option count"); + + for (var i = 0; i < optionSet.Options.Count; i++) + { + Assert.That(optionSet.Options[i].Name, Is.EqualTo(expectedCommandOptionSet.Options[i].Name), + $"Option[{i}] name"); + + Assert.That(optionSet.Options[i].Values, Is.EqualTo(expectedCommandOptionSet.Options[i].Values), + $"Option[{i}] values"); + } } } } \ No newline at end of file diff --git a/CliFx.Tests/CommandResolverTests.cs b/CliFx.Tests/CommandResolverTests.cs index d50c70f..fcd38bd 100644 --- a/CliFx.Tests/CommandResolverTests.cs +++ b/CliFx.Tests/CommandResolverTests.cs @@ -14,36 +14,36 @@ namespace CliFx.Tests private static IEnumerable GetData_ResolveCommand() { yield return new TestCaseData( - new CommandOptionSet(new Dictionary + new CommandOptionSet(new[] { - {"int", "13"} + new CommandOption("int", "13") }), new TestCommand {IntOption = 13} ); yield return new TestCaseData( - new CommandOptionSet(new Dictionary + new CommandOptionSet(new[] { - {"int", "13"}, - {"str", "hello world" } + new CommandOption("int", "13"), + new CommandOption("str", "hello world") }), - new TestCommand { IntOption = 13, StringOption = "hello world"} + new TestCommand {IntOption = 13, StringOption = "hello world"} ); yield return new TestCaseData( - new CommandOptionSet(new Dictionary + new CommandOptionSet(new[] { - {"i", "13"} + new CommandOption("i", "13") }), - new TestCommand { IntOption = 13 } + new TestCommand {IntOption = 13} ); yield return new TestCaseData( - new CommandOptionSet("command", new Dictionary + new CommandOptionSet("command", new[] { - {"int", "13"} + new CommandOption("int", "13") }), - new TestCommand { IntOption = 13 } + new TestCommand {IntOption = 13} ); } @@ -80,9 +80,9 @@ namespace CliFx.Tests yield return new TestCaseData(CommandOptionSet.Empty); yield return new TestCaseData( - new CommandOptionSet(new Dictionary + new CommandOptionSet(new[] { - {"str", "hello world"} + new CommandOption("str", "hello world") }) ); } @@ -92,7 +92,7 @@ namespace CliFx.Tests public void ResolveCommand_IsRequired_Test(CommandOptionSet commandOptionSet) { // Arrange - var commandTypes = new[] { typeof(TestCommand) }; + var commandTypes = new[] {typeof(TestCommand)}; var typeProviderMock = new Mock(); typeProviderMock.Setup(m => m.GetTypes()).Returns(commandTypes); diff --git a/CliFx.Tests/DummyTests.cs b/CliFx.Tests/DummyTests.cs index 6277d88..d41d443 100644 --- a/CliFx.Tests/DummyTests.cs +++ b/CliFx.Tests/DummyTests.cs @@ -14,9 +14,10 @@ namespace CliFx.Tests [TestCase("", "Hello world")] [TestCase("-t .NET", "Hello .NET")] [TestCase("-e", "Hello world!!!")] - [TestCase("add --a 1 --b 2", "3")] - [TestCase("add --a 2.75 --b 3.6", "6.35")] - [TestCase("log --value 100", "2")] + [TestCase("add -v 1 2", "3")] + [TestCase("add -v 2.75 3.6 4.18", "10.53")] + [TestCase("add -v 4 -v 16", "20")] + [TestCase("log -v 100", "2")] [TestCase("log --value 256 --base 2", "8")] public async Task Execute_Test(string arguments, string expectedOutput) { @@ -24,9 +25,9 @@ namespace CliFx.Tests var result = await Cli.Wrap(DummyFilePath).SetArguments(arguments).ExecuteAsync(); // Assert - Assert.That(result.ExitCode, Is.Zero, nameof(result.ExitCode)); - Assert.That(result.StandardOutput.Trim(), Is.EqualTo(expectedOutput), nameof(result.StandardOutput)); - Assert.That(result.StandardError.Trim(), Is.Empty, nameof(result.StandardError)); + Assert.That(result.ExitCode, Is.Zero, "Exit code"); + Assert.That(result.StandardOutput.Trim(), Is.EqualTo(expectedOutput), "Stdout"); + Assert.That(result.StandardError.Trim(), Is.Empty, "Stderr"); } } } \ No newline at end of file diff --git a/CliFx.Tests/TestObjects/TestCommand.cs b/CliFx.Tests/TestObjects/TestCommand.cs index f2d7188..19a206d 100644 --- a/CliFx.Tests/TestObjects/TestCommand.cs +++ b/CliFx.Tests/TestObjects/TestCommand.cs @@ -7,10 +7,10 @@ namespace CliFx.Tests.TestObjects [Command("command")] public class TestCommand : Command { - [CommandOption("int", ShortName = 'i', IsRequired = true)] + [CommandOption("int", 'i', IsRequired = true)] public int IntOption { get; set; } = 24; - [CommandOption("str", ShortName = 's')] + [CommandOption("str", 's')] public string StringOption { get; set; } = "foo bar"; public override ExitCode Execute() => new ExitCode(IntOption, StringOption); diff --git a/CliFx/Attributes/CommandOptionAttribute.cs b/CliFx/Attributes/CommandOptionAttribute.cs index 67f49d0..f838249 100644 --- a/CliFx/Attributes/CommandOptionAttribute.cs +++ b/CliFx/Attributes/CommandOptionAttribute.cs @@ -7,15 +7,31 @@ namespace CliFx.Attributes { public string Name { get; } - public char ShortName { get; set; } + public char? ShortName { get; } public bool IsRequired { get; set; } public string Description { get; set; } - public CommandOptionAttribute(string name) + public CommandOptionAttribute(string name, char? shortName) { Name = name; + ShortName = shortName; + } + + public CommandOptionAttribute(string name, char shortName) + : this(name, (char?) shortName) + { + } + + public CommandOptionAttribute(string name) + : this(name, null) + { + } + + public CommandOptionAttribute(char shortName) + : this(null, shortName) + { } } } \ No newline at end of file diff --git a/CliFx/Internal/CommandOptionProperty.cs b/CliFx/Internal/CommandOptionProperty.cs index 3b1dd15..1c5be2a 100644 --- a/CliFx/Internal/CommandOptionProperty.cs +++ b/CliFx/Internal/CommandOptionProperty.cs @@ -12,13 +12,13 @@ namespace CliFx.Internal public string Name { get; } - public char ShortName { get; } + public char? ShortName { get; } public bool IsRequired { get; } public string Description { get; } - public CommandOptionProperty(PropertyInfo property, string name, char shortName, bool isRequired, string description) + public CommandOptionProperty(PropertyInfo property, string name, char? shortName, bool isRequired, string description) { _property = property; Name = name; diff --git a/CliFx/Internal/Extensions.cs b/CliFx/Internal/Extensions.cs index 73c7020..24afa0d 100644 --- a/CliFx/Internal/Extensions.cs +++ b/CliFx/Internal/Extensions.cs @@ -1,5 +1,7 @@ using System; +using System.Collections; using System.Collections.Generic; +using System.Linq; namespace CliFx.Internal { @@ -7,6 +9,8 @@ namespace CliFx.Internal { public static bool IsNullOrWhiteSpace(this string s) => string.IsNullOrWhiteSpace(s); + public static string AsString(this char c) => new string(c, 1); + public static string SubstringUntil(this string s, string sub, StringComparison comparison = StringComparison.Ordinal) { var index = s.IndexOf(sub, comparison); @@ -50,5 +54,30 @@ namespace CliFx.Internal return false; } + + public static bool IsEnumerable(this Type type) => + type == typeof(IEnumerable) || type.GetInterfaces().Contains(typeof(IEnumerable)); + + public static IReadOnlyList GetIEnumerableUnderlyingTypes(this Type type) + { + if (type == typeof(IEnumerable)) + return new[] {typeof(object)}; + + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + return new[] {type.GetGenericArguments()[0]}; + + return type.GetInterfaces() + .Where(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + .Select(t => t.GetGenericArguments()[0]) + .ToArray(); + } + + public static Array ToNonGenericArray(this ICollection source, Type elementType) + { + var array = Array.CreateInstance(elementType, source.Count); + source.CopyTo(array, 0); + + return array; + } } } \ No newline at end of file diff --git a/CliFx/Models/CommandOption.cs b/CliFx/Models/CommandOption.cs new file mode 100644 index 0000000..2362cb6 --- /dev/null +++ b/CliFx/Models/CommandOption.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; + +namespace CliFx.Models +{ + public class CommandOption + { + public string Name { get; } + + public IReadOnlyList Values { get; } + + public CommandOption(string name, IReadOnlyList values) + { + Name = name; + Values = values; + } + + public CommandOption(string name, string value) + : this(name, new[] {value}) + { + } + + public CommandOption(string name) + : this(name, new string[0]) + { + } + } +} \ No newline at end of file diff --git a/CliFx/Models/CommandOptionSet.cs b/CliFx/Models/CommandOptionSet.cs index cab7b56..6bb91ae 100644 --- a/CliFx/Models/CommandOptionSet.cs +++ b/CliFx/Models/CommandOptionSet.cs @@ -8,21 +8,21 @@ namespace CliFx.Models { public string CommandName { get; } - public IReadOnlyDictionary Options { get; } + public IReadOnlyList Options { get; } - public CommandOptionSet(string commandName, IReadOnlyDictionary options) + public CommandOptionSet(string commandName, IReadOnlyList options) { CommandName = commandName; Options = options; } - public CommandOptionSet(IReadOnlyDictionary options) + public CommandOptionSet(IReadOnlyList options) : this(null, options) { } public CommandOptionSet(string commandName) - : this(commandName, new Dictionary()) + : this(commandName, new CommandOption[0]) { } @@ -30,7 +30,7 @@ namespace CliFx.Models { if (Options.Any()) { - var optionsJoined = Options.Select(o => o.Key).JoinToString(", "); + var optionsJoined = Options.Select(o => o.Name).JoinToString(", "); return !CommandName.IsNullOrWhiteSpace() ? $"{CommandName} / [{optionsJoined}]" : $"[{optionsJoined}]"; } else @@ -42,6 +42,6 @@ namespace CliFx.Models public partial class CommandOptionSet { - public static CommandOptionSet Empty { get; } = new CommandOptionSet(new Dictionary()); + public static CommandOptionSet Empty { get; } = new CommandOptionSet(new CommandOption[0]); } } \ No newline at end of file diff --git a/CliFx/Models/Extensions.cs b/CliFx/Models/Extensions.cs new file mode 100644 index 0000000..62b030a --- /dev/null +++ b/CliFx/Models/Extensions.cs @@ -0,0 +1,21 @@ +using System; +using System.Linq; +using CliFx.Internal; + +namespace CliFx.Models +{ + public static class Extensions + { + public static CommandOption GetOptionOrDefault(this CommandOptionSet set, string name, char? shortName) => + set.Options.FirstOrDefault(o => + { + if (!name.IsNullOrWhiteSpace() && string.Equals(o.Name, name, StringComparison.Ordinal)) + return true; + + if (shortName != null && o.Name.Length == 1 && o.Name.Single() == shortName) + return true; + + return false; + }); + } +} \ No newline at end of file diff --git a/CliFx/Services/CommandOptionConverter.cs b/CliFx/Services/CommandOptionConverter.cs index 529933d..6860487 100644 --- a/CliFx/Services/CommandOptionConverter.cs +++ b/CliFx/Services/CommandOptionConverter.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Reflection; using CliFx.Exceptions; using CliFx.Internal; +using CliFx.Models; namespace CliFx.Services { @@ -21,7 +22,7 @@ namespace CliFx.Services { } - public object ConvertOption(string value, Type targetType) + private object ConvertValue(string value, Type targetType) { // String or object if (targetType == typeof(string) || targetType == typeof(object)) @@ -194,7 +195,7 @@ namespace CliFx.Services if (value.IsNullOrWhiteSpace()) return null; - return ConvertOption(value, nullableUnderlyingType); + return ConvertValue(value, nullableUnderlyingType); } // Has a constructor that accepts a single string @@ -214,5 +215,26 @@ namespace CliFx.Services // Unknown type throw new CommandOptionConvertException($"Can't convert value [{value}] to unrecognized type [{targetType}]."); } + + public object ConvertOption(CommandOption option, Type targetType) + { + if (targetType != typeof(string) && targetType.IsEnumerable()) + { + var underlyingType = targetType.GetIEnumerableUnderlyingTypes().FirstOrDefault() ?? typeof(object); + + if (targetType.IsAssignableFrom(underlyingType.MakeArrayType())) + return option.Values.Select(v => ConvertValue(v, underlyingType)).ToArray().ToNonGenericArray(underlyingType); + + throw new CommandOptionConvertException( + $"Can't convert sequence of values [{option.Values.JoinToString(", ")}] to type [{targetType}]."); + } + else + { + // Take first value and ignore the rest + var value = option.Values.FirstOrDefault(); + + return ConvertValue(value, targetType); + } + } } } \ No newline at end of file diff --git a/CliFx/Services/CommandOptionParser.cs b/CliFx/Services/CommandOptionParser.cs index b555c65..8238599 100644 --- a/CliFx/Services/CommandOptionParser.cs +++ b/CliFx/Services/CommandOptionParser.cs @@ -1,6 +1,6 @@ using System; using System.Collections.Generic; -using System.Globalization; +using System.Linq; using CliFx.Internal; using CliFx.Models; @@ -14,7 +14,7 @@ namespace CliFx.Services string commandName = null; // Initialize options - var options = new Dictionary(StringComparer.OrdinalIgnoreCase); + var rawOptions = new Dictionary>(); // Keep track of the last option's name string optionName = null; @@ -28,7 +28,9 @@ namespace CliFx.Services { // Extract option name (skip 2 chars) optionName = commandLineArgument.Substring(2); - options[optionName] = null; + + if (rawOptions.GetValueOrDefault(optionName) == null) + rawOptions[optionName] = new List(); } // Short option name @@ -36,7 +38,9 @@ namespace CliFx.Services { // Extract option name (skip 1 char) optionName = commandLineArgument.Substring(1); - options[optionName] = null; + + if (rawOptions.GetValueOrDefault(optionName) == null) + rawOptions[optionName] = new List(); } // Multiple stacked short options @@ -44,8 +48,10 @@ namespace CliFx.Services { foreach (var c in commandLineArgument.Substring(1)) { - optionName = c.ToString(CultureInfo.InvariantCulture); - options[optionName] = null; + optionName = c.AsString(); + + if (rawOptions.GetValueOrDefault(optionName) == null) + rawOptions[optionName] = new List(); } } @@ -59,13 +65,13 @@ namespace CliFx.Services else if (!optionName.IsNullOrWhiteSpace()) { // ReSharper disable once AssignNullToNotNullAttribute - options[optionName] = commandLineArgument; + rawOptions[optionName].Add(commandLineArgument); } isFirstArgument = false; } - return new CommandOptionSet(commandName, options); + return new CommandOptionSet(commandName, rawOptions.Select(p => new CommandOption(p.Key, p.Value)).ToArray()); } } } \ No newline at end of file diff --git a/CliFx/Services/CommandResolver.cs b/CliFx/Services/CommandResolver.cs index 81305da..f37f891 100644 --- a/CliFx/Services/CommandResolver.cs +++ b/CliFx/Services/CommandResolver.cs @@ -1,10 +1,10 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using CliFx.Attributes; using CliFx.Exceptions; using CliFx.Internal; +using CliFx.Models; namespace CliFx.Services { @@ -86,18 +86,19 @@ namespace CliFx.Services // Set command options foreach (var property in commandType.GetOptionProperties()) { - // If option set contains this property - set value - if (optionSet.Options.TryGetValue(property.Name, out var value) || - optionSet.Options.TryGetValue(property.ShortName.ToString(CultureInfo.InvariantCulture), out value)) + // Get option for this property + var option = optionSet.GetOptionOrDefault(property.Name, property.ShortName); + + // If there are any matching options - set value + if (option != null) { - var convertedValue = _commandOptionConverter.ConvertOption(value, property.Type); + var convertedValue = _commandOptionConverter.ConvertOption(option, property.Type); property.SetValue(command, convertedValue); } // If the property is missing but it's required - throw else if (property.IsRequired) { - throw new CommandResolveException( - $"Can't resolve command [{optionSet.CommandName}] because required property [{property.Name}] is not set."); + throw new CommandResolveException($"Can't resolve command because required property [{property.Name}] is not set."); } } diff --git a/CliFx/Services/ICommandOptionConverter.cs b/CliFx/Services/ICommandOptionConverter.cs index 395bf30..125d17c 100644 --- a/CliFx/Services/ICommandOptionConverter.cs +++ b/CliFx/Services/ICommandOptionConverter.cs @@ -1,9 +1,10 @@ using System; +using CliFx.Models; namespace CliFx.Services { public interface ICommandOptionConverter { - object ConvertOption(string value, Type targetType); + object ConvertOption(CommandOption option, Type targetType); } } \ No newline at end of file