Rework architecture again

This commit is contained in:
Alexey Golub
2019-07-25 01:14:49 +03:00
parent 2bdb2bddc8
commit d2599af90b
48 changed files with 880 additions and 664 deletions

View File

@@ -2,8 +2,9 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net45</TargetFramework> <TargetFramework>net46</TargetFramework>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<Version>1.2.3.4</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

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

View File

@@ -0,0 +1,32 @@
using System.Text;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Models;
using CliFx.Services;
namespace CliFx.Tests.Dummy.Commands
{
[Command]
public class GreeterCommand : ICommand
{
[CommandOption("target", 't', Description = "Greeting target.")]
public string Target { get; set; } = "world";
[CommandOption('e', Description = "Whether the greeting should be exclaimed.")]
public bool IsExclaimed { get; set; }
public Task ExecuteAsync(CommandContext context)
{
var buffer = new StringBuilder();
buffer.Append("Hello").Append(' ').Append(Target);
if (IsExclaimed)
buffer.Append('!');
context.Output.WriteLine(buffer.ToString());
return Task.CompletedTask;
}
}
}

View File

@@ -1,13 +1,13 @@
using System; using System;
using System.Globalization; using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Models; using CliFx.Models;
using CliFx.Services; using CliFx.Services;
namespace CliFx.Tests.Dummy.Commands namespace CliFx.Tests.Dummy.Commands
{ {
[Command("log", Description = "Calculate the logarithm of a value.")] [Command("log", Description = "Calculates the logarithm of a value.")]
public class LogCommand : Command public class LogCommand : ICommand
{ {
[CommandOption("value", 'v', IsRequired = true, Description = "Value whose logarithm is to be found.")] [CommandOption("value", 'v', IsRequired = true, Description = "Value whose logarithm is to be found.")]
public double Value { get; set; } public double Value { get; set; }
@@ -15,12 +15,12 @@ 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;
protected override ExitCode Process() public Task ExecuteAsync(CommandContext context)
{ {
var result = Math.Log(Value, Base); var result = Math.Log(Value, Base);
Output.WriteLine(result.ToString(CultureInfo.InvariantCulture)); context.Output.WriteLine(result);
return ExitCode.Success; return Task.CompletedTask;
} }
} }
} }

View File

@@ -1,24 +1,24 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Models; using CliFx.Models;
using CliFx.Services; using CliFx.Services;
namespace CliFx.Tests.Dummy.Commands namespace CliFx.Tests.Dummy.Commands
{ {
[Command("add", Description = "Calculate the sum of all input values.")] [Command("sum", Description = "Calculates the sum of all input values.")]
public class AddCommand : Command public class SumCommand : ICommand
{ {
[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; }
protected override ExitCode Process() public Task ExecuteAsync(CommandContext context)
{ {
var result = Values.Sum(); var result = Values.Sum();
Output.WriteLine(result.ToString(CultureInfo.InvariantCulture)); context.Output.WriteLine(result);
return ExitCode.Success; return Task.CompletedTask;
} }
} }
} }

View File

@@ -1,7 +1,9 @@
using System.Threading.Tasks; using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Models; using CliFx.Models;
using CliFx.Services;
using NUnit.Framework; using NUnit.Framework;
namespace CliFx.Tests namespace CliFx.Tests
@@ -9,32 +11,187 @@ namespace CliFx.Tests
public partial class CliApplicationTests public partial class CliApplicationTests
{ {
[Command] [Command]
public class TestCommand : ICommand private class TestDefaultCommand : ICommand
{ {
public static ExitCode ExitCode { get; } = new ExitCode(13); public Task ExecuteAsync(CommandContext context) => Task.CompletedTask;
}
public CommandContext Context { get; set; } [Command("command")]
private class TestNamedCommand : ICommand
{
public Task ExecuteAsync(CommandContext context) => Task.CompletedTask;
}
public Task<ExitCode> ExecuteAsync() => Task.FromResult(ExitCode); [Command("faulty-command")]
private class FaultyCommand : ICommand
{
public Task ExecuteAsync(CommandContext context) => Task.FromException(new CommandErrorException(-1337));
} }
} }
[TestFixture] [TestFixture]
public partial class CliApplicationTests public partial class CliApplicationTests
{ {
private static IEnumerable<TestCaseData> GetTestCases_RunAsync()
{
// Specified command is defined
yield return new TestCaseData(
new[] {typeof(TestNamedCommand)},
new[] {"command"}
);
yield return new TestCaseData(
new[] {typeof(TestNamedCommand)},
new[] {"command", "--help"}
);
yield return new TestCaseData(
new[] {typeof(TestNamedCommand)},
new[] {"command", "-h"}
);
yield return new TestCaseData(
new[] {typeof(TestNamedCommand)},
new[] {"command", "-?"}
);
// Default command is defined
yield return new TestCaseData(
new[] {typeof(TestDefaultCommand)},
new string[0]
);
yield return new TestCaseData(
new[] {typeof(TestDefaultCommand)},
new[] {"--version"}
);
yield return new TestCaseData(
new[] {typeof(TestDefaultCommand)},
new[] {"--help"}
);
yield return new TestCaseData(
new[] {typeof(TestDefaultCommand)},
new[] {"-h"}
);
yield return new TestCaseData(
new[] {typeof(TestDefaultCommand)},
new[] {"-?"}
);
// Default command is not defined
yield return new TestCaseData(
new Type[0],
new string[0]
);
yield return new TestCaseData(
new Type[0],
new[] {"--version"}
);
yield return new TestCaseData(
new Type[0],
new[] {"--help"}
);
yield return new TestCaseData(
new Type[0],
new[] {"-h"}
);
yield return new TestCaseData(
new Type[0],
new[] {"-?"}
);
// Specified a faulty command
yield return new TestCaseData(
new[] {typeof(FaultyCommand)},
new[] {"--version"}
);
yield return new TestCaseData(
new[] {typeof(FaultyCommand)},
new[] {"--help"}
);
yield return new TestCaseData(
new[] {typeof(FaultyCommand)},
new[] {"-h"}
);
yield return new TestCaseData(
new[] {typeof(FaultyCommand)},
new[] {"-?"}
);
}
private static IEnumerable<TestCaseData> GetTestCases_RunAsync_Negative()
{
// Specified command is not defined
yield return new TestCaseData(
new Type[0],
new[] {"command"}
);
yield return new TestCaseData(
new Type[0],
new[] {"command", "--help"}
);
yield return new TestCaseData(
new Type[0],
new[] {"command", "-h"}
);
yield return new TestCaseData(
new Type[0],
new[] {"command", "-?"}
);
// Specified a faulty command
yield return new TestCaseData(
new[] {typeof(FaultyCommand)},
new[] {"faulty-command"}
);
}
[Test] [Test]
public async Task RunAsync_Test() [TestCaseSource(nameof(GetTestCases_RunAsync))]
public async Task RunAsync_Test(IReadOnlyList<Type> commandTypes, IReadOnlyList<string> commandLineArguments)
{ {
// Arrange // Arrange
var application = new CliApplication( var application = new CliApplication(commandTypes);
new CommandInputParser(),
new CommandInitializer(new CommandSchemaResolver(new[] {typeof(TestCommand)})));
// Act // Act
var exitCodeValue = await application.RunAsync(); var exitCodeValue = await application.RunAsync(commandLineArguments);
// Assert // Assert
Assert.That(exitCodeValue, Is.EqualTo(TestCommand.ExitCode.Value), "Exit code"); Assert.That(exitCodeValue, Is.Zero, "Exit code");
}
[Test]
[TestCaseSource(nameof(GetTestCases_RunAsync_Negative))]
public async Task RunAsync_Negative_Test(IReadOnlyList<Type> commandTypes, IReadOnlyList<string> commandLineArguments)
{
// Arrange
var application = new CliApplication(commandTypes);
// Act
var exitCodeValue = await application.RunAsync(commandLineArguments);
// Assert
Assert.That(exitCodeValue, Is.Not.Zero, "Exit code");
} }
} }
} }

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net45</TargetFramework> <TargetFramework>net46</TargetFramework>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject> <IsTestProject>true</IsTestProject>
<CollectCoverage>true</CollectCoverage> <CollectCoverage>true</CollectCoverage>

