Rework architecture and implement auto help

This commit is contained in:
Alexey Golub
2019-07-20 22:50:57 +03:00
parent 77c7faa759
commit 2bdb2bddc8
51 changed files with 1348 additions and 667 deletions

View File

@@ -1,9 +1,9 @@
using System; using System.Collections.Generic;
using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Models; using CliFx.Models;
using CliFx.Services;
namespace CliFx.Tests.Dummy.Commands namespace CliFx.Tests.Dummy.Commands
{ {
@@ -13,10 +13,10 @@ namespace CliFx.Tests.Dummy.Commands
[CommandOption("values", 'v', IsRequired = true, Description = "Input values.")] [CommandOption("values", 'v', IsRequired = true, Description = "Input values.")]
public IReadOnlyList<double> Values { get; set; } public IReadOnlyList<double> Values { get; set; }
public override ExitCode Execute() protected override ExitCode Process()
{ {
var result = Values.Sum(); var result = Values.Sum();
Console.WriteLine(result.ToString(CultureInfo.InvariantCulture)); Output.WriteLine(result.ToString(CultureInfo.InvariantCulture));
return ExitCode.Success; return ExitCode.Success;
} }

View File

@@ -1,11 +1,11 @@
using System; using System.Text;
using System.Text;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Models; using CliFx.Models;
using CliFx.Services;
namespace CliFx.Tests.Dummy.Commands namespace CliFx.Tests.Dummy.Commands
{ {
[DefaultCommand] [Command]
public class DefaultCommand : Command public class DefaultCommand : Command
{ {
[CommandOption("target", 't', Description = "Greeting target.")] [CommandOption("target", 't', Description = "Greeting target.")]
@@ -14,7 +14,7 @@ namespace CliFx.Tests.Dummy.Commands
[CommandOption('e', Description = "Whether the greeting should be enthusiastic.")] [CommandOption('e', Description = "Whether the greeting should be enthusiastic.")]
public bool IsEnthusiastic { get; set; } public bool IsEnthusiastic { get; set; }
public override ExitCode Execute() protected override ExitCode Process()
{ {
var buffer = new StringBuilder(); var buffer = new StringBuilder();
@@ -23,7 +23,7 @@ namespace CliFx.Tests.Dummy.Commands
if (IsEnthusiastic) if (IsEnthusiastic)
buffer.Append("!!!"); buffer.Append("!!!");
Console.WriteLine(buffer.ToString()); Output.WriteLine(buffer.ToString());
return ExitCode.Success; return ExitCode.Success;
} }

View File

@@ -15,10 +15,10 @@ namespace CliFx.Tests.Dummy.Commands
[CommandOption("base", 'b', Description = "Logarithm base.")] [CommandOption("base", 'b', Description = "Logarithm base.")]
public double Base { get; set; } = 10; public double Base { get; set; } = 10;
public override ExitCode Execute() protected override ExitCode Process()
{ {
var result = Math.Log(Value, Base); var result = Math.Log(Value, Base);
Console.WriteLine(result.ToString(CultureInfo.InvariantCulture)); Output.WriteLine(result.ToString(CultureInfo.InvariantCulture));
return ExitCode.Success; return ExitCode.Success;
} }

View File

@@ -8,10 +8,14 @@ namespace CliFx.Tests
{ {
public partial class CliApplicationTests public partial class CliApplicationTests
{ {
[DefaultCommand] [Command]
public class TestCommand : Command public class TestCommand : ICommand
{ {
public override ExitCode Execute() => new ExitCode(13); public static ExitCode ExitCode { get; } = new ExitCode(13);
public CommandContext Context { get; set; }
public Task<ExitCode> ExecuteAsync() => Task.FromResult(ExitCode);
} }
} }
@@ -23,14 +27,14 @@ namespace CliFx.Tests
{ {
// Arrange // Arrange
var application = new CliApplication( var application = new CliApplication(
new CommandOptionParser(), new CommandInputParser(),
new CommandResolver(new[] {typeof(TestCommand)}, new CommandOptionConverter())); new CommandInitializer(new CommandSchemaResolver(new[] {typeof(TestCommand)})));
// Act // Act
var exitCodeValue = await application.RunAsync(); var exitCodeValue = await application.RunAsync();
// Assert // Assert
Assert.That(exitCodeValue, Is.EqualTo(13), "Exit code"); Assert.That(exitCodeValue, Is.EqualTo(TestCommand.ExitCode.Value), "Exit code");
} }
} }
} }

View File

@@ -0,0 +1,140 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Models;
using CliFx.Services;
using NUnit.Framework;
namespace CliFx.Tests
{
public partial class CommandInitializerTests
{
[Command]
public class TestCommand : ICommand
{
[CommandOption("int", 'i', IsRequired = true)]
public int IntOption { get; set; } = 24;
[CommandOption("str", 's')]
public string StringOption { get; set; } = "foo bar";
[CommandOption("bool", 'b', GroupName = "other-group")]
public bool BoolOption { get; set; }
public CommandContext Context { get; set; }
public Task<ExitCode> ExecuteAsync() => throw new System.NotImplementedException();
}
}
[TestFixture]
public partial class CommandInitializerTests
{
private static IEnumerable<TestCaseData> GetTestCases_InitializeCommand()
{
yield return new TestCaseData(
new CommandInput(new[]
{
new CommandOptionInput("int", "13")
}),
new TestCommand {IntOption = 13}
);
yield return new TestCaseData(
new CommandInput(new[]
{
new CommandOptionInput("int", "13"),
new CommandOptionInput("str", "hello world")
}),
new TestCommand {IntOption = 13, StringOption = "hello world"}
);
yield return new TestCaseData(
new CommandInput(new[]
{
new CommandOptionInput("i", "13")
}),
new TestCommand {IntOption = 13}
);
yield return new TestCaseData(
new CommandInput(new[]
{
new CommandOptionInput("bool")
}),
new TestCommand {BoolOption = true}
);
yield return new TestCaseData(
new CommandInput(new[]
{
new CommandOptionInput("b")
}),
new TestCommand {BoolOption = true}
);
yield return new TestCaseData(
new CommandInput(new[]
{
new CommandOptionInput("bool"),
new CommandOptionInput("str", "hello world")
}),
new TestCommand {BoolOption = true}
);
yield return new TestCaseData(
new CommandInput(new[]
{
new CommandOptionInput("int", "13"),
new CommandOptionInput("str", "hello world"),
new CommandOptionInput("bool")
}),
new TestCommand {IntOption = 13, StringOption = "hello world"}
);
}
[Test]
[TestCaseSource(nameof(GetTestCases_InitializeCommand))]
public void InitializeCommand_Test(CommandInput commandInput, TestCommand expectedCommand)
{
// Arrange
var initializer = new CommandInitializer(new CommandSchemaResolver(new[] {typeof(TestCommand)}));
// Act
var command = initializer.InitializeCommand(commandInput) as TestCommand;
// Assert
Assert.Multiple(() =>
{
Assert.That(command, Is.Not.Null);
Assert.That(command.StringOption, Is.EqualTo(expectedCommand.StringOption), nameof(command.StringOption));
Assert.That(command.IntOption, Is.EqualTo(expectedCommand.IntOption), nameof(command.IntOption));
Assert.That(command.BoolOption, Is.EqualTo(expectedCommand.BoolOption), nameof(command.BoolOption));
});
}
private static IEnumerable<TestCaseData> GetTestCases_InitializeCommand_IsRequired()
{
yield return new TestCaseData(CommandInput.Empty);
yield return new TestCaseData(
new CommandInput(new[]
{
new CommandOptionInput("str", "hello world")
})
);
}
[Test]
[TestCaseSource(nameof(GetTestCases_InitializeCommand_IsRequired))]
public void InitializeCommand_IsRequired_Test(CommandInput commandInput)
{
// Arrange
var initializer = new CommandInitializer(new CommandSchemaResolver(new[] {typeof(TestCommand)}));
// Act & Assert
Assert.Throws<CommandResolveException>(() => initializer.InitializeCommand(commandInput));
}
}
}

View File

@@ -0,0 +1,170 @@
using System.Collections.Generic;
using CliFx.Models;
using CliFx.Services;
using NUnit.Framework;
namespace CliFx.Tests
{
[TestFixture]
public class CommandInputParserTests
{
private static IEnumerable<TestCaseData> GetTestCases_ParseInput()
{
yield return new TestCaseData(new string[0], CommandInput.Empty);
yield return new TestCaseData(
new[] {"--option", "value"},
new CommandInput(new[]
{
new CommandOptionInput("option", "value")
})
);
yield return new TestCaseData(
new[] {"--option1", "value1", "--option2", "value2"},
new CommandInput(new[]
{
new CommandOptionInput("option1", "value1"),
new CommandOptionInput("option2", "value2")
})
);
yield return new TestCaseData(
new[] {"--option", "value1", "value2"},
new CommandInput(new[]
{
new CommandOptionInput("option", new[] {"value1", "value2"})
})
);
yield return new TestCaseData(
new[] {"--option", "value1", "--option", "value2"},
new CommandInput(new[]
{
new CommandOptionInput("option", new[] {"value1", "value2"})
})
);
yield return new TestCaseData(
new[] {"-a", "value"},
new CommandInput(new[]
{
new CommandOptionInput("a", "value")
})
);
yield return new TestCaseData(
new[] {"-a", "value1", "-b", "value2"},
new CommandInput(new[]
{
new CommandOptionInput("a", "value1"),
new CommandOptionInput("b", "value2")
})
);
yield return new TestCaseData(
new[] {"-a", "value1", "value2"},
new CommandInput(new[]
{
new CommandOptionInput("a", new[] {"value1", "value2"})
})
);
yield return new TestCaseData(
new[] {"-a", "value1", "-a", "value2"},
new CommandInput(new[]
{
new CommandOptionInput("a", new[] {"value1", "value2"})
})
);
yield return new TestCaseData(
new[] {"--option1", "value1", "-b", "value2"},
new CommandInput(new[]
{
new CommandOptionInput("option1", "value1"),
new CommandOptionInput("b", "value2")
})
);
yield return new TestCaseData(
new[] {"--switch"},
new CommandInput(new[]
{
new CommandOptionInput("switch")
})
);
yield return new TestCaseData(
new[] {"--switch1", "--switch2"},
new CommandInput(new[]
{
new CommandOptionInput("switch1"),
new CommandOptionInput("switch2")
})
);
yield return new TestCaseData(
new[] {"-s"},
new CommandInput(new[]
{
new CommandOptionInput("s")
})
);
yield return new TestCaseData(
new[] {"-a", "-b"},
new CommandInput(new[]
{
new CommandOptionInput("a"),
new CommandOptionInput("b")
})
);
yield return new TestCaseData(
new[] {"-ab"},
new CommandInput(new[]
{
new CommandOptionInput("a"),
new CommandOptionInput("b")
})
);
yield return new TestCaseData(
new[] {"-ab", "value"},
new CommandInput(new[]
{
new CommandOptionInput("a"),
new CommandOptionInput("b", "value")
})
);
yield return new TestCaseData(
new[] {"command"},
new CommandInput("command")
);
yield return new TestCaseData(
new[] {"command", "--option", "value"},
new CommandInput("command", new[]
{
new CommandOptionInput("option", "value")
})
);
}
[Test]
[TestCaseSource(nameof(GetTestCases_ParseInput))]
public void ParseInput_Test(IReadOnlyList<string> commandLineArguments, CommandInput expectedCommandInput)
{
// Arrange
var parser = new CommandInputParser();
// Act
var commandInput = parser.ParseInput(commandLineArguments);
// Assert
Assert.That(commandInput, Is.EqualTo(expectedCommandInput).Using(CommandInputEqualityComparer.Instance));
}
}
}

View File

@@ -7,7 +7,7 @@ using NUnit.Framework;
namespace CliFx.Tests namespace CliFx.Tests
{ {
public partial class CommandOptionConverterTests public partial class CommandOptionInputConverterTests
{ {
public enum TestEnum public enum TestEnum
{ {
@@ -40,162 +40,162 @@ namespace CliFx.Tests
} }
[TestFixture] [TestFixture]
public partial class CommandOptionConverterTests public partial class CommandOptionInputConverterTests
{ {
private static IEnumerable<TestCaseData> GetTestCases_ConvertOption() private static IEnumerable<TestCaseData> GetTestCases_ConvertOption()
{ {
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option", "value"), new CommandOptionInput("option", "value"),
typeof(string), typeof(string),
"value" "value"
); );
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option", "value"), new CommandOptionInput("option", "value"),
typeof(object), typeof(object),
"value" "value"
); );
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option", "true"), new CommandOptionInput("option", "true"),
typeof(bool), typeof(bool),
true true
); );
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option", "false"), new CommandOptionInput("option", "false"),
typeof(bool), typeof(bool),
false false
); );
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option"), new CommandOptionInput("option"),
typeof(bool), typeof(bool),
true true
); );
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option", "123"), new CommandOptionInput("option", "123"),
typeof(int), typeof(int),
123 123
); );
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option", "123.45"), new CommandOptionInput("option", "123.45"),
typeof(double), typeof(double),
123.45 123.45
); );
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option", "28 Apr 1995"), new CommandOptionInput("option", "28 Apr 1995"),
typeof(DateTime), typeof(DateTime),
new DateTime(1995, 04, 28) new DateTime(1995, 04, 28)
); );
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option", "28 Apr 1995"), new CommandOptionInput("option", "28 Apr 1995"),
typeof(DateTimeOffset), typeof(DateTimeOffset),
new DateTimeOffset(new DateTime(1995, 04, 28)) new DateTimeOffset(new DateTime(1995, 04, 28))
); );
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option", "00:14:59"), new CommandOptionInput("option", "00:14:59"),
typeof(TimeSpan), typeof(TimeSpan),
new TimeSpan(00, 14, 59) new TimeSpan(00, 14, 59)
); );
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option", "value2"), new CommandOptionInput("option", "value2"),
typeof(TestEnum), typeof(TestEnum),
TestEnum.Value2 TestEnum.Value2
); );
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option", "666"), new CommandOptionInput("option", "666"),
typeof(int?), typeof(int?),
666 666
); );
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option"), new CommandOptionInput("option"),
typeof(int?), typeof(int?),
null null
); );
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option", "value3"), new CommandOptionInput("option", "value3"),
typeof(TestEnum?), typeof(TestEnum?),
TestEnum.Value3 TestEnum.Value3
); );
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option"), new CommandOptionInput("option"),
typeof(TestEnum?), typeof(TestEnum?),
null null
); );
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option", "01:00:00"), new CommandOptionInput("option", "01:00:00"),
typeof(TimeSpan?), typeof(TimeSpan?),
new TimeSpan(01, 00, 00) new TimeSpan(01, 00, 00)
); );
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option"), new CommandOptionInput("option"),
typeof(TimeSpan?), typeof(TimeSpan?),
null null
); );
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option", "value"), new CommandOptionInput("option", "value"),
typeof(TestStringConstructable), typeof(TestStringConstructable),
new TestStringConstructable("value") new TestStringConstructable("value")
); );
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option", "value"), new CommandOptionInput("option", "value"),
typeof(TestStringParseable), typeof(TestStringParseable),
TestStringParseable.Parse("value") TestStringParseable.Parse("value")
); );
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option", new[] {"value1", "value2"}), new CommandOptionInput("option", new[] {"value1", "value2"}),
typeof(string[]), typeof(string[]),
new[] {"value1", "value2"} new[] {"value1", "value2"}
); );
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option", new[] {"value1", "value2"}), new CommandOptionInput("option", new[] {"value1", "value2"}),
typeof(object[]), typeof(object[]),
new[] {"value1", "value2"} new[] {"value1", "value2"}
); );
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option", new[] {"47", "69"}), new CommandOptionInput("option", new[] {"47", "69"}),
typeof(int[]), typeof(int[]),
new[] {47, 69} new[] {47, 69}
); );
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option", new[] {"value1", "value3"}), new CommandOptionInput("option", new[] {"value1", "value3"}),
typeof(TestEnum[]), typeof(TestEnum[]),
new[] {TestEnum.Value1, TestEnum.Value3} new[] {TestEnum.Value1, TestEnum.Value3}
); );
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option", new[] {"value1", "value2"}), new CommandOptionInput("option", new[] {"value1", "value2"}),
typeof(IEnumerable), typeof(IEnumerable),
new[] {"value1", "value2"} new[] {"value1", "value2"}
); );
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option", new[] {"value1", "value2"}), new CommandOptionInput("option", new[] {"value1", "value2"}),
typeof(IEnumerable<string>), typeof(IEnumerable<string>),
new[] {"value1", "value2"} new[] {"value1", "value2"}
); );
yield return new TestCaseData( yield return new TestCaseData(
new CommandOption("option", new[] {"value1", "value2"}), new CommandOptionInput("option", new[] {"value1", "value2"}),
typeof(IReadOnlyList<string>), typeof(IReadOnlyList<string>),
new[] {"value1", "value2"} new[] {"value1", "value2"}
); );
@@ -203,13 +203,13 @@ namespace CliFx.Tests
[Test] [Test]
[TestCaseSource(nameof(GetTestCases_ConvertOption))] [TestCaseSource(nameof(GetTestCases_ConvertOption))]
public void ConvertOption_Test(CommandOption option, Type targetType, object expectedConvertedValue) public void ConvertOption_Test(CommandOptionInput optionInput, Type targetType, object expectedConvertedValue)
{ {
// Arrange // Arrange
var converter = new CommandOptionConverter(); var converter = new CommandOptionInputConverter();
// Act // Act
var convertedValue = converter.ConvertOption(option, targetType); var convertedValue = converter.ConvertOption(optionInput, targetType);
// Assert // Assert
Assert.Multiple(() => Assert.Multiple(() =>

View File

@@ -1,183 +0,0 @@
using System.Collections.Generic;
using CliFx.Models;
using CliFx.Services;
using NUnit.Framework;
namespace CliFx.Tests
{
[TestFixture]
public class CommandOptionParserTests
{
private static IEnumerable<TestCaseData> GetTestCases_ParseOptions()
{
yield return new TestCaseData(new string[0], CommandOptionSet.Empty);
yield return new TestCaseData(
new[] {"--option", "value"},
new CommandOptionSet(new[]
{
new CommandOption("option", "value")
})
);
yield return new TestCaseData(
new[] {"--option1", "value1", "--option2", "value2"},
new CommandOptionSet(new[]
{
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[]
{
new CommandOption("a", "value")
})
);
yield return new TestCaseData(
new[] {"-a", "value1", "-b", "value2"},
new CommandOptionSet(new[]
{
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[]
{
new CommandOption("option1", "value1"),
new CommandOption("b", "value2")
})
);
yield return new TestCaseData(
new[] {"--switch"},
new CommandOptionSet(new[]
{
new CommandOption("switch")
})
);
yield return new TestCaseData(
new[] {"--switch1", "--switch2"},
new CommandOptionSet(new[]
{
new CommandOption("switch1"),
new CommandOption("switch2")
})
);
yield return new TestCaseData(
new[] {"-s"},
new CommandOptionSet(new[]
{
new CommandOption("s")
})
);
yield return new TestCaseData(
new[] {"-a", "-b"},
new CommandOptionSet(new[]
{
new CommandOption("a"),
new CommandOption("b")
})
);
yield return new TestCaseData(
new[] {"-ab"},
new CommandOptionSet(new[]
{
new CommandOption("a"),
new CommandOption("b")
})
);
yield return new TestCaseData(
new[] {"-ab", "value"},
new CommandOptionSet(new[]
{
new CommandOption("a"),
new CommandOption("b", "value")
})
);
yield return new TestCaseData(
new[] {"command"},
new CommandOptionSet("command")
);
yield return new TestCaseData(
new[] {"command", "--option", "value"},
new CommandOptionSet("command", new[]
{
new CommandOption("option", "value")
})
);
}
[Test]
[TestCaseSource(nameof(GetTestCases_ParseOptions))]
public void ParseOptions_Test(IReadOnlyList<string> commandLineArguments, CommandOptionSet expectedCommandOptionSet)
{
// Arrange
var parser = new CommandOptionParser();
// Act
var optionSet = parser.ParseOptions(commandLineArguments);
// Assert
Assert.Multiple(() =>
{
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");
}
});
}
}
}

View File

@@ -1,97 +0,0 @@
using System.Collections.Generic;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Models;
using CliFx.Services;
using NUnit.Framework;
namespace CliFx.Tests
{
public partial class CommandResolverTests
{
[DefaultCommand]
public class TestCommand : Command
{
[CommandOption("int", 'i', IsRequired = true)]
public int IntOption { get; set; } = 24;
[CommandOption("str", 's')] public string StringOption { get; set; } = "foo bar";
public override ExitCode Execute() => new ExitCode(IntOption, StringOption);
}
}
[TestFixture]
public partial class CommandResolverTests
{
private static IEnumerable<TestCaseData> GetTestCases_ResolveCommand()
{
yield return new TestCaseData(
new CommandOptionSet(new[]
{
new CommandOption("int", "13")
}),
new TestCommand {IntOption = 13}
);
yield return new TestCaseData(
new CommandOptionSet(new[]
{
new CommandOption("int", "13"),
new CommandOption("str", "hello world")
}),
new TestCommand {IntOption = 13, StringOption = "hello world"}
);
yield return new TestCaseData(
new CommandOptionSet(new[]
{
new CommandOption("i", "13")
}),
new TestCommand {IntOption = 13}
);
}
[Test]
[TestCaseSource(nameof(GetTestCases_ResolveCommand))]
public void ResolveCommand_Test(CommandOptionSet commandOptionSet, TestCommand expectedCommand)
{
// Arrange
var resolver = new CommandResolver(new[] {typeof(TestCommand)}, new CommandOptionConverter());
// Act
var command = resolver.ResolveCommand(commandOptionSet) as TestCommand;
// Assert
Assert.Multiple(() =>
{
Assert.That(command, Is.Not.Null);
Assert.That(command.StringOption, Is.EqualTo(expectedCommand.StringOption), nameof(command.StringOption));
Assert.That(command.IntOption, Is.EqualTo(expectedCommand.IntOption), nameof(command.IntOption));
});
}
private static IEnumerable<TestCaseData> GetTestCases_ResolveCommand_IsRequired()
{
yield return new TestCaseData(CommandOptionSet.Empty);
yield return new TestCaseData(
new CommandOptionSet(new[]
{
new CommandOption("str", "hello world")
})
);
}
[Test]
[TestCaseSource(nameof(GetTestCases_ResolveCommand_IsRequired))]
public void ResolveCommand_IsRequired_Test(CommandOptionSet commandOptionSet)
{
// Arrange
var resolver = new CommandResolver(new[] {typeof(TestCommand)}, new CommandOptionConverter());
// Act & Assert
Assert.Throws<CommandResolveException>(() => resolver.ResolveCommand(commandOptionSet));
}
}
}

View File

@@ -0,0 +1,69 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Models;
using CliFx.Services;
using NUnit.Framework;
namespace CliFx.Tests
{
public partial class CommandSchemaResolverTests
{
[Command(Description = "Command description")]
public class TestCommand : ICommand
{
[CommandOption("option-a", 'a', GroupName = "Group 1")]
public int OptionA { get; set; }
[CommandOption("option-b", IsRequired = true)]
public string OptionB { get; set; }
[CommandOption("option-c", Description = "Option C description")]
public bool OptionC { get; set; }
public CommandContext Context { get; set; }
public Task<ExitCode> ExecuteAsync() => throw new NotImplementedException();
}
}
[TestFixture]
public partial class CommandSchemaResolverTests
{
private static IEnumerable<TestCaseData> GetTestCases_ResolveAllSchemas()
{
yield return new TestCaseData(
new[] {typeof(TestCommand)},
new[]
{
new CommandSchema(typeof(TestCommand),
null, true, "Command description",
new[]
{
new CommandOptionSchema(typeof(TestCommand).GetProperty(nameof(TestCommand.OptionA)),
"option-a", 'a', false, "Group 1", null),
new CommandOptionSchema(typeof(TestCommand).GetProperty(nameof(TestCommand.OptionB)),
"option-b", null, true, null, null),
new CommandOptionSchema(typeof(TestCommand).GetProperty(nameof(TestCommand.OptionC)),
"option-c", null, false, null, "Option C description")
})
}
);
}
[Test]
[TestCaseSource(nameof(GetTestCases_ResolveAllSchemas))]
public void ResolveAllSchemas_Test(IReadOnlyList<Type> sourceTypes, IReadOnlyList<CommandSchema> expectedSchemas)
{
// Arrange
var resolver = new CommandSchemaResolver(sourceTypes);
// Act
var schemas = resolver.ResolveAllSchemas();
// Assert
Assert.That(schemas, Is.EqualTo(expectedSchemas).Using(CommandSchemaEqualityComparer.Instance));
}
}
}

View File

@@ -9,6 +9,8 @@ namespace CliFx.Tests
{ {
private static string DummyFilePath => typeof(Dummy.Program).Assembly.Location; private static string DummyFilePath => typeof(Dummy.Program).Assembly.Location;
private static string DummyVersionText => typeof(Dummy.Program).Assembly.GetName().Version.ToString();
[Test] [Test]
[TestCase("", "Hello world")] [TestCase("", "Hello world")]
[TestCase("-t .NET", "Hello .NET")] [TestCase("-t .NET", "Hello .NET")]
@@ -18,15 +20,55 @@ namespace CliFx.Tests
[TestCase("add -v 4 -v 16", "20")] [TestCase("add -v 4 -v 16", "20")]
[TestCase("log -v 100", "2")] [TestCase("log -v 100", "2")]
[TestCase("log --value 256 --base 2", "8")] [TestCase("log --value 256 --base 2", "8")]
public async Task Execute_Test(string arguments, string expectedOutput) public async Task CliApplication_RunAsync_Test(string arguments, string expectedOutput)
{ {
// Act // Act
var result = await Cli.Wrap(DummyFilePath).SetArguments(arguments).ExecuteAsync(); var result = await Cli.Wrap(DummyFilePath).SetArguments(arguments).ExecuteAsync();
// Assert // Assert
Assert.That(result.ExitCode, Is.Zero, "Exit code"); Assert.Multiple(() =>
Assert.That(result.StandardOutput.Trim(), Is.EqualTo(expectedOutput), "Stdout"); {
Assert.That(result.StandardError.Trim(), Is.Empty, "Stderr"); 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");
});
}
[Test]
[TestCase("--version")]
public async Task CliApplication_RunAsync_Version_Test(string arguments)
{
// Act
var result = await Cli.Wrap(DummyFilePath).SetArguments(arguments).ExecuteAsync();
// Assert
Assert.Multiple(() =>
{
Assert.That(result.ExitCode, Is.Zero, "Exit code");
Assert.That(result.StandardOutput.Trim(), Is.EqualTo(DummyVersionText), "Stdout");
Assert.That(result.StandardError.Trim(), Is.Empty, "Stderr");
});
}
[Test]
[TestCase("--help")]
[TestCase("-h")]
[TestCase("add -h")]
[TestCase("add --help")]
[TestCase("log -h")]
[TestCase("log --help")]
public async Task CliApplication_RunAsync_Help_Test(string arguments)
{
// Act
var result = await Cli.Wrap(DummyFilePath).SetArguments(arguments).ExecuteAsync();
// Assert
Assert.Multiple(() =>
{
Assert.That(result.ExitCode, Is.Zero, "Exit code");
Assert.That(result.StandardOutput.Trim(), Is.Not.Empty, "Stdout");
Assert.That(result.StandardError.Trim(), Is.Empty, "Stderr");
});
} }
} }
} }

View File

@@ -1,4 +1,5 @@
using System; using System;
using CliFx.Internal;
namespace CliFx.Attributes namespace CliFx.Attributes
{ {
@@ -9,9 +10,16 @@ namespace CliFx.Attributes
public string Description { get; set; } public string Description { get; set; }
public bool IsDefault => Name.IsNullOrWhiteSpace();
public CommandAttribute(string name) public CommandAttribute(string name)
{ {
Name = name; Name = name;
} }
public CommandAttribute()
: this(null)
{
}
} }
} }

View File

@@ -11,6 +11,8 @@ namespace CliFx.Attributes
public bool IsRequired { get; set; } public bool IsRequired { get; set; }
public string GroupName { get; set; }
public string Description { get; set; } public string Description { get; set; }
public CommandOptionAttribute(string name, char? shortName) public CommandOptionAttribute(string name, char? shortName)

View File

@@ -1,13 +0,0 @@
using System;
namespace CliFx.Attributes
{
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class DefaultCommandAttribute : CommandAttribute
{
public DefaultCommandAttribute()
: base(null)
{
}
}
}

View File

@@ -6,24 +6,24 @@ namespace CliFx
{ {
public class CliApplication : ICliApplication public class CliApplication : ICliApplication
{ {
private readonly ICommandOptionParser _commandOptionParser; private readonly ICommandInputParser _commandInputParser;
private readonly ICommandResolver _commandResolver; private readonly ICommandInitializer _commandInitializer;
public CliApplication(ICommandOptionParser commandOptionParser, ICommandResolver commandResolver) public CliApplication(ICommandInputParser commandInputParser, ICommandInitializer commandInitializer)
{ {
_commandOptionParser = commandOptionParser; _commandInputParser = commandInputParser;
_commandResolver = commandResolver; _commandInitializer = commandInitializer;
} }
public CliApplication() public CliApplication()
: this(new CommandOptionParser(), new CommandResolver(new CommandOptionConverter())) : this(new CommandInputParser(), new CommandInitializer())
{ {
} }
public async Task<int> RunAsync(IReadOnlyList<string> commandLineArguments) public async Task<int> RunAsync(IReadOnlyList<string> commandLineArguments)
{ {
var optionSet = _commandOptionParser.ParseOptions(commandLineArguments); var input = _commandInputParser.ParseInput(commandLineArguments);
var command = _commandResolver.ResolveCommand(optionSet); var command = _commandInitializer.InitializeCommand(input);
var exitCode = await command.ExecuteAsync(); var exitCode = await command.ExecuteAsync();

View File

@@ -1,15 +1,59 @@
using System; using System;
using System.Reflection;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Models; using CliFx.Models;
using CliFx.Services;
namespace CliFx namespace CliFx
{ {
public abstract class Command : ICommand public abstract class Command : ICommand
{ {
public virtual ExitCode Execute() => throw new InvalidOperationException( [CommandOption("help", 'h', GroupName = "__help", Description = "Shows help.")]
"Can't execute command because its execution method is not defined. " + public bool IsHelpRequested { get; set; }
$"Override Execute or ExecuteAsync on {GetType().Name} in order to make it executable.");
public virtual Task<ExitCode> ExecuteAsync() => Task.FromResult(Execute()); [CommandOption("version", GroupName = "__version", Description = "Shows application version.")]
public bool IsVersionRequested { get; set; }
public CommandContext Context { get; set; }
public IConsoleWriter Output { get; set; } = ConsoleWriter.GetStandardOutput();
public IConsoleWriter Error { get; set; } = ConsoleWriter.GetStandardError();
protected virtual ExitCode Process() => throw new InvalidOperationException(
"Can't execute command because its execution method is not defined. " +
$"Override {nameof(Process)} or {nameof(ProcessAsync)} on {GetType().Name} in order to make it executable.");
protected virtual Task<ExitCode> ProcessAsync() => Task.FromResult(Process());
protected virtual void ShowHelp()
{
var text = new HelpTextBuilder().Build(Context);
Output.WriteLine(text);
}
protected virtual void ShowVersion()
{
var text = Assembly.GetEntryAssembly()?.GetName().Version.ToString();
Output.WriteLine(text);
}
public Task<ExitCode> ExecuteAsync()
{
if (IsHelpRequested)
{
ShowHelp();
return Task.FromResult(ExitCode.Success);
}
if (IsVersionRequested && Context.CommandSchema.IsDefault)
{
ShowVersion();
return Task.FromResult(ExitCode.Success);
}
return ProcessAsync();
}
} }
} }

View File

@@ -5,6 +5,8 @@ namespace CliFx
{ {
public interface ICommand public interface ICommand
{ {
CommandContext Context { get; set; }
Task<ExitCode> ExecuteAsync(); Task<ExitCode> ExecuteAsync();
} }
} }

View File

@@ -1,48 +0,0 @@
using System;
using System.Reflection;
using CliFx.Attributes;
namespace CliFx.Internal
{
internal partial class CommandOptionProperty
{
private readonly PropertyInfo _property;
public Type Type => _property.PropertyType;
public string Name { 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)
{
_property = property;
Name = name;
ShortName = shortName;
IsRequired = isRequired;
Description = description;
}
public void SetValue(Command command, object value) => _property.SetValue(command, value);
}
internal partial class CommandOptionProperty
{
public static bool IsValid(PropertyInfo property) => property.IsDefined(typeof(CommandOptionAttribute));
public static CommandOptionProperty Initialize(PropertyInfo property)
{
if (!IsValid(property))
throw new InvalidOperationException($"[{property.Name}] is not a valid command option property.");
var attribute = property.GetCustomAttribute<CommandOptionAttribute>();
return new CommandOptionProperty(property, attribute.Name, attribute.ShortName, attribute.IsRequired,
attribute.Description);
}
}
}

View File

@@ -1,60 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using CliFx.Attributes;
namespace CliFx.Internal
{
internal partial class CommandType
{
private readonly Type _type;
public string Name { get; }
public bool IsDefault { get; }
public string Description { get; }
public IReadOnlyList<CommandOptionProperty> Options { get; }
public CommandType(Type type, string name, bool isDefault, string description, IReadOnlyList<CommandOptionProperty> options)
{
_type = type;
Name = name;
IsDefault = isDefault;
Description = description;
Options = options;
}
public Command Activate() => (Command) Activator.CreateInstance(_type);
}
internal partial class CommandType
{
public static bool IsValid(Type type) =>
type.GetInterfaces().Contains(typeof(ICommand)) &&
type.IsDefined(typeof(CommandAttribute));
public static CommandType Initialize(Type type)
{
if (!IsValid(type))
throw new InvalidOperationException($"[{type.Name}] is not a valid command type.");
var attribute = type.GetCustomAttribute<CommandAttribute>();
var name = attribute.Name;
var isDefault = attribute is DefaultCommandAttribute;
var description = attribute.Description;
var options = type.GetProperties()
.Where(CommandOptionProperty.IsValid)
.Select(CommandOptionProperty.Initialize)
.ToArray();
return new CommandType(type, name, isDefault, description, options);
}
public static IEnumerable<CommandType> GetCommandTypes(IEnumerable<Type> types) => types.Where(IsValid).Select(Initialize);
}
}

View File

@@ -0,0 +1,38 @@
using System.Collections.Generic;
using System.Linq;
namespace CliFx.Internal
{
internal class HashCodeBuilder
{
private int _code = 17;
public HashCodeBuilder Add(int hashCode)
{
unchecked
{
_code = _code * 23 + hashCode;
}
return this;
}
public HashCodeBuilder Add(IEnumerable<int> hashCodes)
{
foreach (var hashCode in hashCodes)
Add(hashCode);
return this;
}
public HashCodeBuilder Add<T>(T obj, IEqualityComparer<T> comparer) => Add(comparer.GetHashCode(obj));
public HashCodeBuilder Add<T>(T obj) => Add(obj, EqualityComparer<T>.Default);
public HashCodeBuilder AddMany<T>(IEnumerable<T> objs, IEqualityComparer<T> comparer) => Add(objs.Select(comparer.GetHashCode));
public HashCodeBuilder AddMany<T>(IEnumerable<T> objs) => AddMany(objs, EqualityComparer<T>.Default);
public int Build() => _code;
}
}

View File

@@ -0,0 +1,17 @@
using System.Collections.Generic;
namespace CliFx.Models
{
public class CommandContext
{
public IReadOnlyList<CommandSchema> AvailableCommandSchemas { get; }
public CommandSchema CommandSchema { get; }
public CommandContext(IReadOnlyList<CommandSchema> availableCommandSchemas, CommandSchema commandSchema)
{
AvailableCommandSchemas = availableCommandSchemas;
CommandSchema = commandSchema;
}
}
}

View File

@@ -0,0 +1,67 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
using CliFx.Internal;
namespace CliFx.Models
{
public partial class CommandInput
{
public string CommandName { get; }
public IReadOnlyList<CommandOptionInput> Options { get; }
public CommandInput(string commandName, IReadOnlyList<CommandOptionInput> options)
{
CommandName = commandName;
Options = options;
}
public CommandInput(IReadOnlyList<CommandOptionInput> options)
: this(null, options)
{
}
public CommandInput(string commandName)
: this(commandName, new CommandOptionInput[0])
{
}
public CommandInput()
: this(null, new CommandOptionInput[0])
{
}
public override string ToString()
{
var buffer = new StringBuilder();
if (!CommandName.IsNullOrWhiteSpace())
{
buffer.Append(CommandName);
}
if (Options.Any())
{
if (buffer.Length > 0)
buffer.Append(' ');
buffer.Append('[');
foreach (var option in Options)
{
buffer.Append(option.Name);
}
buffer.Append(']');
}
return buffer.ToString();
}
}
public partial class CommandInput
{
public static CommandInput Empty { get; } = new CommandInput();
}
}

View File

@@ -0,0 +1,34 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CliFx.Internal;
namespace CliFx.Models
{
public partial class CommandInputEqualityComparer : IEqualityComparer<CommandInput>
{
/// <inheritdoc />
public bool Equals(CommandInput x, CommandInput y)
{
if (ReferenceEquals(x, y))
return true;
if (x is null || y is null)
return false;
return StringComparer.OrdinalIgnoreCase.Equals(x.CommandName, y.CommandName) &&
x.Options.SequenceEqual(y.Options, CommandOptionInputEqualityComparer.Instance);
}
/// <inheritdoc />
public int GetHashCode(CommandInput obj) => new HashCodeBuilder()
.Add(obj.CommandName, StringComparer.OrdinalIgnoreCase)
.AddMany(obj.Options, CommandOptionInputEqualityComparer.Instance)
.Build();
}
public partial class CommandInputEqualityComparer
{
public static CommandInputEqualityComparer Instance { get; } = new CommandInputEqualityComparer();
}
}

View File

@@ -2,24 +2,24 @@
namespace CliFx.Models namespace CliFx.Models
{ {
public class CommandOption public class CommandOptionInput
{ {
public string Name { get; } public string Name { get; }
public IReadOnlyList<string> Values { get; } public IReadOnlyList<string> Values { get; }
public CommandOption(string name, IReadOnlyList<string> values) public CommandOptionInput(string name, IReadOnlyList<string> values)
{ {
Name = name; Name = name;
Values = values; Values = values;
} }
public CommandOption(string name, string value) public CommandOptionInput(string name, string value)
: this(name, new[] {value}) : this(name, new[] {value})
{ {
} }
public CommandOption(string name) public CommandOptionInput(string name)
: this(name, new string[0]) : this(name, new string[0])
{ {
} }

View File

@@ -0,0 +1,34 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CliFx.Internal;
namespace CliFx.Models
{
public partial class CommandOptionInputEqualityComparer : IEqualityComparer<CommandOptionInput>
{
/// <inheritdoc />
public bool Equals(CommandOptionInput x, CommandOptionInput y)
{
if (ReferenceEquals(x, y))
return true;
if (x is null || y is null)
return false;
return StringComparer.OrdinalIgnoreCase.Equals(x.Name, y.Name) &&
x.Values.SequenceEqual(y.Values, StringComparer.Ordinal);
}
/// <inheritdoc />
public int GetHashCode(CommandOptionInput obj) => new HashCodeBuilder()
.Add(obj.Name, StringComparer.OrdinalIgnoreCase)
.AddMany(obj.Values, StringComparer.Ordinal)
.Build();
}
public partial class CommandOptionInputEqualityComparer
{
public static CommandOptionInputEqualityComparer Instance { get; } = new CommandOptionInputEqualityComparer();
}
}

View File

@@ -0,0 +1,32 @@
using System.Collections.Generic;
using System.Reflection;
using CliFx.Internal;
namespace CliFx.Models
{
public class CommandOptionSchema
{
public PropertyInfo Property { get; }
public string Name { get; }
public char? ShortName { get; }
public bool IsRequired { get; }
public string GroupName { get; }
public string Description { get; }
public CommandOptionSchema(PropertyInfo property, string name, char? shortName,
bool isRequired, string groupName, string description)
{
Property = property;
Name = name;
ShortName = shortName;
IsRequired = isRequired;
GroupName = groupName;
Description = description;
}
}
}

View File

@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using CliFx.Internal;
namespace CliFx.Models
{
public partial class CommandOptionSchemaEqualityComparer : IEqualityComparer<CommandOptionSchema>
{
/// <inheritdoc />
public bool Equals(CommandOptionSchema x, CommandOptionSchema y)
{
if (ReferenceEquals(x, y))
return true;
if (x is null || y is null)
return false;
return x.Property == y.Property &&
StringComparer.OrdinalIgnoreCase.Equals(x.Name, y.Name) &&
x.ShortName == y.ShortName &&
StringComparer.OrdinalIgnoreCase.Equals(x.GroupName, y.GroupName) &&
StringComparer.Ordinal.Equals(x.Description, y.Description);
}
/// <inheritdoc />
public int GetHashCode(CommandOptionSchema obj) => new HashCodeBuilder()
.Add(obj.Property)
.Add(obj.Name, StringComparer.OrdinalIgnoreCase)
.Add(obj.ShortName)
.Add(obj.GroupName, StringComparer.OrdinalIgnoreCase)
.Add(obj.Description, StringComparer.Ordinal)
.Build();
}
public partial class CommandOptionSchemaEqualityComparer
{
public static CommandOptionSchemaEqualityComparer Instance { get; } = new CommandOptionSchemaEqualityComparer();
}
}

View File

@@ -1,47 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using CliFx.Internal;
namespace CliFx.Models
{
public partial class CommandOptionSet
{
public string CommandName { get; }
public IReadOnlyList<CommandOption> Options { get; }
public CommandOptionSet(string commandName, IReadOnlyList<CommandOption> options)
{
CommandName = commandName;
Options = options;
}
public CommandOptionSet(IReadOnlyList<CommandOption> options)
: this(null, options)
{
}
public CommandOptionSet(string commandName)
: this(commandName, new CommandOption[0])
{
}
public override string ToString()
{
if (Options.Any())
{
var optionsJoined = Options.Select(o => o.Name).JoinToString(", ");
return !CommandName.IsNullOrWhiteSpace() ? $"{CommandName} / [{optionsJoined}]" : $"[{optionsJoined}]";
}
else
{
return !CommandName.IsNullOrWhiteSpace() ? $"{CommandName} / no options" : "no options";
}
}
}
public partial class CommandOptionSet
{
public static CommandOptionSet Empty { get; } = new CommandOptionSet(new CommandOption[0]);
}
}

View File

@@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
namespace CliFx.Models
{
public class CommandSchema
{
public Type Type { get; }
public string Name { get; }
public bool IsDefault { get; }
public string Description { get; }
public IReadOnlyList<CommandOptionSchema> Options { get; }
public CommandSchema(Type type, string name, bool isDefault, string description, IReadOnlyList<CommandOptionSchema> options)
{
Type = type;
Name = name;
IsDefault = isDefault;
Description = description;
Options = options;
}
}
}

View File

@@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CliFx.Internal;
namespace CliFx.Models
{
public partial class CommandSchemaEqualityComparer : IEqualityComparer<CommandSchema>
{
/// <inheritdoc />
public bool Equals(CommandSchema x, CommandSchema y)
{
if (ReferenceEquals(x, y))
return true;
if (x is null || y is null)
return false;
return x.Type == y.Type &&
StringComparer.OrdinalIgnoreCase.Equals(x.Name, y.Name) &&
x.IsDefault == y.IsDefault &&
StringComparer.Ordinal.Equals(x.Description, y.Description) &&
x.Options.SequenceEqual(y.Options, CommandOptionSchemaEqualityComparer.Instance);
}
/// <inheritdoc />
public int GetHashCode(CommandSchema obj) => new HashCodeBuilder()
.Add(obj.Type)
.Add(obj.Name, StringComparer.OrdinalIgnoreCase)
.Add(obj.IsDefault)
.Add(obj.Description, StringComparer.Ordinal)
.AddMany(obj.Options, CommandOptionSchemaEqualityComparer.Instance)
.Build();
}
public partial class CommandSchemaEqualityComparer
{
public static CommandSchemaEqualityComparer Instance { get; } = new CommandSchemaEqualityComparer();
}
}

View File

@@ -6,7 +6,7 @@ namespace CliFx.Models
{ {
public static class Extensions public static class Extensions
{ {
public static CommandOption GetOptionOrDefault(this CommandOptionSet set, string name, char? shortName) => public static CommandOptionInput GetOptionOrDefault(this CommandInput set, string name, char? shortName) =>
set.Options.FirstOrDefault(o => set.Options.FirstOrDefault(o =>
{ {
if (!name.IsNullOrWhiteSpace() && string.Equals(o.Name, name, StringComparison.Ordinal)) if (!name.IsNullOrWhiteSpace() && string.Equals(o.Name, name, StringComparison.Ordinal))

24
CliFx/Models/TextSpan.cs Normal file
View File

@@ -0,0 +1,24 @@
using System.Drawing;
namespace CliFx.Models
{
public class TextSpan
{
public string Text { get; }
public Color Color { get; }
public TextSpan(string text, Color color)
{
Text = text;
Color = color;
}
public TextSpan(string text)
: this(text, Color.Gray)
{
}
public override string ToString() => Text;
}
}

View File

@@ -0,0 +1,137 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Internal;
using CliFx.Models;
namespace CliFx.Services
{
public class CommandInitializer : ICommandInitializer
{
private readonly ITypeActivator _typeActivator;
private readonly ICommandSchemaResolver _commandSchemaResolver;
private readonly ICommandOptionInputConverter _commandOptionInputConverter;
public CommandInitializer(ITypeActivator typeActivator, ICommandSchemaResolver commandSchemaResolver,
ICommandOptionInputConverter commandOptionInputConverter)
{
_typeActivator = typeActivator;
_commandSchemaResolver = commandSchemaResolver;
_commandOptionInputConverter = commandOptionInputConverter;
}
public CommandInitializer(ICommandSchemaResolver commandSchemaResolver)
: this(new TypeActivator(), commandSchemaResolver, new CommandOptionInputConverter())
{
}
public CommandInitializer()
: this(new CommandSchemaResolver())
{
}
private CommandSchema GetDefaultSchema(IReadOnlyList<CommandSchema> schemas)
{
// Get command types marked as default
var defaultSchemas = schemas.Where(t => t.IsDefault).ToArray();
// If there's only one type - return
if (defaultSchemas.Length == 1)
return defaultSchemas.Single();
// If there are multiple - throw
if (defaultSchemas.Length > 1)
{
throw new CommandResolveException(
"Can't resolve default command because there is more than one command marked as default. " +
$"Make sure you apply {nameof(CommandAttribute)} only to one command.");
}
// If there aren't any - throw
throw new CommandResolveException(
"Can't resolve default command because there are no commands marked as default. " +
$"Apply {nameof(CommandAttribute)} to the default command.");
}
private CommandSchema GetSchemaByName(IReadOnlyList<CommandSchema> schemas, string name)
{
// Get command types with given name
var matchingSchemas =
schemas.Where(t => string.Equals(t.Name, name, StringComparison.OrdinalIgnoreCase)).ToArray();
// If there's only one type - return
if (matchingSchemas.Length == 1)
return matchingSchemas.Single();
// If there are multiple - throw
if (matchingSchemas.Length > 1)
{
throw new CommandResolveException(
$"Can't resolve command because there is more than one command named [{name}]. " +
"Make sure all command names are unique and keep in mind that comparison is case-insensitive.");
}
// If there aren't any - throw
throw new CommandResolveException(
$"Can't resolve command because none of the commands is named [{name}]. " +
$"Apply {nameof(CommandAttribute)} to give command a name.");
}
// TODO: refactor
public ICommand InitializeCommand(CommandInput input)
{
var schemas = _commandSchemaResolver.ResolveAllSchemas();
// Get command type
var schema = !input.CommandName.IsNullOrWhiteSpace()
? GetSchemaByName(schemas, input.CommandName)
: GetDefaultSchema(schemas);
// Activate command
var command = (ICommand) _typeActivator.Activate(schema.Type);
command.Context = new CommandContext(schemas, schema);
// Set command options
var isGroupNameDetected = false;
var groupName = default(string);
var properties = new HashSet<CommandOptionSchema>();
foreach (var option in input.Options)
{
var optionInfo = schema.Options.FirstOrDefault(p =>
string.Equals(p.Name, option.Name, StringComparison.OrdinalIgnoreCase) ||
string.Equals(p.ShortName?.AsString(), option.Name, StringComparison.OrdinalIgnoreCase));
if (optionInfo == null)
continue;
if (isGroupNameDetected && !string.Equals(groupName, optionInfo.GroupName, StringComparison.OrdinalIgnoreCase))
continue;
if (!isGroupNameDetected)
{
groupName = optionInfo.GroupName;
isGroupNameDetected = true;
}
var convertedValue = _commandOptionInputConverter.ConvertOption(option, optionInfo.Property.PropertyType);
optionInfo.Property.SetValue(command, convertedValue);
properties.Add(optionInfo);
}
var unsetRequiredOptions = schema.Options
.Except(properties)
.Where(p => p.IsRequired)
.Where(p => string.Equals(p.GroupName, groupName, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (unsetRequiredOptions.Any())
throw new CommandResolveException(
$"Can't resolve command because one or more required properties were not set: {unsetRequiredOptions.Select(p => p.Name).JoinToString(", ")}");
return command;
}
}
}

View File

@@ -6,9 +6,10 @@ using CliFx.Models;
namespace CliFx.Services namespace CliFx.Services
{ {
public class CommandOptionParser : ICommandOptionParser public class CommandInputParser : ICommandInputParser
{ {
public CommandOptionSet ParseOptions(IReadOnlyList<string> commandLineArguments) // TODO: refactor
public CommandInput ParseInput(IReadOnlyList<string> commandLineArguments)
{ {
// Initialize command name placeholder // Initialize command name placeholder
string commandName = null; string commandName = null;
@@ -71,7 +72,7 @@ namespace CliFx.Services
isFirstArgument = false; isFirstArgument = false;
} }
return new CommandOptionSet(commandName, rawOptions.Select(p => new CommandOption(p.Key, p.Value)).ToArray()); return new CommandInput(commandName, rawOptions.Select(p => new CommandOptionInput(p.Key, p.Value)).ToArray());
} }
} }
} }

View File

@@ -8,16 +8,16 @@ using CliFx.Models;
namespace CliFx.Services namespace CliFx.Services
{ {
public class CommandOptionConverter : ICommandOptionConverter public class CommandOptionInputConverter : ICommandOptionInputConverter
{ {
private readonly IFormatProvider _formatProvider; private readonly IFormatProvider _formatProvider;
public CommandOptionConverter(IFormatProvider formatProvider) public CommandOptionInputConverter(IFormatProvider formatProvider)
{ {
_formatProvider = formatProvider; _formatProvider = formatProvider;
} }
public CommandOptionConverter() public CommandOptionInputConverter()
: this(CultureInfo.InvariantCulture) : this(CultureInfo.InvariantCulture)
{ {
} }
@@ -216,7 +216,8 @@ namespace CliFx.Services
throw new CommandOptionConvertException($"Can't convert value [{value}] to unrecognized type [{targetType}]."); throw new CommandOptionConvertException($"Can't convert value [{value}] to unrecognized type [{targetType}].");
} }
public object ConvertOption(CommandOption option, Type targetType) // TODO: refactor this
public object ConvertOption(CommandOptionInput option, Type targetType)
{ {
if (targetType != typeof(string) && targetType.IsEnumerable()) if (targetType != typeof(string) && targetType.IsEnumerable())
{ {

View File

@@ -1,114 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Internal;
using CliFx.Models;
namespace CliFx.Services
{
public partial class CommandResolver : ICommandResolver
{
private readonly IReadOnlyList<Type> _availableTypes;
private readonly ICommandOptionConverter _commandOptionConverter;
public CommandResolver(IReadOnlyList<Type> availableTypes, ICommandOptionConverter commandOptionConverter)
{
_availableTypes = availableTypes;
_commandOptionConverter = commandOptionConverter;
}
public CommandResolver(ICommandOptionConverter commandOptionConverter)
: this(GetDefaultAvailableTypes(), commandOptionConverter)
{
}
private IEnumerable<CommandType> GetCommandTypes() => CommandType.GetCommandTypes(_availableTypes);
private CommandType GetDefaultCommandType()
{
// Get command types marked as default
var defaultCommandTypes = GetCommandTypes().Where(t => t.IsDefault).ToArray();
// If there's only one type - return
if (defaultCommandTypes.Length == 1)
return defaultCommandTypes.Single();
// If there are multiple - throw
if (defaultCommandTypes.Length > 1)
{
throw new CommandResolveException(
"Can't resolve default command because there is more than one command marked as default. " +
$"Make sure you apply {nameof(DefaultCommandAttribute)} only to one command.");
}
// If there aren't any - throw
throw new CommandResolveException(
"Can't resolve default command because there are no commands marked as default. " +
$"Apply {nameof(DefaultCommandAttribute)} to the default command.");
}
private CommandType GetCommandType(string name)
{
// Get command types with given name
var matchingCommandTypes =
GetCommandTypes().Where(t => string.Equals(t.Name, name, StringComparison.OrdinalIgnoreCase)).ToArray();
// If there's only one type - return
if (matchingCommandTypes.Length == 1)
return matchingCommandTypes.Single();
// If there are multiple - throw
if (matchingCommandTypes.Length > 1)
{
throw new CommandResolveException(
$"Can't resolve command because there is more than one command named [{name}]. " +
"Make sure all command names are unique and keep in mind that comparison is case-insensitive.");
}
// If there aren't any - throw
throw new CommandResolveException(
$"Can't resolve command because none of the commands is named [{name}]. " +
$"Apply {nameof(CommandAttribute)} to give command a name.");
}
public ICommand ResolveCommand(CommandOptionSet optionSet)
{
// Get command type
var commandType = !optionSet.CommandName.IsNullOrWhiteSpace()
? GetCommandType(optionSet.CommandName)
: GetDefaultCommandType();
// Activate command
var command = commandType.Activate();
// Set command options
foreach (var property in commandType.Options)
{
// 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(option, property.Type);
property.SetValue(command, convertedValue);
}
// If the property is required but it's missing - throw
else if (property.IsRequired)
{
throw new CommandResolveException($"Can't resolve command because required property [{property.Name}] is not set.");
}
}
return command;
}
}
public partial class CommandResolver
{
private static IReadOnlyList<Type> GetDefaultAvailableTypes() => Assembly.GetEntryAssembly()?.GetExportedTypes() ?? new Type[0];
}
}

View File

@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using CliFx.Attributes;
using CliFx.Models;
namespace CliFx.Services
{
public class CommandSchemaResolver : ICommandSchemaResolver
{
private readonly IReadOnlyList<Type> _sourceTypes;
public CommandSchemaResolver(IReadOnlyList<Type> sourceTypes)
{
_sourceTypes = sourceTypes;
}
public CommandSchemaResolver(IReadOnlyList<Assembly> sourceAssemblies)
: this(sourceAssemblies.SelectMany(a => a.ExportedTypes).ToArray())
{
}
public CommandSchemaResolver()
: this(new[] {Assembly.GetEntryAssembly()})
{
}
private IEnumerable<Type> GetCommandTypes() => _sourceTypes.Where(t => t.GetInterfaces().Contains(typeof(ICommand)));
private IReadOnlyList<CommandOptionSchema> GetCommandOptionSchemas(Type commandType)
{
var result = new List<CommandOptionSchema>();
foreach (var optionProperty in commandType.GetProperties())
{
var optionAttribute = optionProperty.GetCustomAttribute<CommandOptionAttribute>();
if (optionAttribute == null)
continue;
result.Add(new CommandOptionSchema(optionProperty,
optionAttribute.Name,
optionAttribute.ShortName,
optionAttribute.IsRequired,
optionAttribute.GroupName,
optionAttribute.Description));
}
return result;
}
public IReadOnlyList<CommandSchema> ResolveAllSchemas()
{
var result = new List<CommandSchema>();
foreach (var commandType in GetCommandTypes())
{
var commandAttribute = commandType.GetCustomAttribute<CommandAttribute>();
if (commandAttribute == null)
continue;
result.Add(new CommandSchema(commandType,
commandAttribute.Name,
commandAttribute.IsDefault,
commandAttribute.Description,
GetCommandOptionSchemas(commandType)));
}
return result;
}
}
}

View File

@@ -0,0 +1,32 @@
using System;
using System.IO;
using CliFx.Models;
namespace CliFx.Services
{
public partial class ConsoleWriter : IConsoleWriter, IDisposable
{
private readonly TextWriter _textWriter;
private readonly bool _isRedirected;
public ConsoleWriter(TextWriter textWriter, bool isRedirected)
{
_textWriter = textWriter;
_isRedirected = isRedirected;
}
// TODO: handle colors
public void Write(TextSpan text) => _textWriter.Write(text.Text);
public void WriteLine(TextSpan text) => _textWriter.WriteLine(text.Text);
public void Dispose() => _textWriter.Dispose();
}
public partial class ConsoleWriter
{
public static ConsoleWriter GetStandardOutput() => new ConsoleWriter(Console.Out, Console.IsOutputRedirected);
public static ConsoleWriter GetStandardError() => new ConsoleWriter(Console.Error, Console.IsErrorRedirected);
}
}

View File

@@ -0,0 +1,11 @@
using CliFx.Models;
namespace CliFx.Services
{
public static class Extensions
{
public static void Write(this IConsoleWriter consoleWriter, string text) => consoleWriter.Write(new TextSpan(text));
public static void WriteLine(this IConsoleWriter consoleWriter, string text) => consoleWriter.WriteLine(new TextSpan(text));
}
}

View File

@@ -0,0 +1,106 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using CliFx.Internal;
using CliFx.Models;
namespace CliFx.Services
{
// TODO: add color
public class HelpTextBuilder : IHelpTextBuilder
{
// TODO: move to context?
private string GetExeName() => Path.GetFileNameWithoutExtension(Assembly.GetEntryAssembly()?.Location);
// TODO: move to context?
private string GetVersionText() => Assembly.GetEntryAssembly()?.GetName().Version.ToString();
private IReadOnlyList<string> GetOptionIdentifiers(CommandOptionSchema option)
{
var result = new List<string>();
if (option.ShortName != null)
result.Add("-" + option.ShortName.Value);
if (!option.Name.IsNullOrWhiteSpace())
result.Add("--" + option.Name);
return result;
}
private void AddDescription(StringBuilder buffer, CommandContext context)
{
if (context.CommandSchema.Description.IsNullOrWhiteSpace())
return;
buffer.AppendLine("Description:");
buffer.Append(" ");
buffer.AppendLine(context.CommandSchema.Description);
buffer.AppendLine();
}
private void AddUsage(StringBuilder buffer, CommandContext context)
{
buffer.AppendLine("Usage:");
buffer.Append(" ");
buffer.Append(GetExeName());
if (!context.CommandSchema.Name.IsNullOrWhiteSpace())
{
buffer.Append(' ');
buffer.Append(context.CommandSchema.Name);
}
if (context.CommandSchema.Options.Any())
{
buffer.Append(' ');
buffer.Append("[options]");
}
buffer.AppendLine().AppendLine();
}
private void AddOptions(StringBuilder buffer, CommandContext context)
{
if (!context.CommandSchema.Options.Any())
return;
buffer.AppendLine("Options:");
foreach (var option in context.CommandSchema.Options)
{
buffer.Append(option.IsRequired ? " * " : " ");
buffer.Append(GetOptionIdentifiers(option).JoinToString("|"));
if (!option.Description.IsNullOrWhiteSpace())
{
buffer.Append(" ");
buffer.Append(option.Description);
}
buffer.AppendLine();
}
buffer.AppendLine();
}
public string Build(CommandContext context)
{
var buffer = new StringBuilder();
AddDescription(buffer, context);
AddUsage(buffer, context);
AddOptions(buffer, context);
// TODO: add default command help
return buffer.ToString();
}
}
}

View File

@@ -0,0 +1,9 @@
using CliFx.Models;
namespace CliFx.Services
{
public interface ICommandInitializer
{
ICommand InitializeCommand(CommandInput input);
}
}

View File

@@ -0,0 +1,10 @@
using System.Collections.Generic;
using CliFx.Models;
namespace CliFx.Services
{
public interface ICommandInputParser
{
CommandInput ParseInput(IReadOnlyList<string> commandLineArguments);
}
}

View File

@@ -1,10 +0,0 @@
using System;
using CliFx.Models;
namespace CliFx.Services
{
public interface ICommandOptionConverter
{
object ConvertOption(CommandOption option, Type targetType);
}
}

View File

@@ -0,0 +1,10 @@
using System;
using CliFx.Models;
namespace CliFx.Services
{
public interface ICommandOptionInputConverter
{
object ConvertOption(CommandOptionInput option, Type targetType);
}
}

View File

@@ -1,10 +0,0 @@
using System.Collections.Generic;
using CliFx.Models;
namespace CliFx.Services
{
public interface ICommandOptionParser
{
CommandOptionSet ParseOptions(IReadOnlyList<string> commandLineArguments);
}
}

View File

@@ -1,9 +0,0 @@
using CliFx.Models;
namespace CliFx.Services
{
public interface ICommandResolver
{
ICommand ResolveCommand(CommandOptionSet optionSet);
}
}

View File

@@ -0,0 +1,10 @@
using System.Collections.Generic;
using CliFx.Models;
namespace CliFx.Services
{
public interface ICommandSchemaResolver
{
IReadOnlyList<CommandSchema> ResolveAllSchemas();
}
}

View File

@@ -0,0 +1,11 @@
using CliFx.Models;
namespace CliFx.Services
{
public interface IConsoleWriter
{
void Write(TextSpan text);
void WriteLine(TextSpan text);
}
}

View File

@@ -0,0 +1,9 @@
using CliFx.Models;
namespace CliFx.Services
{
public interface IHelpTextBuilder
{
string Build(CommandContext context);
}
}

View File

@@ -0,0 +1,9 @@
using System;
namespace CliFx.Services
{
public interface ITypeActivator
{
object Activate(Type type);
}
}

View File

@@ -0,0 +1,9 @@
using System;
namespace CliFx.Services
{
public class TypeActivator : ITypeActivator
{
public object Activate(Type type) => Activator.CreateInstance(type);
}
}