Add positional arguments (#32)

This commit is contained in:
Thorkil Holm-Jacobsen
2020-01-13 12:31:05 +01:00
committed by Alexey Golub
parent ed87373dc3
commit e48839b938
32 changed files with 1150 additions and 211 deletions

View File

@@ -14,7 +14,7 @@ namespace CliFx.Demo.Commands
{ {
private readonly LibraryService _libraryService; 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; } public string Title { get; set; }
[CommandOption("author", 'a', IsRequired = true, Description = "Book author.")] [CommandOption("author", 'a', IsRequired = true, Description = "Book author.")]

View File

@@ -32,7 +32,7 @@ namespace CliFx.Tests
.UseDescription("test") .UseDescription("test")
.UseConsole(new VirtualConsole(TextWriter.Null)) .UseConsole(new VirtualConsole(TextWriter.Null))
.UseCommandFactory(schema => (ICommand) Activator.CreateInstance(schema.Type!)!) .UseCommandFactory(schema => (ICommand) Activator.CreateInstance(schema.Type!)!)
.UseCommandOptionInputConverter(new CommandOptionInputConverter()) .UseCommandOptionInputConverter(new CommandInputConverter())
.UseEnvironmentVariablesProvider(new EnvironmentVariablesProviderStub()) .UseEnvironmentVariablesProvider(new EnvironmentVariablesProviderStub())
.Build(); .Build();
} }

View File

@@ -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<TestCaseData> GetTestCases_ValidatorTest()
{
// Validation should succeed when no arguments are supplied
yield return new TestCaseData(new ValidatorTest(new List<CommandArgumentSchema>(), 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<int> EnumerableProperty { get; set; }
public string StringProperty { get; set; }
}
public class ValidatorTest
{
public ValidatorTest(IReadOnlyCollection<CommandArgumentSchema> schemas, bool succeedsValidation)
{
Schemas = schemas;
SucceedsValidation = succeedsValidation;
}
public IReadOnlyCollection<CommandArgumentSchema> 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);
}
}
}

View File