View File

@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx.Models;
using CliFx.Services;
using NUnit.Framework;
namespace CliFx.Tests
{
public partial class CommandFactoryTests
{
private class TestCommand : ICommand
{
public Task ExecuteAsync(CommandContext context) => throw new NotImplementedException();
}
}
[TestFixture]
public partial class CommandFactoryTests
{
private static IEnumerable<TestCaseData> GetTestCases_CreateCommand()
{
yield return new TestCaseData(typeof(TestCommand));
}
[Test]
[TestCaseSource(nameof(GetTestCases_CreateCommand))]
public void CreateCommand_Test(Type commandType)
{
// Arrange
var factory = new CommandFactory();
// Act
var schema = new CommandSchemaResolver().GetCommandSchema(commandType);
var command = factory.CreateCommand(schema);
// Assert
Assert.That(command, Is.TypeOf(commandType));
}
}
}

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Exceptions; using CliFx.Exceptions;
@@ -10,7 +11,6 @@ namespace CliFx.Tests
{ {
public partial class CommandInitializerTests public partial class CommandInitializerTests
{ {
[Command]
public class TestCommand : ICommand public class TestCommand : ICommand
{ {
[CommandOption("int", 'i', IsRequired = true)] [CommandOption("int", 'i', IsRequired = true)]
@@ -22,9 +22,7 @@ namespace CliFx.Tests
[CommandOption("bool", 'b', GroupName = "other-group")] [CommandOption("bool", 'b', GroupName = "other-group")]
public bool BoolOption { get; set; } public bool BoolOption { get; set; }
public CommandContext Context { get; set; } public Task ExecuteAsync(CommandContext context) => throw new NotImplementedException();
public Task<ExitCode> ExecuteAsync() => throw new System.NotImplementedException();
} }
} }
@@ -94,26 +92,6 @@ namespace CliFx.Tests
); );
} }
[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() private static IEnumerable<TestCaseData> GetTestCases_InitializeCommand_IsRequired()
{ {
yield return new TestCaseData(CommandInput.Empty); yield return new TestCaseData(CommandInput.Empty);
@@ -126,15 +104,38 @@ namespace CliFx.Tests
); );
} }
[Test]
[TestCaseSource(nameof(GetTestCases_InitializeCommand))]
public void InitializeCommand_Test(CommandInput commandInput, TestCommand expectedCommand)
{
// Arrange
var initializer = new CommandInitializer();
// Act
var schema = new CommandSchemaResolver().GetCommandSchema(typeof(TestCommand));
var command = new TestCommand();
initializer.InitializeCommand(command, schema, commandInput);
// Assert
Assert.Multiple(() =>
{
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));
});
}
[Test] [Test]
[TestCaseSource(nameof(GetTestCases_InitializeCommand_IsRequired))] [TestCaseSource(nameof(GetTestCases_InitializeCommand_IsRequired))]
public void InitializeCommand_IsRequired_Test(CommandInput commandInput) public void InitializeCommand_IsRequired_Test(CommandInput commandInput)
{ {
// Arrange // Arrange
var initializer = new CommandInitializer(new CommandSchemaResolver(new[] {typeof(TestCommand)})); var initializer = new CommandInitializer();
// Act & Assert // Act & Assert
Assert.Throws<CommandResolveException>(() => initializer.InitializeCommand(commandInput)); var schema = new CommandSchemaResolver().GetCommandSchema(typeof(TestCommand));
var command = new TestCommand();
Assert.Throws<MissingCommandOptionException>(() => initializer.InitializeCommand(command, schema, commandInput));
} }
} }
} }

View File

@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Models; using CliFx.Models;
@@ -10,8 +11,10 @@ namespace CliFx.Tests
{ {
public partial class CommandSchemaResolverTests public partial class CommandSchemaResolverTests
{ {
[Command(Description = "Command description")] [Command("Command name", Description = "Command description")]
public class TestCommand : ICommand [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Local")]
private class TestCommand : ICommand
{ {
[CommandOption("option-a", 'a', GroupName = "Group 1")] [CommandOption("option-a", 'a', GroupName = "Group 1")]
public int OptionA { get; set; } public int OptionA { get; set; }
@@ -22,9 +25,7 @@ namespace CliFx.Tests
[CommandOption("option-c", Description = "Option C description")] [CommandOption("option-c", Description = "Option C description")]
public bool OptionC { get; set; } public bool OptionC { get; set; }
public CommandContext Context { get; set; } public Task ExecuteAsync(CommandContext context) => throw new NotImplementedException();
public Task<ExitCode> ExecuteAsync() => throw new NotImplementedException();
} }
} }
@@ -34,36 +35,32 @@ namespace CliFx.Tests
private static IEnumerable<TestCaseData> GetTestCases_ResolveAllSchemas() private static IEnumerable<TestCaseData> GetTestCases_ResolveAllSchemas()
{ {
yield return new TestCaseData( yield return new TestCaseData(
new[] {typeof(TestCommand)}, typeof(TestCommand),
new[] new CommandSchema(typeof(TestCommand), "Command name", "Command description",
{
new CommandSchema(typeof(TestCommand),
null, true, "Command description",
new[] new[]
{ {
new CommandOptionSchema(typeof(TestCommand).GetProperty(nameof(TestCommand.OptionA)), new CommandOptionSchema(typeof(TestCommand).GetProperty(nameof(TestCommand.OptionA)),
"option-a", 'a', false, "Group 1", null), "option-a", 'a', "Group 1", false, null),
new CommandOptionSchema(typeof(TestCommand).GetProperty(nameof(TestCommand.OptionB)), new CommandOptionSchema(typeof(TestCommand).GetProperty(nameof(TestCommand.OptionB)),
"option-b", null, true, null, null), "option-b", null, null, true, null),
new CommandOptionSchema(typeof(TestCommand).GetProperty(nameof(TestCommand.OptionC)), new CommandOptionSchema(typeof(TestCommand).GetProperty(nameof(TestCommand.OptionC)),
"option-c", null, false, null, "Option C description") "option-c", null, null, false, "Option C description")
}) })
}
); );
} }
[Test] [Test]
[TestCaseSource(nameof(GetTestCases_ResolveAllSchemas))] [TestCaseSource(nameof(GetTestCases_ResolveAllSchemas))]
public void ResolveAllSchemas_Test(IReadOnlyList<Type> sourceTypes, IReadOnlyList<CommandSchema> expectedSchemas) public void GetCommandSchema_Test(Type commandType, CommandSchema expectedSchema)
{ {
// Arrange // Arrange
var resolver = new CommandSchemaResolver(sourceTypes); var resolver = new CommandSchemaResolver();
// Act // Act
var schemas = resolver.ResolveAllSchemas(); var schema = resolver.GetCommandSchema(commandType);
// Assert // Assert
Assert.That(schemas, Is.EqualTo(expectedSchemas).Using(CommandSchemaEqualityComparer.Instance)); Assert.That(schema, Is.EqualTo(expectedSchema).Using(CommandSchemaEqualityComparer.Instance));
} }
} }
} }

View File

@@ -14,61 +14,59 @@ namespace CliFx.Tests
[Test] [Test]
[TestCase("", "Hello world")] [TestCase("", "Hello world")]
[TestCase("-t .NET", "Hello .NET")] [TestCase("-t .NET", "Hello .NET")]
[TestCase("-e", "Hello world!!!")] [TestCase("-e", "Hello world!")]
[TestCase("add -v 1 2", "3")] [TestCase("sum -v 1 2", "3")]
[TestCase("add -v 2.75 3.6 4.18", "10.53")] [TestCase("sum -v 2.75 3.6 4.18", "10.53")]
[TestCase("add -v 4 -v 16", "20")] [TestCase("sum -v 4 -v 16", "20")]
[TestCase("sum --values 2 5 --values 3", "10")]
[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 CliApplication_RunAsync_Test(string arguments, string expectedOutput) public async Task CliApplication_RunAsync_Test(string arguments, string expectedOutput)
{ {
// Act // Arrange & Act
var result = await Cli.Wrap(DummyFilePath).SetArguments(arguments).ExecuteAsync(); var result = await Cli.Wrap(DummyFilePath)
.SetArguments(arguments)
.EnableExitCodeValidation()
.EnableStandardErrorValidation()
.ExecuteAsync();
// Assert // Assert
Assert.Multiple(() =>
{
Assert.That(result.ExitCode, Is.Zero, "Exit code");
Assert.That(result.StandardOutput.Trim(), Is.EqualTo(expectedOutput), "Stdout"); Assert.That(result.StandardOutput.Trim(), Is.EqualTo(expectedOutput), "Stdout");
Assert.That(result.StandardError.Trim(), Is.Empty, "Stderr");
});
} }
[Test] [Test]
[TestCase("--version")] [TestCase("--version")]
public async Task CliApplication_RunAsync_Version_Test(string arguments) public async Task CliApplication_RunAsync_ShowVersion_Test(string arguments)
{ {
// Act // Arrange & Act
var result = await Cli.Wrap(DummyFilePath).SetArguments(arguments).ExecuteAsync(); var result = await Cli.Wrap(DummyFilePath)
.SetArguments(arguments)
.EnableExitCodeValidation()
.EnableStandardErrorValidation()
.ExecuteAsync();
// Assert // Assert
Assert.Multiple(() =>
{
Assert.That(result.ExitCode, Is.Zero, "Exit code");
Assert.That(result.StandardOutput.Trim(), Is.EqualTo(DummyVersionText), "Stdout"); Assert.That(result.StandardOutput.Trim(), Is.EqualTo(DummyVersionText), "Stdout");
Assert.That(result.StandardError.Trim(), Is.Empty, "Stderr");
});
} }
[Test] [Test]
[TestCase("--help")] [TestCase("--help")]
[TestCase("-h")] [TestCase("-h")]
[TestCase("add -h")] [TestCase("sum -h")]
[TestCase("add --help")] [TestCase("sum --help")]
[TestCase("log -h")] [TestCase("log -h")]
[TestCase("log --help")] [TestCase("log --help")]
public async Task CliApplication_RunAsync_Help_Test(string arguments) public async Task CliApplication_RunAsync_ShowHelp_Test(string arguments)
{ {
// Act // Arrange & Act
var result = await Cli.Wrap(DummyFilePath).SetArguments(arguments).ExecuteAsync(); var result = await Cli.Wrap(DummyFilePath)
.SetArguments(arguments)
.EnableExitCodeValidation()
.EnableStandardErrorValidation()
.ExecuteAsync();
// Assert // Assert
Assert.Multiple(() =>
{
Assert.That(result.ExitCode, Is.Zero, "Exit code");
Assert.That(result.StandardOutput.Trim(), Is.Not.Empty, "Stdout"); Assert.That(result.StandardOutput.Trim(), Is.Not.Empty, "Stdout");
Assert.That(result.StandardError.Trim(), Is.Empty, "Stderr");
});
} }
} }
} }

View File

@@ -10,8 +10,6 @@ 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;

View File