@@ -13,7 +13,7 @@ namespace CliFx.Tests.Services
public class CommandFactoryTests public class CommandFactoryTests
{ {
private static CommandSchema GetCommandSchema(Type commandType) => private static CommandSchema GetCommandSchema(Type commandType) =>
new CommandSchemaResolver().GetCommandSchemas(new[] {commandType}).Single(); new CommandSchemaResolver(new CommandArgumentSchemasValidator()).GetCommandSchemas(new[] {commandType}).Single();
private static IEnumerable<TestCaseData> GetTestCases_CreateCommand() private static IEnumerable<TestCaseData> GetTestCases_CreateCommand()
{ {

View File

@@ -16,144 +16,215 @@ namespace CliFx.Tests.Services
public class CommandInitializerTests public class CommandInitializerTests
{ {
private static CommandSchema GetCommandSchema(Type commandType) => private static CommandSchema GetCommandSchema(Type commandType) =>
new CommandSchemaResolver().GetCommandSchemas(new[] { commandType }).Single(); new CommandSchemaResolver(new CommandArgumentSchemasValidator()).GetCommandSchemas(new[] { commandType }).Single();
private static IEnumerable<TestCaseData> GetTestCases_InitializeCommand() private static IEnumerable<TestCaseData> GetTestCases_InitializeCommand()
{ {
yield return new TestCaseData( yield return new TestCaseData(
new DivideCommand(), new DivideCommand(),
GetCommandSchema(typeof(DivideCommand)), new CommandCandidate(
new CommandInput("div", new[] GetCommandSchema(typeof(DivideCommand)),
{ new string[0],
new CommandOptionInput("dividend", "13"), new CommandInput(new[] { "div" }, new[]
new CommandOptionInput("divisor", "8") {
}), new CommandOptionInput("dividend", "13"),
new CommandOptionInput("divisor", "8")
})),
new DivideCommand { Dividend = 13, Divisor = 8 } new DivideCommand { Dividend = 13, Divisor = 8 }
); );
yield return new TestCaseData( yield return new TestCaseData(
new DivideCommand(), new DivideCommand(),
GetCommandSchema(typeof(DivideCommand)), new CommandCandidate(
new CommandInput("div", new[] GetCommandSchema(typeof(DivideCommand)),
{ new string[0],
new CommandOptionInput("dividend", "13"), new CommandInput(new[] { "div" }, new[]
new CommandOptionInput("d", "8") {
}), new CommandOptionInput("dividend", "13"),
new CommandOptionInput("d", "8")
})),
new DivideCommand { Dividend = 13, Divisor = 8 } new DivideCommand { Dividend = 13, Divisor = 8 }
); );
yield return new TestCaseData( yield return new TestCaseData(
new DivideCommand(), new DivideCommand(),
GetCommandSchema(typeof(DivideCommand)), new CommandCandidate(
new CommandInput("div", new[] GetCommandSchema(typeof(DivideCommand)),
{ new string[0],
new CommandOptionInput("D", "13"), new CommandInput(new[] { "div" }, new[]
new CommandOptionInput("d", "8") {
}), new CommandOptionInput("D", "13"),
new CommandOptionInput("d", "8")
})),
new DivideCommand { Dividend = 13, Divisor = 8 } new DivideCommand { Dividend = 13, Divisor = 8 }
); );
yield return new TestCaseData( yield return new TestCaseData(
new ConcatCommand(), new ConcatCommand(),
GetCommandSchema(typeof(ConcatCommand)), new CommandCandidate(
new CommandInput("concat", new[] GetCommandSchema(typeof(ConcatCommand)),
{ new string[0],
new CommandOptionInput("i", new[] {"foo", " ", "bar"}) new CommandInput(new[] { "concat" }, new[]
}), {
new CommandOptionInput("i", new[] { "foo", " ", "bar" })
})),
new ConcatCommand { Inputs = new[] { "foo", " ", "bar" } } new ConcatCommand { Inputs = new[] { "foo", " ", "bar" } }
); );
yield return new TestCaseData( yield return new TestCaseData(
new ConcatCommand(), new ConcatCommand(),
GetCommandSchema(typeof(ConcatCommand)), new CommandCandidate(
new CommandInput("concat", new[] GetCommandSchema(typeof(ConcatCommand)),
{ new string[0],
new CommandOptionInput("i", new[] {"foo", "bar"}), new CommandInput(new[] { "concat" }, new[]
new CommandOptionInput("s", " ") {
}), new CommandOptionInput("i", new[] { "foo", "bar" }),
new CommandOptionInput("s", " ")
})),
new ConcatCommand { Inputs = new[] { "foo", "bar" }, Separator = " " } new ConcatCommand { Inputs = new[] { "foo", "bar" }, Separator = " " }
); );
//Will read a value from environment variables because none is supplied via CommandInput //Will read a value from environment variables because none is supplied via CommandInput
yield return new TestCaseData( yield return new TestCaseData(
new EnvironmentVariableCommand(), new EnvironmentVariableCommand(),
GetCommandSchema(typeof(EnvironmentVariableCommand)), new CommandCandidate(
new CommandInput(null, new CommandOptionInput[0], EnvironmentVariablesProviderStub.EnvironmentVariables), GetCommandSchema(typeof(EnvironmentVariableCommand)),
new string[0],
new CommandInput(new string[0], new CommandOptionInput[0], EnvironmentVariablesProviderStub.EnvironmentVariables)),
new EnvironmentVariableCommand { Option = "A" } new EnvironmentVariableCommand { Option = "A" }
); );
//Will read multiple values from environment variables because none is supplied via CommandInput //Will read multiple values from environment variables because none is supplied via CommandInput
yield return new TestCaseData( yield return new TestCaseData(
new EnvironmentVariableWithMultipleValuesCommand(), new EnvironmentVariableWithMultipleValuesCommand(),
GetCommandSchema(typeof(EnvironmentVariableWithMultipleValuesCommand)), new CommandCandidate(
new CommandInput(null, new CommandOptionInput[0], EnvironmentVariablesProviderStub.EnvironmentVariables), GetCommandSchema(typeof(EnvironmentVariableWithMultipleValuesCommand)),
new string[0],
new CommandInput(new string[0], new CommandOptionInput[0], EnvironmentVariablesProviderStub.EnvironmentVariables)),
new EnvironmentVariableWithMultipleValuesCommand { Option = new[] { "A", "B", "C" } } new EnvironmentVariableWithMultipleValuesCommand { Option = new[] { "A", "B", "C" } }
); );
//Will not read a value from environment variables because one is supplied via CommandInput //Will not read a value from environment variables because one is supplied via CommandInput
yield return new TestCaseData( yield return new TestCaseData(
new EnvironmentVariableCommand(), new EnvironmentVariableCommand(),
GetCommandSchema(typeof(EnvironmentVariableCommand)), new CommandCandidate(
new CommandInput(null, new[] GetCommandSchema(typeof(EnvironmentVariableCommand)),
{ new string[0],
new CommandOptionInput("opt", new[] { "X" }) new CommandInput(new string[0], new[]
}, {
EnvironmentVariablesProviderStub.EnvironmentVariables), new CommandOptionInput("opt", new[] { "X" })
},
EnvironmentVariablesProviderStub.EnvironmentVariables)),
new EnvironmentVariableCommand { Option = "X" } new EnvironmentVariableCommand { Option = "X" }
); );
//Will not split environment variable values because underlying property is not a collection //Will not split environment variable values because underlying property is not a collection
yield return new TestCaseData( yield return new TestCaseData(
new EnvironmentVariableWithoutCollectionPropertyCommand(), new EnvironmentVariableWithoutCollectionPropertyCommand(),
GetCommandSchema(typeof(EnvironmentVariableWithoutCollectionPropertyCommand)), new CommandCandidate(
new CommandInput(null, new CommandOptionInput[0], EnvironmentVariablesProviderStub.EnvironmentVariables), GetCommandSchema(typeof(EnvironmentVariableWithoutCollectionPropertyCommand)),
new EnvironmentVariableWithoutCollectionPropertyCommand { Option = $"A{Path.PathSeparator}B{Path.PathSeparator}C{Path.PathSeparator}" } 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<string, string>())),
new ArgumentCommand { FirstArgument = "abc", SecondArgument = 123, ThirdArguments = new List<int>{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<string, string>())),
new ArgumentCommand { FirstArgument = "abc", Option = "option value" }
);
} }
private static IEnumerable<TestCaseData> GetTestCases_InitializeCommand_Negative() private static IEnumerable<TestCaseData> GetTestCases_InitializeCommand_Negative()
{ {
yield return new TestCaseData( yield return new TestCaseData(
new DivideCommand(), new DivideCommand(),
GetCommandSchema(typeof(DivideCommand)), new CommandCandidate(
new CommandInput("div") GetCommandSchema(typeof(DivideCommand)),
); new string[0],
new CommandInput(new[] { "div" })
));
yield return new TestCaseData( yield return new TestCaseData(
new DivideCommand(), new DivideCommand(),
GetCommandSchema(typeof(DivideCommand)), new CommandCandidate(
new CommandInput("div", new[] GetCommandSchema(typeof(DivideCommand)),
{ new string[0],
new CommandOptionInput("D", "13") new CommandInput(new[] { "div" }, new[]
}) {
); new CommandOptionInput("D", "13")
})
));
yield return new TestCaseData( yield return new TestCaseData(
new ConcatCommand(), new ConcatCommand(),
GetCommandSchema(typeof(ConcatCommand)), new CommandCandidate(
new CommandInput("concat") GetCommandSchema(typeof(ConcatCommand)),
); new string[0],
new CommandInput(new[] { "concat" })
));
yield return new TestCaseData( yield return new TestCaseData(
new ConcatCommand(), new ConcatCommand(),
GetCommandSchema(typeof(ConcatCommand)), new CommandCandidate(
new CommandInput("concat", new[] GetCommandSchema(typeof(ConcatCommand)),
{ new string[0],
new CommandOptionInput("s", "_") 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<string, string>()))
);
// 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<string, string>()))
);
// 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<string, string>()))
);
} }
[Test] [Test]
[TestCaseSource(nameof(GetTestCases_InitializeCommand))] [TestCaseSource(nameof(GetTestCases_InitializeCommand))]
public void InitializeCommand_Test(ICommand command, CommandSchema commandSchema, CommandInput commandInput, public void InitializeCommand_Test(ICommand command, CommandCandidate commandCandidate,
ICommand expectedCommand) ICommand expectedCommand)
{ {
// Arrange // Arrange
var initializer = new CommandInitializer(); var initializer = new CommandInitializer();
// Act // Act
initializer.InitializeCommand(command, commandSchema, commandInput); initializer.InitializeCommand(command, commandCandidate);
// Assert // Assert
command.Should().BeEquivalentTo(expectedCommand, o => o.RespectingRuntimeTypes()); command.Should().BeEquivalentTo(expectedCommand, o => o.RespectingRuntimeTypes());
@@ -161,13 +232,13 @@ namespace CliFx.Tests.Services
[Test] [Test]
[TestCaseSource(nameof(GetTestCases_InitializeCommand_Negative))] [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 // Arrange
var initializer = new CommandInitializer(); var initializer = new CommandInitializer();
// Act & Assert // Act & Assert
initializer.Invoking(i => i.InitializeCommand(command, commandSchema, commandInput)) initializer.Invoking(i => i.InitializeCommand(command, commandCandidate))
.Should().ThrowExactly<CliFxException>(); .Should().ThrowExactly<CliFxException>();
} }
} }

View File

@@ -12,7 +12,7 @@ using NUnit.Framework;
namespace CliFx.Tests.Services namespace CliFx.Tests.Services
{ {
[TestFixture] [TestFixture]
public class CommandOptionInputConverterTests public class CommandInputConverterTests
{ {
private static IEnumerable<TestCaseData> GetTestCases_ConvertOptionInput() private static IEnumerable<TestCaseData> GetTestCases_ConvertOptionInput()
{ {
@@ -298,7 +298,7 @@ namespace CliFx.Tests.Services
object expectedConvertedValue) object expectedConvertedValue)
{ {
// Arrange // Arrange
var converter = new CommandOptionInputConverter(); var converter = new CommandInputConverter();
// Act // Act
var convertedValue = converter.ConvertOptionInput(optionInput, targetType); var convertedValue = converter.ConvertOptionInput(optionInput, targetType);
@@ -313,7 +313,7 @@ namespace CliFx.Tests.Services
public void ConvertOptionInput_Negative_Test(CommandOptionInput optionInput, Type targetType) public void ConvertOptionInput_Negative_Test(CommandOptionInput optionInput, Type targetType)
{ {
// Arrange // Arrange
var converter = new CommandOptionInputConverter(); var converter = new CommandInputConverter();
// Act & Assert // Act & Assert
converter.Invoking(c => c.ConvertOptionInput(optionInput, targetType)) converter.Invoking(c => c.ConvertOptionInput(optionInput, targetType))

View File

@@ -158,13 +158,13 @@ namespace CliFx.Tests.Services
yield return new TestCaseData( yield return new TestCaseData(
new[] { "command" }, new[] { "command" },
new CommandInput("command"), new CommandInput(new []{ "command" }),
new EmptyEnvironmentVariablesProviderStub() new EmptyEnvironmentVariablesProviderStub()
); );
yield return new TestCaseData( yield return new TestCaseData(
new[] { "command", "--option", "value" }, new[] { "command", "--option", "value" },
new CommandInput("command", new[] new CommandInput(new []{ "command" }, new[]
{ {
new CommandOptionInput("option", "value") new CommandOptionInput("option", "value")
}), }),
@@ -173,13 +173,13 @@ namespace CliFx.Tests.Services
yield return new TestCaseData( yield return new TestCaseData(
new[] { "long", "command", "name" }, new[] { "long", "command", "name" },
new CommandInput("long command name"), new CommandInput(new []{ "long", "command", "name"}),
new EmptyEnvironmentVariablesProviderStub() new EmptyEnvironmentVariablesProviderStub()
); );
yield return new TestCaseData( yield return new TestCaseData(
new[] { "long", "command", "name", "--option", "value" }, new[] { "long", "command", "name", "--option", "value" },
new CommandInput("long command name", new[] new CommandInput(new []{ "long", "command", "name" }, new[]
{ {
new CommandOptionInput("option", "value") new CommandOptionInput("option", "value")
}), }),
@@ -188,7 +188,7 @@ namespace CliFx.Tests.Services
yield return new TestCaseData( yield return new TestCaseData(
new[] { "[debug]" }, new[] { "[debug]" },
new CommandInput(null, new CommandInput(new string[0],
new[] { "debug" }, new[] { "debug" },
new CommandOptionInput[0]), new CommandOptionInput[0]),
new EmptyEnvironmentVariablesProviderStub() new EmptyEnvironmentVariablesProviderStub()
@@ -196,7 +196,7 @@ namespace CliFx.Tests.Services
yield return new TestCaseData( yield return new TestCaseData(
new[] { "[debug]", "[preview]" }, new[] { "[debug]", "[preview]" },
new CommandInput(null, new CommandInput(new string[0],
new[] { "debug", "preview" }, new[] { "debug", "preview" },
new CommandOptionInput[0]), new CommandOptionInput[0]),
new EmptyEnvironmentVariablesProviderStub() new EmptyEnvironmentVariablesProviderStub()
@@ -204,7 +204,7 @@ namespace CliFx.Tests.Services
yield return new TestCaseData( yield return new TestCaseData(
new[] { "[debug]", "[preview]", "-o", "value" }, new[] { "[debug]", "[preview]", "-o", "value" },
new CommandInput(null, new CommandInput(new string[0],
new[] { "debug", "preview" }, new[] { "debug", "preview" },
new[] new[]
{ {
@@ -215,7 +215,7 @@ namespace CliFx.Tests.Services
yield return new TestCaseData( yield return new TestCaseData(
new[] { "command", "[debug]", "[preview]", "-o", "value" }, new[] { "command", "[debug]", "[preview]", "-o", "value" },
new CommandInput("command", new CommandInput(new []{"command"},
new[] { "debug", "preview" }, new[] { "debug", "preview" },
new[] new[]
{ {
@@ -226,7 +226,7 @@ namespace CliFx.Tests.Services
yield return new TestCaseData( yield return new TestCaseData(
new[] { "command", "[debug]", "[preview]", "-o", "value" }, new[] { "command", "[debug]", "[preview]", "-o", "value" },
new CommandInput("command", new CommandInput(new []{ "command"},
new[] { "debug", "preview" }, new[] { "debug", "preview" },
new[] new[]
{ {

View File

@@ -19,7 +19,7 @@ namespace CliFx.Tests.Services
new[] new[]
{ {
new CommandSchema(typeof(DivideCommand), "div", "Divide one number by another.", new CommandSchema(typeof(DivideCommand), "div", "Divide one number by another.",
new[] new CommandArgumentSchema[0], new[]
{ {
new CommandOptionSchema(typeof(DivideCommand).GetProperty(nameof(DivideCommand.Dividend)), new CommandOptionSchema(typeof(DivideCommand).GetProperty(nameof(DivideCommand.Dividend)),
"dividend", 'D', true, "The number to divide.", null), "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) "divisor", 'd', true, "The number to divide by.", null)
}), }),
new CommandSchema(typeof(ConcatCommand), "concat", "Concatenate strings.", new CommandSchema(typeof(ConcatCommand), "concat", "Concatenate strings.",
new CommandArgumentSchema[0],
new[] new[]
{ {
new CommandOptionSchema(typeof(ConcatCommand).GetProperty(nameof(ConcatCommand.Inputs)), new CommandOptionSchema(typeof(ConcatCommand).GetProperty(nameof(ConcatCommand.Inputs)),
@@ -35,6 +36,7 @@ namespace CliFx.Tests.Services
null, 's', false, "String separator.", null) null, 's', false, "String separator.", null)
}), }),
new CommandSchema(typeof(EnvironmentVariableCommand), null, "Reads option values from environment variables.", new CommandSchema(typeof(EnvironmentVariableCommand), null, "Reads option values from environment variables.",
new CommandArgumentSchema[0],
new[] new[]
{ {
new CommandOptionSchema(typeof(EnvironmentVariableCommand).GetProperty(nameof(EnvironmentVariableCommand.Option)), new CommandOptionSchema(typeof(EnvironmentVariableCommand).GetProperty(nameof(EnvironmentVariableCommand.Option)),
@@ -48,7 +50,7 @@ namespace CliFx.Tests.Services
new[] { typeof(HelloWorldDefaultCommand) }, new[] { typeof(HelloWorldDefaultCommand) },
new[] 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[] yield return new TestCaseData(new object[]
{ {
new[] {typeof(NonImplementedCommand)} new[] { typeof(NonImplementedCommand) }
}); });
yield return new TestCaseData(new object[] yield return new TestCaseData(new object[]
{ {
new[] {typeof(NonAnnotatedCommand)} new[] { typeof(NonAnnotatedCommand) }
}); });
yield return new TestCaseData(new object[] yield return new TestCaseData(new object[]
{ {
new[] {typeof(DuplicateOptionNamesCommand)} new[] { typeof(DuplicateOptionNamesCommand) }
}); });
yield return new TestCaseData(new object[] yield return new TestCaseData(new object[]
{ {
new[] {typeof(DuplicateOptionShortNamesCommand)} new[] { typeof(DuplicateOptionShortNamesCommand) }
}); });
yield return new TestCaseData(new object[] yield return new TestCaseData(new object[]
{ {
new[] {typeof(ExceptionCommand), typeof(CommandExceptionCommand)} new[] { typeof(ExceptionCommand), typeof(CommandExceptionCommand) }
}); });
} }
private static IEnumerable<TestCaseData> 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<TestCaseData> 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] [Test]
[TestCaseSource(nameof(GetTestCases_GetCommandSchemas))] [TestCaseSource(nameof(GetTestCases_GetCommandSchemas))]
public void GetCommandSchemas_Test(IReadOnlyList<Type> commandTypes, public void GetCommandSchemas_Test(IReadOnlyList<Type> commandTypes,
IReadOnlyList<CommandSchema> expectedCommandSchemas) IReadOnlyList<CommandSchema> expectedCommandSchemas)
{ {
// Arrange // Arrange
var commandSchemaResolver = new CommandSchemaResolver(); var commandSchemaResolver = new CommandSchemaResolver(new CommandArgumentSchemasValidator());
// Act // Act
var commandSchemas = commandSchemaResolver.GetCommandSchemas(commandTypes); var commandSchemas = commandSchemaResolver.GetCommandSchemas(commandTypes);
@@ -106,11 +263,44 @@ namespace CliFx.Tests.Services
public void GetCommandSchemas_Negative_Test(IReadOnlyList<Type> commandTypes) public void GetCommandSchemas_Negative_Test(IReadOnlyList<Type> commandTypes)
{ {
// Arrange // Arrange
var resolver = new CommandSchemaResolver(); var resolver = new CommandSchemaResolver(new CommandArgumentSchemasValidator());
// Act & Assert // Act & Assert
resolver.Invoking(r => r.GetCommandSchemas(commandTypes)) resolver.Invoking(r => r.GetCommandSchemas(commandTypes))
.Should().ThrowExactly<CliFxException>(); .Should().ThrowExactly<CliFxException>();
} }
[Test]
[TestCaseSource(nameof(GetTestCases_GetTargetCommandSchema_Positive))]
public void GetTargetCommandSchema_Positive_Test(IReadOnlyList<CommandSchema> availableCommandSchemas,
CommandInput commandInput,
IReadOnlyList<string> 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<CommandSchema> availableCommandSchemas, CommandInput commandInput)
{
// Arrange
var resolver = new CommandSchemaResolver(new CommandArgumentSchemasValidator());
// Act
var commandCandidate = resolver.GetTargetCommandSchema(availableCommandSchemas, commandInput);
// Assert
commandCandidate.Should().BeNull();
}
} }
} }

View File

@@ -13,7 +13,7 @@ namespace CliFx.Tests.Services
public class DelegateCommandFactoryTests public class DelegateCommandFactoryTests
{ {
private static CommandSchema GetCommandSchema(Type commandType) => private static CommandSchema GetCommandSchema(Type commandType) =>
new CommandSchemaResolver().GetCommandSchemas(new[] {commandType}).Single(); new CommandSchemaResolver(new CommandArgumentSchemasValidator()).GetCommandSchemas(new[] {commandType}).Single();
private static IEnumerable<TestCaseData> GetTestCases_CreateCommand() private static IEnumerable<TestCaseData> GetTestCases_CreateCommand()
{ {

View File

@@ -15,7 +15,7 @@ namespace CliFx.Tests.Services
{ {
private static HelpTextSource CreateHelpTextSource(IReadOnlyList<Type> availableCommandTypes, Type targetCommandType) private static HelpTextSource CreateHelpTextSource(IReadOnlyList<Type> availableCommandTypes, Type targetCommandType)
{ {
var commandSchemaResolver = new CommandSchemaResolver(); var commandSchemaResolver = new CommandSchemaResolver(new CommandArgumentSchemasValidator());
var applicationMetadata = new ApplicationMetadata("TestApp", "testapp", "1.0", null); var applicationMetadata = new ApplicationMetadata("TestApp", "testapp", "1.0", null);
var availableCommandSchemas = commandSchemaResolver.GetCommandSchemas(availableCommandTypes); var availableCommandSchemas = commandSchemaResolver.GetCommandSchemas(availableCommandTypes);
@@ -85,6 +85,27 @@ namespace CliFx.Tests.Services
"-h|--help", "Shows help text." "-h|--help", "Shows help text."
} }
); );
yield return new TestCaseData(
CreateHelpTextSource(
new[] {typeof(ArgumentCommand)},
typeof(ArgumentCommand)),
new[]
{
"Description",
"Command using positional arguments",
"Usage",
"arg cmd", "<first>", "[<secondargument>]", "[<third list>]", "[options]",
"Arguments",
"* first",
"secondargument",
"third list", "A list of numbers",
"Options",
"-o|--option",
"-h|--help", "Shows help text."
}
);
} }
[Test] [Test]

View File

@@ -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<int> ThirdArguments { get; set; }
[CommandOption("option", 'o')]
public string Option { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,42 @@
using System;
namespace CliFx.Attributes
{
/// <summary>
/// Annotates a property that defines a command argument.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public class CommandArgumentAttribute : Attribute
{
/// <summary>
/// The name of the argument, which is used in help text.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// Whether the argument is required.
/// </summary>
public bool IsRequired { get; set; }
/// <summary>
/// Argument description, which is used in help text.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// The ordering of the argument. Lower values will appear before higher values.
/// <remarks>
/// Two arguments of the same command cannot have the same <see cref="Order"/>.
/// </remarks>
/// </summary>
public int Order { get; }
/// <summary>
/// Initializes an instance of <see cref="CommandArgumentAttribute"/> with a given order.
/// </summary>
public CommandArgumentAttribute(int order)
{
Order = order;
}
}
}

View File

@@ -1,10 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Exceptions; using CliFx.Exceptions;
using CliFx.Internal;
using CliFx.Models; using CliFx.Models;
using CliFx.Services; using CliFx.Services;
@@ -74,7 +72,7 @@ namespace CliFx
return null; return null;
// Render command name // Render command name
_console.Output.WriteLine($"Command name: {commandInput.CommandName}"); _console.Output.WriteLine($"Arguments: {string.Join(" ", commandInput.Arguments)}");
_console.Output.WriteLine(); _console.Output.WriteLine();
// Render directives // Render directives
@@ -103,7 +101,7 @@ namespace CliFx
private int? HandleVersionOption(CommandInput commandInput) private int? HandleVersionOption(CommandInput commandInput)
{ {
// Version should be rendered if it was requested on a default command // 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 shouldn't render version, pass execution to the next handler
if (!shouldRenderVersion) if (!shouldRenderVersion)
@@ -117,10 +115,10 @@ namespace CliFx
} }
private int? HandleHelpOption(CommandInput commandInput, private int? HandleHelpOption(CommandInput commandInput,
IReadOnlyList<CommandSchema> availableCommandSchemas, CommandSchema? targetCommandSchema) IReadOnlyList<CommandSchema> availableCommandSchemas, CommandCandidate? commandCandidate)
{ {
// Help should be rendered if it was requested, or when executing a command which isn't defined // 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 shouldn't render help, pass execution to the next handler
if (!shouldRenderHelp) if (!shouldRenderHelp)
@@ -129,31 +127,22 @@ namespace CliFx
// Keep track whether there was an error in the input // Keep track whether there was an error in the input
var isError = false; var isError = false;
// If target command isn't defined, find its contextual replacement // Report error if no command matched the arguments
if (targetCommandSchema == null) if (commandCandidate is null)
{ {
// If command was specified, inform the user that it's not defined // If a command was specified, inform the user that the command is not defined
if (commandInput.IsCommandSpecified()) if (commandInput.HasArguments())
{ {
_console.WithForegroundColor(ConsoleColor.Red, _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; isError = true;
} }
// Replace target command with closest parent of specified command commandCandidate = new CommandCandidate(CommandSchema.StubDefaultCommand, new string[0], commandInput);
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();
}
} }
// Build help text source // Build help text source
var helpTextSource = new HelpTextSource(_metadata, availableCommandSchemas, targetCommandSchema); var helpTextSource = new HelpTextSource(_metadata, availableCommandSchemas, commandCandidate.Schema);
// Render help text // Render help text
_helpTextRenderer.RenderHelpText(_console, helpTextSource); _helpTextRenderer.RenderHelpText(_console, helpTextSource);
@@ -162,13 +151,18 @@ namespace CliFx
return isError ? -1 : 0; return isError ? -1 : 0;
} }
private async ValueTask<int> HandleCommandExecutionAsync(CommandInput commandInput, CommandSchema targetCommandSchema) private async ValueTask<int> HandleCommandExecutionAsync(CommandCandidate? commandCandidate)
{ {
// Create an instance of the command if (commandCandidate is null)
var command = _commandFactory.CreateCommand(targetCommandSchema); {
throw new ArgumentException("Cannot execute command because it was not found.");
}
// Populate command with options according to its schema // Create an instance of the command
_commandInitializer.InitializeCommand(command, targetCommandSchema, commandInput); var command = _commandFactory.CreateCommand(commandCandidate.Schema);
// Populate command with options and arguments according to its schema
_commandInitializer.InitializeCommand(command, commandCandidate);
// Execute command // Execute command
await command.ExecuteAsync(_console); await command.ExecuteAsync(_console);
@@ -189,15 +183,15 @@ namespace CliFx
var availableCommandSchemas = _commandSchemaResolver.GetCommandSchemas(_configuration.CommandTypes); var availableCommandSchemas = _commandSchemaResolver.GetCommandSchemas(_configuration.CommandTypes);
// Find command schema matching the name specified in the input // 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 // Chain handlers until the first one that produces an exit code
return return
await HandleDebugDirectiveAsync(commandInput) ?? await HandleDebugDirectiveAsync(commandInput) ??
HandlePreviewDirective(commandInput) ?? HandlePreviewDirective(commandInput) ??
HandleVersionOption(commandInput) ?? HandleVersionOption(commandInput) ??
HandleHelpOption(commandInput, availableCommandSchemas, targetCommandSchema) ?? HandleHelpOption(commandInput, availableCommandSchemas, commandCandidate) ??
await HandleCommandExecutionAsync(commandInput, targetCommandSchema!); await HandleCommandExecutionAsync(commandCandidate);
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -25,7 +25,7 @@ namespace CliFx
private string? _description; private string? _description;
private IConsole? _console; private IConsole? _console;
private ICommandFactory? _commandFactory; private ICommandFactory? _commandFactory;
private ICommandOptionInputConverter? _commandOptionInputConverter; private ICommandInputConverter? _commandInputConverter;
private IEnvironmentVariablesProvider? _environmentVariablesProvider; private IEnvironmentVariablesProvider? _environmentVariablesProvider;
/// <inheritdoc /> /// <inheritdoc />
@@ -107,9 +107,9 @@ namespace CliFx
} }
/// <inheritdoc /> /// <inheritdoc />
public ICliApplicationBuilder UseCommandOptionInputConverter(ICommandOptionInputConverter converter) public ICliApplicationBuilder UseCommandOptionInputConverter(ICommandInputConverter converter)
{ {
_commandOptionInputConverter = converter; _commandInputConverter = converter;
return this; return this;
} }
@@ -129,7 +129,7 @@ namespace CliFx
_versionText ??= GetDefaultVersionText() ?? "v1.0"; _versionText ??= GetDefaultVersionText() ?? "v1.0";
_console ??= new SystemConsole(); _console ??= new SystemConsole();
_commandFactory ??= new CommandFactory(); _commandFactory ??= new CommandFactory();
_commandOptionInputConverter ??= new CommandOptionInputConverter(); _commandInputConverter ??= new CommandInputConverter();
_environmentVariablesProvider ??= new EnvironmentVariablesProvider(); _environmentVariablesProvider ??= new EnvironmentVariablesProvider();
// Project parameters to expected types // Project parameters to expected types
@@ -137,8 +137,8 @@ namespace CliFx
var configuration = new ApplicationConfiguration(_commandTypes.ToArray(), _isDebugModeAllowed, _isPreviewModeAllowed); var configuration = new ApplicationConfiguration(_commandTypes.ToArray(), _isDebugModeAllowed, _isPreviewModeAllowed);
return new CliApplication(metadata, configuration, return new CliApplication(metadata, configuration,
_console, new CommandInputParser(_environmentVariablesProvider), new CommandSchemaResolver(), _console, new CommandInputParser(_environmentVariablesProvider), new CommandSchemaResolver(new CommandArgumentSchemasValidator()),
_commandFactory, new CommandInitializer(_commandOptionInputConverter, new EnvironmentVariablesParser()), new HelpTextRenderer()); _commandFactory, new CommandInitializer(_commandInputConverter, new EnvironmentVariablesParser()), new HelpTextRenderer());
} }
} }

View File

@@ -60,9 +60,9 @@ namespace CliFx
ICliApplicationBuilder UseCommandFactory(ICommandFactory factory); ICliApplicationBuilder UseCommandFactory(ICommandFactory factory);
/// <summary> /// <summary>
/// Configures application to use specified implementation of <see cref="ICommandOptionInputConverter"/>. /// Configures application to use specified implementation of <see cref="ICommandInputConverter"/>.
/// </summary> /// </summary>
ICliApplicationBuilder UseCommandOptionInputConverter(ICommandOptionInputConverter converter); ICliApplicationBuilder UseCommandOptionInputConverter(ICommandInputConverter converter);
/// <summary> /// <summary>
/// Configures application to use specified implementation of <see cref="IEnvironmentVariablesProvider"/>. /// Configures application to use specified implementation of <see cref="IEnvironmentVariablesProvider"/>.

View File

@@ -3,6 +3,7 @@ using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using CliFx.Models;
namespace CliFx.Internal namespace CliFx.Internal
{ {
@@ -66,5 +67,11 @@ namespace CliFx.Internal
public static bool IsCollection(this Type type) => public static bool IsCollection(this Type type) =>
type != typeof(string) && type.GetEnumerableUnderlyingType() != null; type != typeof(string) && type.GetEnumerableUnderlyingType() != null;
public static IOrderedEnumerable<CommandArgumentSchema> Ordered(this IEnumerable<CommandArgumentSchema> source)
{
return source
.OrderBy(a => a.Order);
}
} }
} }

View File

@@ -0,0 +1,78 @@
using System.Globalization;
using System.Reflection;
using System.Text;
namespace CliFx.Models
{
/// <summary>
/// Schema of a defined command argument.
/// </summary>
public class CommandArgumentSchema
{
/// <summary>
/// Underlying property.
/// </summary>
public PropertyInfo Property { get; }
/// <summary>
/// Argument name used for help text.
/// </summary>
public string? Name { get; }
/// <summary>
/// Whether the argument is required.
/// </summary>
public bool IsRequired { get; }
/// <summary>
/// Argument description.
/// </summary>
public string? Description { get; }
/// <summary>
/// Order of the argument.
/// </summary>
public int Order { get; }
/// <summary>
/// The display name of the argument. Returns <see cref="Name"/> if specified, otherwise the name of the underlying property.
/// </summary>
public string DisplayName => !string.IsNullOrWhiteSpace(Name) ? Name! : Property.Name.ToLower(CultureInfo.InvariantCulture);
/// <summary>
/// Initializes an instance of <see cref="CommandArgumentSchema"/>.
/// </summary>
public CommandArgumentSchema(PropertyInfo property, string? name, bool isRequired, string? description, int order)
{
Property = property;
Name = name;
IsRequired = isRequired;
Description = description;
Order = order;
}
/// <summary>
/// Returns the string representation of the argument schema.
/// </summary>
/// <returns></returns>
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();
}
}
}

View File

@@ -0,0 +1,35 @@
using System.Collections.Generic;
namespace CliFx.Models
{
/// <summary>
/// Defines the target command and the input required for initializing the command.
/// </summary>
public class CommandCandidate
{
/// <summary>
/// The command schema of the target command.
/// </summary>
public CommandSchema Schema { get; }
/// <summary>
/// The positional arguments input for the command.
/// </summary>
public IReadOnlyList<string> PositionalArgumentsInput { get; }
/// <summary>
/// The command input for the command.
/// </summary>
public CommandInput CommandInput { get; }
/// <summary>
/// Initializes and instance of <see cref="CommandCandidate"/>
/// </summary>
public CommandCandidate(CommandSchema schema, IReadOnlyList<string> positionalArgumentsInput, CommandInput commandInput)
{
Schema = schema;
PositionalArgumentsInput = positionalArgumentsInput;
CommandInput = commandInput;
}
}
}

View File

@@ -10,10 +10,9 @@ namespace CliFx.Models
public partial class CommandInput public partial class CommandInput
{ {
/// <summary> /// <summary>
/// Specified command name. /// Specified arguments.
/// Can be null if command was not specified.
/// </summary> /// </summary>
public string? CommandName { get; } public IReadOnlyList<string> Arguments { get; }
/// <summary> /// <summary>
/// Specified directives. /// Specified directives.
@@ -33,10 +32,10 @@ namespace CliFx.Models
/// <summary> /// <summary>
/// Initializes an instance of <see cref="CommandInput"/>. /// Initializes an instance of <see cref="CommandInput"/>.
/// </summary> /// </summary>
public CommandInput(string? commandName, IReadOnlyList<string> directives, IReadOnlyList<CommandOptionInput> options, public CommandInput(IReadOnlyList<string> arguments, IReadOnlyList<string> directives, IReadOnlyList<CommandOptionInput> options,
IReadOnlyDictionary<string, string> environmentVariables) IReadOnlyDictionary<string, string> environmentVariables)
{ {
CommandName = commandName; Arguments = arguments;
Directives = directives; Directives = directives;
Options = options; Options = options;
EnvironmentVariables = environmentVariables; EnvironmentVariables = environmentVariables;
@@ -45,24 +44,24 @@ namespace CliFx.Models
/// <summary> /// <summary>
/// Initializes an instance of <see cref="CommandInput"/>. /// Initializes an instance of <see cref="CommandInput"/>.
/// </summary> /// </summary>
public CommandInput(string? commandName, IReadOnlyList<string> directives, IReadOnlyList<CommandOptionInput> options) public CommandInput(IReadOnlyList<string> arguments, IReadOnlyList<string> directives, IReadOnlyList<CommandOptionInput> options)
: this(commandName, directives, options, EmptyEnvironmentVariables) : this(arguments, directives, options, EmptyEnvironmentVariables)
{ {
} }
/// <summary> /// <summary>
/// Initializes an instance of <see cref="CommandInput"/>. /// Initializes an instance of <see cref="CommandInput"/>.
/// </summary> /// </summary>
public CommandInput(string? commandName, IReadOnlyList<CommandOptionInput> options, IReadOnlyDictionary<string, string> environmentVariables) public CommandInput(IReadOnlyList<string> arguments, IReadOnlyList<CommandOptionInput> options, IReadOnlyDictionary<string, string> environmentVariables)
: this(commandName, EmptyDirectives, options, environmentVariables) : this(arguments, EmptyDirectives, options, environmentVariables)
{ {
} }
/// <summary> /// <summary>
/// Initializes an instance of <see cref="CommandInput"/>. /// Initializes an instance of <see cref="CommandInput"/>.
/// </summary> /// </summary>
public CommandInput(string? commandName, IReadOnlyList<CommandOptionInput> options) public CommandInput(IReadOnlyList<string> arguments, IReadOnlyList<CommandOptionInput> options)
: this(commandName, EmptyDirectives, options) : this(arguments, EmptyDirectives, options)
{ {
} }
@@ -70,15 +69,15 @@ namespace CliFx.Models
/// Initializes an instance of <see cref="CommandInput"/>. /// Initializes an instance of <see cref="CommandInput"/>.
/// </summary> /// </summary>
public CommandInput(IReadOnlyList<CommandOptionInput> options) public CommandInput(IReadOnlyList<CommandOptionInput> options)
: this(null, options) : this(new string[0], options)
{ {
} }
/// <summary> /// <summary>
/// Initializes an instance of <see cref="CommandInput"/>. /// Initializes an instance of <see cref="CommandInput"/>.
/// </summary> /// </summary>
public CommandInput(string? commandName) public CommandInput(IReadOnlyList<string> arguments)
: this(commandName, EmptyOptions) : this(arguments, EmptyOptions)
{ {
} }
@@ -87,8 +86,11 @@ namespace CliFx.Models
{ {
var buffer = new StringBuilder(); var buffer = new StringBuilder();
if (!string.IsNullOrWhiteSpace(CommandName)) foreach (var argument in Arguments)
buffer.Append(CommandName); {
buffer.AppendIfNotEmpty(' ');
buffer.Append(argument);
}
foreach (var directive in Directives) foreach (var directive in Directives)
{ {

View File

@@ -30,15 +30,21 @@ namespace CliFx.Models
/// </summary> /// </summary>
public IReadOnlyList<CommandOptionSchema> Options { get; } public IReadOnlyList<CommandOptionSchema> Options { get; }
/// <summary>
/// Command arguments.
/// </summary>
public IReadOnlyList<CommandArgumentSchema> Arguments { get; }
/// <summary> /// <summary>
/// Initializes an instance of <see cref="CommandSchema"/>. /// Initializes an instance of <see cref="CommandSchema"/>.
/// </summary> /// </summary>
public CommandSchema(Type? type, string? name, string? description, IReadOnlyList<CommandOptionSchema> options) public CommandSchema(Type type, string name, string description, IReadOnlyList<CommandArgumentSchema> arguments, IReadOnlyList<CommandOptionSchema> options)
{ {
Type = type; Type = type;
Name = name; Name = name;
Description = description; Description = description;
Options = options; Options = options;
Arguments = arguments;
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -64,6 +70,6 @@ namespace CliFx.Models
public partial class CommandSchema public partial class CommandSchema
{ {
internal static CommandSchema StubDefaultCommand { get; } = internal static CommandSchema StubDefaultCommand { get; } =
new CommandSchema(null, null, null, new CommandOptionSchema[0]); new CommandSchema(null, null, null, new CommandArgumentSchema[0], new CommandOptionSchema[0]);
} }
} }

View File

@@ -2,6 +2,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions;
namespace CliFx.Models namespace CliFx.Models
{ {
@@ -90,7 +91,7 @@ namespace CliFx.Models
/// <summary> /// <summary>
/// Gets whether a command was specified in the input. /// Gets whether a command was specified in the input.
/// </summary> /// </summary>
public static bool IsCommandSpecified(this CommandInput commandInput) => !string.IsNullOrWhiteSpace(commandInput.CommandName); public static bool HasArguments(this CommandInput commandInput) => commandInput.Arguments.Any();
/// <summary> /// <summary>
/// Gets whether debug directive was specified in the input. /// Gets whether debug directive was specified in the input.

View File

@@ -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
{
/// <inheritdoc />
public class CommandArgumentSchemasValidator : ICommandArgumentSchemasValidator
{
private bool IsEnumerableArgument(CommandArgumentSchema schema)
{
return schema.Property.PropertyType != typeof(string) && schema.Property.PropertyType.GetEnumerableUnderlyingType() != null;
}
/// <inheritdoc />
public IEnumerable<ValidationError> ValidateArgumentSchemas(IReadOnlyCollection<CommandArgumentSchema> 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.");
}
}
}
/// <summary>
/// Represents a failed validation.
/// </summary>
public class ValidationError
{
/// <summary>
/// Creates an instance of <see cref="ValidationError"/> with a message.
/// </summary>
public ValidationError(string message)
{
Message = message;
}
/// <summary>
/// The error message for the failed validation.
/// </summary>
public string Message { get; }
}
}

View File

@@ -1,4 +1,6 @@
using System.Linq; using System;
using System.Collections.Generic;
using System.Linq;
using CliFx.Exceptions; using CliFx.Exceptions;
using CliFx.Internal; using CliFx.Internal;
using CliFx.Models; using CliFx.Models;
@@ -10,15 +12,15 @@ namespace CliFx.Services
/// </summary> /// </summary>
public class CommandInitializer : ICommandInitializer public class CommandInitializer : ICommandInitializer
{ {
private readonly ICommandOptionInputConverter _commandOptionInputConverter; private readonly ICommandInputConverter _commandInputConverter;
private readonly IEnvironmentVariablesParser _environmentVariablesParser; private readonly IEnvironmentVariablesParser _environmentVariablesParser;
/// <summary> /// <summary>
/// Initializes an instance of <see cref="CommandInitializer"/>. /// Initializes an instance of <see cref="CommandInitializer"/>.
/// </summary> /// </summary>
public CommandInitializer(ICommandOptionInputConverter commandOptionInputConverter, IEnvironmentVariablesParser environmentVariablesParser) public CommandInitializer(ICommandInputConverter commandInputConverter, IEnvironmentVariablesParser environmentVariablesParser)
{ {
_commandOptionInputConverter = commandOptionInputConverter; _commandInputConverter = commandInputConverter;
_environmentVariablesParser = environmentVariablesParser; _environmentVariablesParser = environmentVariablesParser;
} }
@@ -26,7 +28,7 @@ namespace CliFx.Services
/// Initializes an instance of <see cref="CommandInitializer"/>. /// Initializes an instance of <see cref="CommandInitializer"/>.
/// </summary> /// </summary>
public CommandInitializer(IEnvironmentVariablesParser environmentVariablesParser) public CommandInitializer(IEnvironmentVariablesParser environmentVariablesParser)
: this(new CommandOptionInputConverter(), environmentVariablesParser) : this(new CommandInputConverter(), environmentVariablesParser)
{ {
} }
@@ -34,43 +36,47 @@ namespace CliFx.Services
/// Initializes an instance of <see cref="CommandInitializer"/>. /// Initializes an instance of <see cref="CommandInitializer"/>.
/// </summary> /// </summary>
public CommandInitializer() public CommandInitializer()
: this(new CommandOptionInputConverter(), new EnvironmentVariablesParser()) : this(new CommandInputConverter(), new EnvironmentVariablesParser())
{ {
} }
/// <inheritdoc /> private void InitializeCommandOptions(ICommand command, CommandCandidate commandCandidate)
public void InitializeCommand(ICommand command, CommandSchema commandSchema, CommandInput commandInput)
{ {
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 // 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 //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 // Ignore special options that are not backed by a property
if (optionSchema.Property == null) if (optionSchema.Property == null)
continue; continue;
//Find matching option input //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 no option input is available fall back to environment variable values
if (optionInput == null && !string.IsNullOrWhiteSpace(optionSchema.EnvironmentVariableName)) 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 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; 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 //No fallback available and no option input was specified, skip option
if (optionInput == null) if (optionInput == null)
continue; continue;
var convertedValue = _commandOptionInputConverter.ConvertOptionInput(optionInput, optionSchema.Property.PropertyType); var convertedValue = _commandInputConverter.ConvertOptionInput(optionInput, optionSchema.Property.PropertyType);
// Set value of the underlying property // Set value of the underlying property
optionSchema.Property.SetValue(command, convertedValue); optionSchema.Property.SetValue(command, convertedValue);
@@ -87,5 +93,57 @@ namespace CliFx.Services
throw new CliFxException($"Some of the required options were not provided: {unsetRequiredOptionNames}."); 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(", ")}.");
}
}
/// <inheritdoc />
public void InitializeCommand(ICommand command, CommandCandidate commandCandidate)
{
InitializeCommandOptions(command, commandCandidate);
InitializeCommandArguments(command, commandCandidate);
}
} }
} }

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
@@ -9,28 +10,53 @@ using CliFx.Models;
namespace CliFx.Services namespace CliFx.Services
{ {
/// <summary> /// <summary>
/// Default implementation of <see cref="ICommandOptionInputConverter"/>. /// Default implementation of <see cref="ICommandInputConverter"/>.
/// </summary> /// </summary>
public partial class CommandOptionInputConverter : ICommandOptionInputConverter public partial class CommandInputConverter : ICommandInputConverter
{ {
private readonly IFormatProvider _formatProvider; private readonly IFormatProvider _formatProvider;
/// <summary> /// <summary>
/// Initializes an instance of <see cref="CommandOptionInputConverter"/>. /// Initializes an instance of <see cref="CommandInputConverter"/>.
/// </summary> /// </summary>
public CommandOptionInputConverter(IFormatProvider formatProvider) public CommandInputConverter(IFormatProvider formatProvider)
{ {
_formatProvider = formatProvider; _formatProvider = formatProvider;
} }
/// <summary> /// <summary>
/// Initializes an instance of <see cref="CommandOptionInputConverter"/>. /// Initializes an instance of <see cref="CommandInputConverter"/>.
/// </summary> /// </summary>
public CommandOptionInputConverter() public CommandInputConverter()
: this(CultureInfo.InvariantCulture) : this(CultureInfo.InvariantCulture)
{ {
} }
private object? ConvertEnumerableValue(IReadOnlyList<string> 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<T>, IEnumerable<T>, etc)
if (targetType.IsAssignableFrom(convertedValuesType))
return convertedValues;
// Try to inject the array into the constructor (works for HashSet<T>, List<T>, 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}].");
}
/// <summary> /// <summary>
/// Converts a single string value to specified target type. /// Converts a single string value to specified target type.
/// </summary> /// </summary>
@@ -118,17 +144,17 @@ namespace CliFx.Services
// Has a constructor that accepts a single string // Has a constructor that accepts a single string
var stringConstructor = GetStringConstructor(targetType); var stringConstructor = GetStringConstructor(targetType);
if (stringConstructor != null) 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 // Has a static parse method that accepts a single string and a format provider
var parseMethodWithFormatProvider = GetStaticParseMethodWithFormatProvider(targetType); var parseMethodWithFormatProvider = GetStaticParseMethodWithFormatProvider(targetType);
if (parseMethodWithFormatProvider != null) 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 // Has a static parse method that accepts a single string
var parseMethod = GetStaticParseMethod(targetType); var parseMethod = GetStaticParseMethod(targetType);
if (parseMethod != null) if (parseMethod != null)
return parseMethod.Invoke(null, new object[] {value}); return parseMethod.Invoke(null, new object[] { value });
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -145,6 +171,24 @@ namespace CliFx.Services
"This type is not among the list of types supported by this library."); "This type is not among the list of types supported by this library.");
} }
/// <inheritdoc />
public virtual object? ConvertArgumentInput(IReadOnlyList<string> 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);
}
/// <inheritdoc /> /// <inheritdoc />
public virtual object? ConvertOptionInput(CommandOptionInput optionInput, Type targetType) public virtual object? ConvertOptionInput(CommandOptionInput optionInput, Type targetType)
{ {
@@ -170,32 +214,12 @@ namespace CliFx.Services
// Convert to an enumerable type // Convert to an enumerable type
else else
{ {
// Convert values to the underlying enumerable type and cast it to dynamic array return ConvertEnumerableValue(optionInput.Values, enumerableUnderlyingType, targetType);
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<T>, IEnumerable<T>, etc)
if (targetType.IsAssignableFrom(convertedValuesType))
return convertedValues;
// Try to inject the array into the constructor (works for HashSet<T>, List<T>, 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}].");
} }
} }
} }
public partial class CommandOptionInputConverter public partial class CommandInputConverter
{ {
private static ConstructorInfo? GetStringConstructor(Type type) => type.GetConstructor(new[] {typeof(string)}); private static ConstructorInfo? GetStringConstructor(Type type) => type.GetConstructor(new[] {typeof(string)});

View File

@@ -1,7 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text;
using CliFx.Internal; using CliFx.Internal;
using CliFx.Models; using CliFx.Models;
@@ -33,7 +32,7 @@ namespace CliFx.Services
/// <inheritdoc /> /// <inheritdoc />
public CommandInput ParseCommandInput(IReadOnlyList<string> commandLineArguments) public CommandInput ParseCommandInput(IReadOnlyList<string> commandLineArguments)
{ {
var commandNameBuilder = new StringBuilder(); var arguments = new List<string>();
var directives = new List<string>(); var directives = new List<string>();
var optionsDic = new Dictionary<string, List<string>>(); var optionsDic = new Dictionary<string, List<string>>();
@@ -79,8 +78,7 @@ namespace CliFx.Services
} }
else else
{ {
commandNameBuilder.AppendIfNotEmpty(' '); arguments.Add(commandLineArgument);
commandNameBuilder.Append(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 options = optionsDic.Select(p => new CommandOptionInput(p.Key, p.Value)).ToArray();
var environmentVariables = _environmentVariablesProvider.GetEnvironmentVariables(); var environmentVariables = _environmentVariablesProvider.GetEnvironmentVariables();
return new CommandInput(commandName, directives, options, environmentVariables); return new CommandInput(arguments, directives, options, environmentVariables);
} }
} }
} }

View File

@@ -2,6 +2,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using System.Text.RegularExpressions;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Exceptions; using CliFx.Exceptions;
using CliFx.Internal; using CliFx.Internal;
@@ -14,6 +15,16 @@ namespace CliFx.Services
/// </summary> /// </summary>
public class CommandSchemaResolver : ICommandSchemaResolver public class CommandSchemaResolver : ICommandSchemaResolver
{ {
private readonly ICommandArgumentSchemasValidator _commandArgumentSchemasValidator;
/// <summary>
/// Creates an instance of <see cref="CommandSchemaResolver"/>.
/// </summary>
public CommandSchemaResolver(ICommandArgumentSchemasValidator commandArgumentSchemasValidator)
{
_commandArgumentSchemasValidator = commandArgumentSchemasValidator;
}
private IReadOnlyList<CommandOptionSchema> GetCommandOptionSchemas(Type commandType) private IReadOnlyList<CommandOptionSchema> GetCommandOptionSchemas(Type commandType)
{ {
var result = new List<CommandOptionSchema>(); var result = new List<CommandOptionSchema>();
@@ -67,6 +78,24 @@ namespace CliFx.Services
return result; return result;
} }
private IReadOnlyList<CommandArgumentSchema> GetCommandArgumentSchemas(Type commandType)
{
var argumentSchemas = commandType.GetProperties()
.Select(p => new { Property = p, Attribute = p.GetCustomAttribute<CommandArgumentAttribute>() })
.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;
}
/// <inheritdoc /> /// <inheritdoc />
public IReadOnlyList<CommandSchema> GetCommandSchemas(IReadOnlyList<Type> commandTypes) public IReadOnlyList<CommandSchema> GetCommandSchemas(IReadOnlyList<Type> commandTypes)
{ {
@@ -108,11 +137,14 @@ namespace CliFx.Services
// Get option schemas // Get option schemas
var optionSchemas = GetCommandOptionSchemas(commandType); var optionSchemas = GetCommandOptionSchemas(commandType);
// Get argument schemas
var argumentSchemas = GetCommandArgumentSchemas(commandType);
// Build command schema // Build command schema
var commandSchema = new CommandSchema(commandType, var commandSchema = new CommandSchema(commandType,
attribute.Name, attribute.Name,
attribute.Description, attribute.Description,
optionSchemas); argumentSchemas, optionSchemas);
// Make sure there are no other commands with the same name // Make sure there are no other commands with the same name
var existingCommandWithSameName = result var existingCommandWithSameName = result
@@ -131,5 +163,31 @@ namespace CliFx.Services
return result; return result;
} }
/// <inheritdoc />
public CommandCandidate? GetTargetCommandSchema(IReadOnlyList<CommandSchema> 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);
}
} }
} }

View File

@@ -19,7 +19,7 @@ namespace CliFx.Services
var row = 0; var row = 0;
// Get built-in option schemas (help and version) // Get built-in option schemas (help and version)
var builtInOptionSchemas = new List<CommandOptionSchema> {CommandOptionSchema.HelpOption}; var builtInOptionSchemas = new List<CommandOptionSchema> { CommandOptionSchema.HelpOption };
if (source.TargetCommandSchema.IsDefault()) if (source.TargetCommandSchema.IsDefault())
builtInOptionSchemas.Add(CommandOptionSchema.VersionOption); builtInOptionSchemas.Add(CommandOptionSchema.VersionOption);
@@ -104,7 +104,7 @@ namespace CliFx.Services
// Description // Description
if (!string.IsNullOrWhiteSpace(source.ApplicationMetadata.Description)) if (!string.IsNullOrWhiteSpace(source.ApplicationMetadata.Description))
{ {
Render(source.ApplicationMetadata.Description); Render(source.ApplicationMetadata.Description!);
RenderNewLine(); RenderNewLine();
} }
} }
@@ -122,7 +122,7 @@ namespace CliFx.Services
// Description // Description
RenderIndent(); RenderIndent();
Render(source.TargetCommandSchema.Description); Render(source.TargetCommandSchema.Description!);
RenderNewLine(); RenderNewLine();
} }
@@ -142,7 +142,7 @@ namespace CliFx.Services
if (!string.IsNullOrWhiteSpace(source.TargetCommandSchema.Name)) if (!string.IsNullOrWhiteSpace(source.TargetCommandSchema.Name))
{ {
Render(" "); Render(" ");
RenderWithColor(source.TargetCommandSchema.Name, ConsoleColor.Cyan); RenderWithColor(source.TargetCommandSchema.Name!, ConsoleColor.Cyan);
} }
// Child command // Child command
@@ -152,12 +152,69 @@ namespace CliFx.Services
RenderWithColor("[command]", ConsoleColor.Cyan); 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 // Options
Render(" "); Render(" ");
RenderWithColor("[options]", ConsoleColor.White); RenderWithColor("[options]", ConsoleColor.White);
RenderNewLine(); 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() void RenderOptions()
{ {
// Margin // Margin
@@ -207,7 +264,7 @@ namespace CliFx.Services
if (!string.IsNullOrWhiteSpace(optionSchema.Description)) if (!string.IsNullOrWhiteSpace(optionSchema.Description))
{ {
RenderColumnIndent(); RenderColumnIndent();
Render(optionSchema.Description); Render(optionSchema.Description!);
} }
RenderNewLine(); RenderNewLine();
@@ -238,7 +295,7 @@ namespace CliFx.Services
if (!string.IsNullOrWhiteSpace(childCommandSchema.Description)) if (!string.IsNullOrWhiteSpace(childCommandSchema.Description))
{ {
RenderColumnIndent(); RenderColumnIndent();
Render(childCommandSchema.Description); Render(childCommandSchema.Description!);
} }
RenderNewLine(); RenderNewLine();
@@ -275,6 +332,7 @@ namespace CliFx.Services
RenderApplicationInfo(); RenderApplicationInfo();
RenderDescription(); RenderDescription();
RenderUsage(); RenderUsage();
RenderArguments();
RenderOptions(); RenderOptions();
RenderChildCommands(); RenderChildCommands();
} }
@@ -285,6 +343,6 @@ namespace CliFx.Services
private static string? GetRelativeCommandName(CommandSchema commandSchema, CommandSchema parentCommandSchema) => private static string? GetRelativeCommandName(CommandSchema commandSchema, CommandSchema parentCommandSchema) =>
string.IsNullOrWhiteSpace(parentCommandSchema.Name) || string.IsNullOrWhiteSpace(commandSchema.Name) string.IsNullOrWhiteSpace(parentCommandSchema.Name) || string.IsNullOrWhiteSpace(commandSchema.Name)
? commandSchema.Name ? commandSchema.Name
: commandSchema.Name.Substring(parentCommandSchema.Name.Length + 1); : commandSchema.Name!.Substring(parentCommandSchema.Name!.Length + 1);
} }
} }

View File

@@ -0,0 +1,16 @@
using System.Collections.Generic;
using CliFx.Models;
namespace CliFx.Services
{
/// <summary>
/// Validates command arguments.
/// </summary>
public interface ICommandArgumentSchemasValidator
{
/// <summary>
/// Validate the given command arguments.
/// </summary>
IEnumerable<ValidationError> ValidateArgumentSchemas(IReadOnlyCollection<CommandArgumentSchema> commandArgumentSchemas);
}
}

View File

@@ -10,6 +10,6 @@ namespace CliFx.Services
/// <summary> /// <summary>
/// Populates an instance of <see cref="ICommand"/> with specified input according to specified schema. /// Populates an instance of <see cref="ICommand"/> with specified input according to specified schema.
/// </summary> /// </summary>
void InitializeCommand(ICommand command, CommandSchema commandSchema, CommandInput commandInput); void InitializeCommand(ICommand command, CommandCandidate commandCandidate);
} }
} }

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using CliFx.Models; using CliFx.Models;
namespace CliFx.Services namespace CliFx.Services
@@ -6,11 +7,16 @@ namespace CliFx.Services
/// <summary> /// <summary>
/// Converts input command options. /// Converts input command options.
/// </summary> /// </summary>
public interface ICommandOptionInputConverter public interface ICommandInputConverter
{ {
/// <summary> /// <summary>
/// Converts an option to specified target type. /// Converts an option to specified target type.
/// </summary> /// </summary>
object? ConvertOptionInput(CommandOptionInput optionInput, Type targetType); object? ConvertOptionInput(CommandOptionInput optionInput, Type targetType);
/// <summary>
/// Converts an argument to specified target type, using up arguments from the given enumerator.
/// </summary>
object? ConvertArgumentInput(IReadOnlyList<string> arguments, ref int currentIndex, Type targetType);
} }
} }

View File

@@ -13,5 +13,10 @@ namespace CliFx.Services
/// Resolves schemas of specified command types. /// Resolves schemas of specified command types.
/// </summary> /// </summary>
IReadOnlyList<CommandSchema> GetCommandSchemas(IReadOnlyList<Type> commandTypes); IReadOnlyList<CommandSchema> GetCommandSchemas(IReadOnlyList<Type> commandTypes);
/// <summary>
/// Get the target command schema. The target command is the most specific command that matches the unbound input arguments.
/// </summary>
CommandCandidate? GetTargetCommandSchema(IReadOnlyList<CommandSchema> availableCommandSchemas, CommandInput commandInput);
} }
} }