@@ -9,10 +9,10 @@ namespace CliFx.Attributes
public char? ShortName { get; } public char? ShortName { get; }
public bool IsRequired { get; set; }
public string GroupName { get; set; } public string GroupName { get; set; }
public bool IsRequired { 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,33 +1,137 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Exceptions;
using CliFx.Internal;
using CliFx.Models;
using CliFx.Services; using CliFx.Services;
namespace CliFx namespace CliFx
{ {
public class CliApplication : ICliApplication public partial class CliApplication : ICliApplication
{ {
private readonly IReadOnlyList<Type> _commandTypes;
private readonly ICommandInputParser _commandInputParser; private readonly ICommandInputParser _commandInputParser;
private readonly ICommandSchemaResolver _commandSchemaResolver;
private readonly ICommandFactory _commandFactory;
private readonly ICommandInitializer _commandInitializer; private readonly ICommandInitializer _commandInitializer;
private readonly ICommandHelpTextBuilder _commandHelpTextBuilder;
public CliApplication(ICommandInputParser commandInputParser, ICommandInitializer commandInitializer) public CliApplication(IReadOnlyList<Type> commandTypes,
ICommandInputParser commandInputParser, ICommandSchemaResolver commandSchemaResolver,
ICommandFactory commandFactory, ICommandInitializer commandInitializer, ICommandHelpTextBuilder commandHelpTextBuilder)
{ {
_commandTypes = commandTypes;
_commandInputParser = commandInputParser; _commandInputParser = commandInputParser;
_commandSchemaResolver = commandSchemaResolver;
_commandFactory = commandFactory;
_commandInitializer = commandInitializer; _commandInitializer = commandInitializer;
_commandHelpTextBuilder = commandHelpTextBuilder;
}
public CliApplication(IReadOnlyList<Type> commandTypes)
: this(commandTypes,
new CommandInputParser(), new CommandSchemaResolver(), new CommandFactory(),
new CommandInitializer(), new CommandHelpTextBuilder())
{
} }
public CliApplication() public CliApplication()
: this(new CommandInputParser(), new CommandInitializer()) : this(GetDefaultCommandTypes())
{ {
} }
public async Task<int> RunAsync(IReadOnlyList<string> commandLineArguments) public async Task<int> RunAsync(IReadOnlyList<string> commandLineArguments)
{ {
var input = _commandInputParser.ParseInput(commandLineArguments); var stdOut = ConsoleWriter.GetStandardOutput();
var command = _commandInitializer.InitializeCommand(input); var stdErr = ConsoleWriter.GetStandardError();
var exitCode = await command.ExecuteAsync(); try
{
var commandInput = _commandInputParser.ParseInput(commandLineArguments);
return exitCode.Value; var availableCommandSchemas = _commandSchemaResolver.GetCommandSchemas(_commandTypes);
var matchingCommandSchema = availableCommandSchemas.FindByNameOrNull(commandInput.CommandName);
// Fail if specified a command which is not defined
if (commandInput.IsCommandSpecified() && matchingCommandSchema == null)
{
stdErr.WriteLine($"Specified command [{commandInput.CommandName}] doesn't exist.");
return -1;
}
// Show version if it was requested without specifying a command
if (commandInput.IsVersionRequested() && !commandInput.IsCommandSpecified())
{
var versionText = Assembly.GetEntryAssembly()?.GetName().Version.ToString();
stdOut.WriteLine(versionText);
return 0;
}
// Use a stub if command was not specified but there is no default command defined
if (matchingCommandSchema == null)
{
matchingCommandSchema = _commandSchemaResolver.GetCommandSchema(typeof(StubDefaultCommand));
}
// Show help if it was requested
if (commandInput.IsHelpRequested())
{
var helpText = _commandHelpTextBuilder.Build(availableCommandSchemas, matchingCommandSchema);
stdOut.WriteLine(helpText);
return 0;
}
// Create an instance of the command
var command = matchingCommandSchema.Type == typeof(StubDefaultCommand)
? new StubDefaultCommand(_commandHelpTextBuilder)
: _commandFactory.CreateCommand(matchingCommandSchema);
// Populate command with options according to its schema
_commandInitializer.InitializeCommand(command, matchingCommandSchema, commandInput);
// Create context and execute command
var commandContext = new CommandContext(commandInput, availableCommandSchemas, matchingCommandSchema, stdOut, stdErr);
await command.ExecuteAsync(commandContext);
return 0;
}
catch (Exception ex)
{
stdErr.WriteLine(ex.ToString());
return ex is CommandErrorException errorException ? errorException.ExitCode : -1;
}
finally
{
stdOut.Dispose();
stdErr.Dispose();
}
}
}
public partial class CliApplication
{
private static IReadOnlyList<Type> GetDefaultCommandTypes() =>
Assembly.GetEntryAssembly()?.ExportedTypes.Where(t => t.Implements(typeof(ICommand))).ToArray() ??
Type.EmptyTypes;
private sealed class StubDefaultCommand : ICommand
{
private readonly ICommandHelpTextBuilder _commandHelpTextBuilder;
public StubDefaultCommand(ICommandHelpTextBuilder commandHelpTextBuilder)
{
_commandHelpTextBuilder = commandHelpTextBuilder;
}
public Task ExecuteAsync(CommandContext context)
{
var helpText = _commandHelpTextBuilder.Build(context.AvailableCommandSchemas, context.MatchingCommandSchema);
context.Output.WriteLine(helpText);
return Task.CompletedTask;
}
} }
} }
} }

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFrameworks>net45;netstandard2.0</TargetFrameworks> <TargetFrameworks>net46;netstandard2.0</TargetFrameworks>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<Version>0.0.1</Version> <Version>0.0.1</Version>
<Company>Tyrrrz</Company> <Company>Tyrrrz</Company>

View File

@@ -1,59 +0,0 @@
using System;
using System.Reflection;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Models;
using CliFx.Services;
namespace CliFx
{
public abstract class Command : ICommand
{
[CommandOption("help", 'h', GroupName = "__help", Description = "Shows help.")]
public bool IsHelpRequested { get; set; }
[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

@@ -0,0 +1,21 @@
using System;
namespace CliFx.Exceptions
{
public class CannotConvertCommandOptionException : Exception
{
public CannotConvertCommandOptionException()
{
}
public CannotConvertCommandOptionException(string message)
: base(message)
{
}
public CannotConvertCommandOptionException(string message, Exception innerException)
: base(message, innerException)
{
}
}
}

View File

@@ -0,0 +1,30 @@
using System;
namespace CliFx.Exceptions
{
public class CommandErrorException : Exception
{
public int ExitCode { get; }
public CommandErrorException(int exitCode, string message, Exception innerException)
: base(message, innerException)
{
ExitCode = exitCode;
}
public CommandErrorException(int exitCode, Exception innerException)
: this(exitCode, null, innerException)
{
}
public CommandErrorException(int exitCode, string message)
: this(exitCode, message, null)
{
}
public CommandErrorException(int exitCode)
: this(exitCode, null, null)
{
}
}
}

View File

@@ -1,21 +0,0 @@
using System;
namespace CliFx.Exceptions
{
public class CommandOptionConvertException : Exception
{
public CommandOptionConvertException()
{
}
public CommandOptionConvertException(string message)
: base(message)
{
}
public CommandOptionConvertException(string message, Exception innerException)
: base(message, innerException)
{
}
}
}

View File

@@ -1,21 +0,0 @@
using System;
namespace CliFx.Exceptions
{
public class CommandResolveException : Exception
{
public CommandResolveException()
{
}
public CommandResolveException(string message)
: base(message)
{
}
public CommandResolveException(string message, Exception innerException)
: base(message, innerException)
{
}
}
}

View File

@@ -0,0 +1,21 @@
using System;
namespace CliFx.Exceptions
{
public class MissingCommandOptionException : Exception
{
public MissingCommandOptionException()
{
}
public MissingCommandOptionException(string message)
: base(message)
{
}
public MissingCommandOptionException(string message, Exception innerException)
: base(message, innerException)
{
}
}
}

View File

@@ -1,9 +0,0 @@
using System.Threading.Tasks;
namespace CliFx
{
public static class Extensions
{
public static Task<int> RunAsync(this ICliApplication application) => application.RunAsync(new string[0]);
}
}

View File

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

View File

@@ -11,49 +11,12 @@ namespace CliFx.Internal
public static string AsString(this char c) => new string(c, 1); public static string AsString(this char c) => new string(c, 1);
public static string SubstringUntil(this string s, string sub, StringComparison comparison = StringComparison.Ordinal) public static string JoinToString<T>(this IEnumerable<T> source, string separator) => string.Join(separator, source);
{
var index = s.IndexOf(sub, comparison);
return index < 0 ? s : s.Substring(0, index);
}
public static string SubstringAfter(this string s, string sub, StringComparison comparison = StringComparison.Ordinal)
{
var index = s.IndexOf(sub, comparison);
return index < 0 ? string.Empty : s.Substring(index + sub.Length, s.Length - index - sub.Length);
}
public static TValue GetValueOrDefault<TKey, TValue>(this IReadOnlyDictionary<TKey, TValue> dic, TKey key) => public static TValue GetValueOrDefault<TKey, TValue>(this IReadOnlyDictionary<TKey, TValue> dic, TKey key) =>
dic.TryGetValue(key, out var result) ? result : default; dic.TryGetValue(key, out var result) ? result : default;
public static string TrimStart(this string s, string sub, StringComparison comparison = StringComparison.Ordinal) public static IEnumerable<T> ExceptNull<T>(this IEnumerable<T> source) where T : class => source.Where(i => i != null);
{
while (s.StartsWith(sub, comparison))
s = s.Substring(sub.Length);
return s;
}
public static string TrimEnd(this string s, string sub, StringComparison comparison = StringComparison.Ordinal)
{
while (s.EndsWith(sub, comparison))
s = s.Substring(0, s.Length - sub.Length);
return s;
}
public static string JoinToString<T>(this IEnumerable<T> source, string separator) => string.Join(separator, source);
public static bool IsDerivedFrom(this Type type, Type baseType)
{
for (var currentType = type; currentType != null; currentType = currentType.BaseType)
{
if (currentType == baseType)
return true;
}
return false;
}
public static bool IsEnumerable(this Type type) => public static bool IsEnumerable(this Type type) =>
type == typeof(IEnumerable) || type.GetInterfaces().Contains(typeof(IEnumerable)); type == typeof(IEnumerable) || type.GetInterfaces().Contains(typeof(IEnumerable));
@@ -79,5 +42,7 @@ namespace CliFx.Internal
return array; return array;
} }
public static bool Implements(this Type type, Type interfaceType) => type.GetInterfaces().Contains(interfaceType);
} }
} }

View File

@@ -1,17 +1,29 @@
using System.Collections.Generic; using System.Collections.Generic;
using CliFx.Services;
namespace CliFx.Models namespace CliFx.Models
{ {
public class CommandContext public class CommandContext
{ {
public CommandInput CommandInput { get; }
public IReadOnlyList<CommandSchema> AvailableCommandSchemas { get; } public IReadOnlyList<CommandSchema> AvailableCommandSchemas { get; }
public CommandSchema CommandSchema { get; } public CommandSchema MatchingCommandSchema { get; }
public CommandContext(IReadOnlyList<CommandSchema> availableCommandSchemas, CommandSchema commandSchema) public IConsoleWriter Output { get; }
public IConsoleWriter Error { get; }
public CommandContext(CommandInput commandInput,
IReadOnlyList<CommandSchema> availableCommandSchemas, CommandSchema matchingCommandSchema,
IConsoleWriter output, IConsoleWriter error)
{ {
CommandInput = commandInput;
AvailableCommandSchemas = availableCommandSchemas; AvailableCommandSchemas = availableCommandSchemas;
CommandSchema = commandSchema; MatchingCommandSchema = matchingCommandSchema;
Output = output;
Error = error;
} }
} }
} }

View File

@@ -50,7 +50,7 @@ namespace CliFx.Models
foreach (var option in Options) foreach (var option in Options)
{ {
buffer.Append(option.Name); buffer.Append(option.Alias);
} }
buffer.Append(']'); buffer.Append(']');

View File

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

View File

@@ -16,13 +16,13 @@ namespace CliFx.Models
if (x is null || y is null) if (x is null || y is null)
return false; return false;
return StringComparer.OrdinalIgnoreCase.Equals(x.Name, y.Name) && return StringComparer.OrdinalIgnoreCase.Equals(x.Alias, y.Alias) &&
x.Values.SequenceEqual(y.Values, StringComparer.Ordinal); x.Values.SequenceEqual(y.Values, StringComparer.Ordinal);
} }
/// <inheritdoc /> /// <inheritdoc />
public int GetHashCode(CommandOptionInput obj) => new HashCodeBuilder() public int GetHashCode(CommandOptionInput obj) => new HashCodeBuilder()
.Add(obj.Name, StringComparer.OrdinalIgnoreCase) .Add(obj.Alias, StringComparer.OrdinalIgnoreCase)
.AddMany(obj.Values, StringComparer.Ordinal) .AddMany(obj.Values, StringComparer.Ordinal)
.Build(); .Build();
} }

View File

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

View File

@@ -9,17 +9,14 @@ namespace CliFx.Models
public string Name { get; } public string Name { get; }
public bool IsDefault { get; }
public string Description { get; } public string Description { get; }
public IReadOnlyList<CommandOptionSchema> Options { get; } public IReadOnlyList<CommandOptionSchema> Options { get; }
public CommandSchema(Type type, string name, bool isDefault, string description, IReadOnlyList<CommandOptionSchema> options) public CommandSchema(Type type, string name, string description, IReadOnlyList<CommandOptionSchema> options)
{ {
Type = type; Type = type;
Name = name; Name = name;
IsDefault = isDefault;
Description = description; Description = description;
Options = options; Options = options;
} }

View File

@@ -18,7 +18,6 @@ namespace CliFx.Models
return x.Type == y.Type && return x.Type == y.Type &&
StringComparer.OrdinalIgnoreCase.Equals(x.Name, y.Name) && StringComparer.OrdinalIgnoreCase.Equals(x.Name, y.Name) &&
x.IsDefault == y.IsDefault &&
StringComparer.Ordinal.Equals(x.Description, y.Description) && StringComparer.Ordinal.Equals(x.Description, y.Description) &&
x.Options.SequenceEqual(y.Options, CommandOptionSchemaEqualityComparer.Instance); x.Options.SequenceEqual(y.Options, CommandOptionSchemaEqualityComparer.Instance);
} }
@@ -27,7 +26,6 @@ namespace CliFx.Models
public int GetHashCode(CommandSchema obj) => new HashCodeBuilder() public int GetHashCode(CommandSchema obj) => new HashCodeBuilder()
.Add(obj.Type) .Add(obj.Type)
.Add(obj.Name, StringComparer.OrdinalIgnoreCase) .Add(obj.Name, StringComparer.OrdinalIgnoreCase)
.Add(obj.IsDefault)
.Add(obj.Description, StringComparer.Ordinal) .Add(obj.Description, StringComparer.Ordinal)
.AddMany(obj.Options, CommandOptionSchemaEqualityComparer.Instance) .AddMany(obj.Options, CommandOptionSchemaEqualityComparer.Instance)
.Build(); .Build();

View File

@@ -1,26 +0,0 @@
using System.Globalization;
namespace CliFx.Models
{
public partial class ExitCode
{
public int Value { get; }
public string Message { get; }
public bool IsSuccess => Value == 0;
public ExitCode(int value, string message = null)
{
Value = value;
Message = message;
}
public override string ToString() => Value.ToString(CultureInfo.InvariantCulture);
}
public partial class ExitCode
{
public static ExitCode Success { get; } = new ExitCode(0);
}
}

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using CliFx.Internal; using CliFx.Internal;
@@ -6,16 +7,66 @@ namespace CliFx.Models
{ {
public static class Extensions public static class Extensions
{ {
public static CommandOptionInput GetOptionOrDefault(this CommandInput set, string name, char? shortName) => public static bool IsCommandSpecified(this CommandInput commandInput) => !commandInput.CommandName.IsNullOrWhiteSpace();
set.Options.FirstOrDefault(o =>
public static bool IsEmpty(this CommandInput commandInput) => !commandInput.IsCommandSpecified() && !commandInput.Options.Any();
public static bool IsHelpOption(this CommandOptionInput optionInput) =>
string.Equals(optionInput.Alias, "help", StringComparison.OrdinalIgnoreCase) ||
string.Equals(optionInput.Alias, "h", StringComparison.OrdinalIgnoreCase) ||
string.Equals(optionInput.Alias, "?", StringComparison.OrdinalIgnoreCase);
public static bool IsVersionOption(this CommandOptionInput optionInput) =>
string.Equals(optionInput.Alias, "version", StringComparison.OrdinalIgnoreCase);
public static bool IsHelpRequested(this CommandInput commandInput) => commandInput.Options.Any(o => o.IsHelpOption());
public static bool IsVersionRequested(this CommandInput commandInput) => commandInput.Options.Any(o => o.IsVersionOption());
public static bool IsDefault(this CommandSchema commandSchema) => commandSchema.Name.IsNullOrWhiteSpace();
public static CommandSchema FindByNameOrNull(this IEnumerable<CommandSchema> commandSchemas, string name) =>
commandSchemas.FirstOrDefault(c => string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase));
public static IReadOnlyList<CommandSchema> FindSubCommandSchemas(this IEnumerable<CommandSchema> commandSchemas,
string parentName)
{ {
if (!name.IsNullOrWhiteSpace() && string.Equals(o.Name, name, StringComparison.Ordinal)) // For a command with no name, every other command is its subcommand
return true; if (parentName.IsNullOrWhiteSpace())
return commandSchemas.Where(c => !c.Name.IsNullOrWhiteSpace()).ToArray();
if (shortName != null && o.Name.Length == 1 && o.Name.Single() == shortName) // For a named command, commands that are prefixed by its name are its subcommands
return true; return commandSchemas.Where(c => !c.Name.IsNullOrWhiteSpace())
.Where(c => c.Name.StartsWith(parentName + " ", StringComparison.OrdinalIgnoreCase))
.ToArray();
}
public static CommandOptionSchema FindByAliasOrNull(this IEnumerable<CommandOptionSchema> optionSchemas, string alias) =>
optionSchemas.FirstOrDefault(o => o.GetAliases().Contains(alias, StringComparer.OrdinalIgnoreCase));
return false; public static IReadOnlyList<string> GetAliases(this CommandOptionSchema optionSchema)
}); {
var result = new List<string>();
if (!optionSchema.Name.IsNullOrWhiteSpace())
result.Add(optionSchema.Name);
if (optionSchema.ShortName != null)
result.Add(optionSchema.ShortName.Value.AsString());
return result;
}
public static IReadOnlyList<string> GetAliasesWithPrefixes(this CommandOptionSchema optionSchema)
{
var result = new List<string>();
if (!optionSchema.Name.IsNullOrWhiteSpace())
result.Add("--" + optionSchema.Name);
if (optionSchema.ShortName != null)
result.Add("-" + optionSchema.ShortName.Value.AsString());
return result;
}
} }
} }

View File

@@ -0,0 +1,10 @@
using System;
using CliFx.Models;
namespace CliFx.Services
{
public class CommandFactory : ICommandFactory
{
public ICommand CreateCommand(CommandSchema schema) => (ICommand) Activator.CreateInstance(schema.Type);
}
}

View File

@@ -0,0 +1,141 @@
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 CommandHelpTextBuilder : ICommandHelpTextBuilder
{
// TODO: move to context?
private string GetExeName() => Path.GetFileNameWithoutExtension(Assembly.GetEntryAssembly()?.Location);
private void AddDescription(StringBuilder buffer, CommandSchema commands)
{
if (commands.Description.IsNullOrWhiteSpace())
return;
buffer.AppendLine("Description:");
buffer.Append(" ");
buffer.AppendLine(commands.Description);
buffer.AppendLine();
}
private void AddUsage(StringBuilder buffer, CommandSchema command, IReadOnlyList<CommandSchema> subCommands)
{
buffer.AppendLine("Usage:");
buffer.Append(" ");
buffer.Append(GetExeName());
if (!command.Name.IsNullOrWhiteSpace())
{
buffer.Append(' ');
buffer.Append(command.Name);
}
if (subCommands.Any())
{
buffer.Append(' ');
buffer.Append("[command]");
}
if (command.Options.Any())
{
buffer.Append(' ');
buffer.Append("[options]");
}
buffer.AppendLine().AppendLine();
}
private void AddOptions(StringBuilder buffer, CommandSchema command)
{
if (!command.Options.Any())
return;
buffer.AppendLine("Options:");
foreach (var option in command.Options)
{
buffer.Append(option.IsRequired ? "* " : " ");
buffer.Append(option.GetAliasesWithPrefixes().JoinToString("|"));
if (!option.Description.IsNullOrWhiteSpace())
{
buffer.Append(" ");
buffer.Append(option.Description);
}
buffer.AppendLine();
}
// Help option
{
buffer.Append(" ");
buffer.Append("--help|-h");
buffer.Append(" ");
buffer.Append("Shows helps text.");
buffer.AppendLine();
}
// Version option
if (command.IsDefault())
{
buffer.Append(" ");
buffer.Append("--version");
buffer.Append(" ");
buffer.Append("Shows application version.");
buffer.AppendLine();
}
buffer.AppendLine();
}
private void AddSubCommands(StringBuilder buffer, IReadOnlyList<CommandSchema> subCommands)
{
if (!subCommands.Any())
return;
buffer.AppendLine("Commands:");
foreach (var command in subCommands)
{
buffer.Append(" ");
buffer.Append(command.Name);
if (!command.Description.IsNullOrWhiteSpace())
{
buffer.Append(" ");
buffer.Append(command.Description);
}
buffer.AppendLine();
}
buffer.AppendLine();
}
public string Build(IReadOnlyList<CommandSchema> commandSchemas, CommandSchema commandSchema)
{
var buffer = new StringBuilder();
var subCommands = commandSchemas.FindSubCommandSchemas(commandSchema.Name);
AddDescription(buffer, commandSchema);
AddUsage(buffer, commandSchema, subCommands);
AddOptions(buffer, commandSchema);
AddSubCommands(buffer, subCommands);
return buffer.ToString().Trim();
}
}
}

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 CliFx.Attributes;
using CliFx.Exceptions; using CliFx.Exceptions;
using CliFx.Internal; using CliFx.Internal;
using CliFx.Models; using CliFx.Models;
@@ -10,115 +9,44 @@ namespace CliFx.Services
{ {
public class CommandInitializer : ICommandInitializer public class CommandInitializer : ICommandInitializer
{ {
private readonly ITypeActivator _typeActivator;
private readonly ICommandSchemaResolver _commandSchemaResolver;
private readonly ICommandOptionInputConverter _commandOptionInputConverter; private readonly ICommandOptionInputConverter _commandOptionInputConverter;
public CommandInitializer(ITypeActivator typeActivator, ICommandSchemaResolver commandSchemaResolver, public CommandInitializer(ICommandOptionInputConverter commandOptionInputConverter)
ICommandOptionInputConverter commandOptionInputConverter)
{ {
_typeActivator = typeActivator;
_commandSchemaResolver = commandSchemaResolver;
_commandOptionInputConverter = commandOptionInputConverter; _commandOptionInputConverter = commandOptionInputConverter;
} }
public CommandInitializer(ICommandSchemaResolver commandSchemaResolver)
: this(new TypeActivator(), commandSchemaResolver, new CommandOptionInputConverter())
{
}
public CommandInitializer() public CommandInitializer()
: this(new CommandSchemaResolver()) : this(new CommandOptionInputConverter())
{ {
} }
private CommandSchema GetDefaultSchema(IReadOnlyList<CommandSchema> schemas) public void InitializeCommand(ICommand command, CommandSchema schema, CommandInput input)
{ {
// 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 // Set command options
var isGroupNameDetected = false; var isGroupNameDetected = false;
var groupName = default(string); var groupName = default(string);
var properties = new HashSet<CommandOptionSchema>(); var properties = new HashSet<CommandOptionSchema>();
foreach (var option in input.Options) foreach (var option in input.Options)
{ {
var optionInfo = schema.Options.FirstOrDefault(p => var optionSchema = schema.Options.FindByAliasOrNull(option.Alias);
string.Equals(p.Name, option.Name, StringComparison.OrdinalIgnoreCase) ||
string.Equals(p.ShortName?.AsString(), option.Name, StringComparison.OrdinalIgnoreCase));
if (optionInfo == null) if (optionSchema == null)
continue; continue;
if (isGroupNameDetected && !string.Equals(groupName, optionInfo.GroupName, StringComparison.OrdinalIgnoreCase)) if (isGroupNameDetected && !string.Equals(groupName, optionSchema.GroupName, StringComparison.OrdinalIgnoreCase))
continue; continue;
if (!isGroupNameDetected) if (!isGroupNameDetected)
{ {
groupName = optionInfo.GroupName; groupName = optionSchema.GroupName;
isGroupNameDetected = true; isGroupNameDetected = true;
} }
var convertedValue = _commandOptionInputConverter.ConvertOption(option, optionInfo.Property.PropertyType); var convertedValue = _commandOptionInputConverter.ConvertOption(option, optionSchema.Property.PropertyType);
optionInfo.Property.SetValue(command, convertedValue); optionSchema.Property.SetValue(command, convertedValue);
properties.Add(optionInfo); properties.Add(optionSchema);
} }
var unsetRequiredOptions = schema.Options var unsetRequiredOptions = schema.Options
@@ -128,10 +56,8 @@ namespace CliFx.Services
.ToArray(); .ToArray();
if (unsetRequiredOptions.Any()) if (unsetRequiredOptions.Any())
throw new CommandResolveException( throw new MissingCommandOptionException(
$"Can't resolve command because one or more required properties were not set: {unsetRequiredOptions.Select(p => p.Name).JoinToString(", ")}"); $"Can't resolve command because one or more required properties were not set: {unsetRequiredOptions.Select(p => p.Name).JoinToString(", ")}");
return command;
} }
} }
} }

View File

@@ -39,7 +39,7 @@ namespace CliFx.Services
if (bool.TryParse(value, out var result)) if (bool.TryParse(value, out var result))
return result; return result;
throw new CommandOptionConvertException($"Can't convert value [{value}] to boolean."); throw new CannotConvertCommandOptionException($"Can't convert value [{value}] to boolean.");
} }
// Char // Char
@@ -48,7 +48,7 @@ namespace CliFx.Services
if (value.Length == 1) if (value.Length == 1)
return value[0]; return value[0];
throw new CommandOptionConvertException( throw new CannotConvertCommandOptionException(
$"Can't convert value [{value}] to char. The value is either empty or longer than one character."); $"Can't convert value [{value}] to char. The value is either empty or longer than one character.");
} }
@@ -58,7 +58,7 @@ namespace CliFx.Services
if (sbyte.TryParse(value, NumberStyles.Integer, _formatProvider, out var result)) if (sbyte.TryParse(value, NumberStyles.Integer, _formatProvider, out var result))
return result; return result;
throw new CommandOptionConvertException($"Can't convert value [{value}] to sbyte."); throw new CannotConvertCommandOptionException($"Can't convert value [{value}] to sbyte.");
} }
// Byte // Byte
@@ -67,7 +67,7 @@ namespace CliFx.Services
if (byte.TryParse(value, NumberStyles.Integer, _formatProvider, out var result)) if (byte.TryParse(value, NumberStyles.Integer, _formatProvider, out var result))
return result; return result;
throw new CommandOptionConvertException($"Can't convert value [{value}] to byte."); throw new CannotConvertCommandOptionException($"Can't convert value [{value}] to byte.");
} }
// Short // Short
@@ -76,7 +76,7 @@ namespace CliFx.Services
if (short.TryParse(value, NumberStyles.Integer, _formatProvider, out var result)) if (short.TryParse(value, NumberStyles.Integer, _formatProvider, out var result))
return result; return result;
throw new CommandOptionConvertException($"Can't convert value [{value}] to short."); throw new CannotConvertCommandOptionException($"Can't convert value [{value}] to short.");
} }
// Ushort // Ushort
@@ -85,7 +85,7 @@ namespace CliFx.Services
if (ushort.TryParse(value, NumberStyles.Integer, _formatProvider, out var result)) if (ushort.TryParse(value, NumberStyles.Integer, _formatProvider, out var result))
return result; return result;
throw new CommandOptionConvertException($"Can't convert value [{value}] to ushort."); throw new CannotConvertCommandOptionException($"Can't convert value [{value}] to ushort.");
} }
// Int // Int
@@ -94,7 +94,7 @@ namespace CliFx.Services
if (int.TryParse(value, NumberStyles.Integer, _formatProvider, out var result)) if (int.TryParse(value, NumberStyles.Integer, _formatProvider, out var result))
return result; return result;
throw new CommandOptionConvertException($"Can't convert value [{value}] to int."); throw new CannotConvertCommandOptionException($"Can't convert value [{value}] to int.");
} }
// Uint // Uint
@@ -103,7 +103,7 @@ namespace CliFx.Services
if (uint.TryParse(value, NumberStyles.Integer, _formatProvider, out var result)) if (uint.TryParse(value, NumberStyles.Integer, _formatProvider, out var result))
return result; return result;
throw new CommandOptionConvertException($"Can't convert value [{value}] to uint."); throw new CannotConvertCommandOptionException($"Can't convert value [{value}] to uint.");
} }
// Long // Long
@@ -112,7 +112,7 @@ namespace CliFx.Services
if (long.TryParse(value, NumberStyles.Integer, _formatProvider, out var result)) if (long.TryParse(value, NumberStyles.Integer, _formatProvider, out var result))
return result; return result;
throw new CommandOptionConvertException($"Can't convert value [{value}] to long."); throw new CannotConvertCommandOptionException($"Can't convert value [{value}] to long.");
} }
// Ulong // Ulong
@@ -121,7 +121,7 @@ namespace CliFx.Services
if (ulong.TryParse(value, NumberStyles.Integer, _formatProvider, out var result)) if (ulong.TryParse(value, NumberStyles.Integer, _formatProvider, out var result))
return result; return result;
throw new CommandOptionConvertException($"Can't convert value [{value}] to ulong."); throw new CannotConvertCommandOptionException($"Can't convert value [{value}] to ulong.");
} }
// Float // Float
@@ -130,7 +130,7 @@ namespace CliFx.Services
if (float.TryParse(value, NumberStyles.Float | NumberStyles.AllowThousands, _formatProvider, out var result)) if (float.TryParse(value, NumberStyles.Float | NumberStyles.AllowThousands, _formatProvider, out var result))
return result; return result;
throw new CommandOptionConvertException($"Can't convert value [{value}] to float."); throw new CannotConvertCommandOptionException($"Can't convert value [{value}] to float.");
} }
// Double // Double
@@ -139,7 +139,7 @@ namespace CliFx.Services
if (double.TryParse(value, NumberStyles.Float | NumberStyles.AllowThousands, _formatProvider, out var result)) if (double.TryParse(value, NumberStyles.Float | NumberStyles.AllowThousands, _formatProvider, out var result))
return result; return result;
throw new CommandOptionConvertException($"Can't convert value [{value}] to double."); throw new CannotConvertCommandOptionException($"Can't convert value [{value}] to double.");
} }
// Decimal // Decimal
@@ -148,7 +148,7 @@ namespace CliFx.Services
if (decimal.TryParse(value, NumberStyles.Number, _formatProvider, out var result)) if (decimal.TryParse(value, NumberStyles.Number, _formatProvider, out var result))
return result; return result;
throw new CommandOptionConvertException($"Can't convert value [{value}] to decimal."); throw new CannotConvertCommandOptionException($"Can't convert value [{value}] to decimal.");
} }
// DateTime // DateTime
@@ -157,7 +157,7 @@ namespace CliFx.Services
if (DateTime.TryParse(value, _formatProvider, DateTimeStyles.None, out var result)) if (DateTime.TryParse(value, _formatProvider, DateTimeStyles.None, out var result))
return result; return result;
throw new CommandOptionConvertException($"Can't convert value [{value}] to DateTime."); throw new CannotConvertCommandOptionException($"Can't convert value [{value}] to DateTime.");
} }
// DateTimeOffset // DateTimeOffset
@@ -166,7 +166,7 @@ namespace CliFx.Services
if (DateTimeOffset.TryParse(value, _formatProvider, DateTimeStyles.None, out var result)) if (DateTimeOffset.TryParse(value, _formatProvider, DateTimeStyles.None, out var result))
return result; return result;
throw new CommandOptionConvertException($"Can't convert value [{value}] to DateTimeOffset."); throw new CannotConvertCommandOptionException($"Can't convert value [{value}] to DateTimeOffset.");
} }
// TimeSpan // TimeSpan
@@ -175,7 +175,7 @@ namespace CliFx.Services
if (TimeSpan.TryParse(value, _formatProvider, out var result)) if (TimeSpan.TryParse(value, _formatProvider, out var result))
return result; return result;
throw new CommandOptionConvertException($"Can't convert value [{value}] to TimeSpan."); throw new CannotConvertCommandOptionException($"Can't convert value [{value}] to TimeSpan.");
} }
// Enum // Enum
@@ -184,7 +184,7 @@ namespace CliFx.Services
if (Enum.GetNames(targetType).Contains(value, StringComparer.OrdinalIgnoreCase)) if (Enum.GetNames(targetType).Contains(value, StringComparer.OrdinalIgnoreCase))
return Enum.Parse(targetType, value, true); return Enum.Parse(targetType, value, true);
throw new CommandOptionConvertException( throw new CannotConvertCommandOptionException(
$"Can't convert value [{value}] to [{targetType}]. The value is not defined on the enum."); $"Can't convert value [{value}] to [{targetType}]. The value is not defined on the enum.");
} }
@@ -213,7 +213,7 @@ namespace CliFx.Services
} }
// Unknown type // Unknown type
throw new CommandOptionConvertException($"Can't convert value [{value}] to unrecognized type [{targetType}]."); throw new CannotConvertCommandOptionException($"Can't convert value [{value}] to unrecognized type [{targetType}].");
} }
// TODO: refactor this // TODO: refactor this
@@ -226,7 +226,7 @@ namespace CliFx.Services
if (targetType.IsAssignableFrom(underlyingType.MakeArrayType())) if (targetType.IsAssignableFrom(underlyingType.MakeArrayType()))
return option.Values.Select(v => ConvertValue(v, underlyingType)).ToArray().ToNonGenericArray(underlyingType); return option.Values.Select(v => ConvertValue(v, underlyingType)).ToArray().ToNonGenericArray(underlyingType);
throw new CommandOptionConvertException( throw new CannotConvertCommandOptionException(
$"Can't convert sequence of values [{option.Values.JoinToString(", ")}] to type [{targetType}]."); $"Can't convert sequence of values [{option.Values.JoinToString(", ")}] to type [{targetType}].");
} }
else if (option.Values.Count <= 1) else if (option.Values.Count <= 1)
@@ -239,7 +239,7 @@ namespace CliFx.Services
else else
{ {
// TODO: better exception // TODO: better exception
throw new CommandOptionConvertException( throw new CannotConvertCommandOptionException(
$"Can't convert sequence of values [{option.Values.JoinToString(", ")}] to type [{targetType}]."); $"Can't convert sequence of values [{option.Values.JoinToString(", ")}] to type [{targetType}].");
} }
} }

View File

@@ -1,74 +1,42 @@
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Internal;
using CliFx.Models; using CliFx.Models;
namespace CliFx.Services namespace CliFx.Services
{ {
public class CommandSchemaResolver : ICommandSchemaResolver public class CommandSchemaResolver : ICommandSchemaResolver
{ {
private readonly IReadOnlyList<Type> _sourceTypes; private CommandOptionSchema GetCommandOptionSchema(PropertyInfo optionProperty)
public CommandSchemaResolver(IReadOnlyList<Type> sourceTypes)
{ {
_sourceTypes = sourceTypes; var attribute = optionProperty.GetCustomAttribute<CommandOptionAttribute>();
if (attribute == null)
return null;
return new CommandOptionSchema(optionProperty,
attribute.Name,
attribute.ShortName,
attribute.GroupName,
attribute.IsRequired, attribute.Description);
} }
public CommandSchemaResolver(IReadOnlyList<Assembly> sourceAssemblies) // TODO: validate stuff like duplicate names, multiple default commands, etc
: this(sourceAssemblies.SelectMany(a => a.ExportedTypes).ToArray()) public CommandSchema GetCommandSchema(Type commandType)
{ {
} if (!commandType.Implements(typeof(ICommand)))
throw new ArgumentException($"Command type must implement {nameof(ICommand)}.", nameof(commandType));
public CommandSchemaResolver() var attribute = commandType.GetCustomAttribute<CommandAttribute>();
: this(new[] {Assembly.GetEntryAssembly()})
{
}
private IEnumerable<Type> GetCommandTypes() => _sourceTypes.Where(t => t.GetInterfaces().Contains(typeof(ICommand))); var options = commandType.GetProperties().Select(GetCommandOptionSchema).ExceptNull().ToArray();
private IReadOnlyList<CommandOptionSchema> GetCommandOptionSchemas(Type commandType) return new CommandSchema(commandType,
{ attribute?.Name,
var result = new List<CommandOptionSchema>(); attribute?.Description,
options);
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

@@ -1,11 +1,42 @@
using CliFx.Models; using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using CliFx.Models;
namespace CliFx.Services namespace CliFx.Services
{ {
public static class Extensions public static class Extensions
{ {
public static void Write(this IConsoleWriter consoleWriter, string text) => consoleWriter.Write(new TextSpan(text)); public static IReadOnlyList<CommandSchema> GetCommandSchemas(this ICommandSchemaResolver commandSchemaResolver,
IEnumerable<Type> commandTypes) => commandTypes.Select(commandSchemaResolver.GetCommandSchema).ToArray();
public static void WriteLine(this IConsoleWriter consoleWriter, string text) => consoleWriter.WriteLine(new TextSpan(text)); public static void Write(this IConsoleWriter consoleWriter, string text) =>
consoleWriter.Write(new TextSpan(text));
public static void Write(this IConsoleWriter consoleWriter, IFormattable formattable) =>
consoleWriter.Write(formattable.ToString(null, CultureInfo.InvariantCulture));
public static void Write(this IConsoleWriter consoleWriter, object obj)
{
if (obj is IFormattable formattable)
consoleWriter.Write(formattable);
else
consoleWriter.Write(obj.ToString());
}
public static void WriteLine(this IConsoleWriter consoleWriter, string text) =>
consoleWriter.WriteLine(new TextSpan(text));
public static void WriteLine(this IConsoleWriter consoleWriter, IFormattable formattable) =>
consoleWriter.WriteLine(formattable.ToString(null, CultureInfo.InvariantCulture));
public static void WriteLine(this IConsoleWriter consoleWriter, object obj)
{
if (obj is IFormattable formattable)
consoleWriter.WriteLine(formattable);
else
consoleWriter.WriteLine(obj.ToString());
}
} }
} }

View File

@@ -1,106 +0,0 @@
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,10 @@
using System;
using CliFx.Models;
namespace CliFx.Services
{
public interface ICommandFactory
{
ICommand CreateCommand(CommandSchema schema);
}
}

View File

@@ -0,0 +1,10 @@
using System.Collections.Generic;
using CliFx.Models;
namespace CliFx.Services
{
public interface ICommandHelpTextBuilder
{
string Build(IReadOnlyList<CommandSchema> commandSchemas, CommandSchema commandSchema);
}
}

View File

@@ -4,6 +4,6 @@ namespace CliFx.Services
{ {
public interface ICommandInitializer public interface ICommandInitializer
{ {
ICommand InitializeCommand(CommandInput input); void InitializeCommand(ICommand command, CommandSchema schema, CommandInput input);
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ CliFx is a powerful framework for building command line applications.
## Features ## Features
- ...to be added with a stable release... - ...to be added with a stable release...
- Targets .NET Framework 4.5+ and .NET Standard 2.0+ - Targets .NET Framework 4.6+ and .NET Standard 2.0+
- No external dependencies - No external dependencies
## Usage ## Usage