Add project files.

This commit is contained in:
Alexey Golub
2019-06-02 18:32:25 +03:00
parent 84606aba4f
commit da79a016a5
37 changed files with 1310 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net45</TargetFramework>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\CliFx\CliFx.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,25 @@
using System;
using System.Globalization;
using CliFx.Attributes;
using CliFx.Models;
namespace CliFx.Tests.Dummy.Commands
{
[Command("add")]
public class AddCommand : Command
{
[CommandOption("a", IsRequired = true, Description = "Left operand.")]
public double A { get; set; }
[CommandOption("b", IsRequired = true, Description = "Right operand.")]
public double B { get; set; }
public override ExitCode Execute()
{
var result = A + B;
Console.WriteLine(result.ToString(CultureInfo.InvariantCulture));
return ExitCode.Success;
}
}
}

View File

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

View File

@@ -0,0 +1,25 @@
using System;
using System.Globalization;
using CliFx.Attributes;
using CliFx.Models;
namespace CliFx.Tests.Dummy.Commands
{
[Command("log")]
public class LogCommand : Command
{
[CommandOption("value", IsRequired = true, Description = "Value whose logarithm is to be found.")]
public double Value { get; set; }
[CommandOption("base", Description = "Logarithm base.")]
public double Base { get; set; } = 10;
public override ExitCode Execute()
{
var result = Math.Log(Value, Base);
Console.WriteLine(result.ToString(CultureInfo.InvariantCulture));
return ExitCode.Success;
}
}
}

View File

@@ -0,0 +1,9 @@
using System.Threading.Tasks;
namespace CliFx.Tests.Dummy
{
public static class Program
{
public static Task<int> Main(string[] args) => new CliApplication().RunAsync(args);
}
}

View File

@@ -0,0 +1,33 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx.Services;
using CliFx.Tests.TestObjects;
using Moq;
using NUnit.Framework;
namespace CliFx.Tests
{
[TestFixture]
public class CliApplicationTests
{
[Test]
public async Task RunAsync_Test()
{
// Arrange
var command = new TestCommand();
var expectedExitCode = await command.ExecuteAsync();
var commandResolverMock = new Mock<ICommandResolver>();
commandResolverMock.Setup(m => m.ResolveCommand(It.IsAny<IReadOnlyList<string>>())).Returns(command);
var commandResolver = commandResolverMock.Object;
var application = new CliApplication(commandResolver);
// Act
var exitCodeValue = await application.RunAsync();
// Assert
Assert.That(exitCodeValue, Is.EqualTo(expectedExitCode.Value));
}
}
}

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net45</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.0.1" />
<PackageReference Include="NUnit" Version="3.11.0" />
<PackageReference Include="NUnit3TestAdapter" Version="3.11.0" />
<PackageReference Include="Moq" Version="4.11.0" />
<PackageReference Include="CliWrap" Version="2.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CliFx.Tests.Dummy\CliFx.Tests.Dummy.csproj" />
<ProjectReference Include="..\CliFx\CliFx.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
using CliFx.Services;
using CliFx.Tests.TestObjects;
using NUnit.Framework;
namespace CliFx.Tests
{
[TestFixture]
public class CommandOptionConverterTests
{
private static IEnumerable<TestCaseData> GetData_ConvertOption()
{
yield return new TestCaseData("value", typeof(string), "value")
.SetName("To string");
yield return new TestCaseData("value", typeof(object), "value")
.SetName("To object");
yield return new TestCaseData("true", typeof(bool), true)
.SetName("To bool (true)");
yield return new TestCaseData("false", typeof(bool), false)
.SetName("To bool (false)");
yield return new TestCaseData(null, typeof(bool), true)
.SetName("To bool (switch)");
yield return new TestCaseData("123", typeof(int), 123)
.SetName("To int");
yield return new TestCaseData("123.45", typeof(double), 123.45)
.SetName("To double");
yield return new TestCaseData("28 Apr 1995", typeof(DateTime), new DateTime(1995, 04, 28))
.SetName("To DateTime");
yield return new TestCaseData("28 Apr 1995", typeof(DateTimeOffset), new DateTimeOffset(new DateTime(1995, 04, 28)))
.SetName("To DateTimeOffset");
yield return new TestCaseData("00:14:59", typeof(TimeSpan), new TimeSpan(00, 14, 59))
.SetName("To TimeSpan");
yield return new TestCaseData("value2", typeof(TestEnum), TestEnum.Value2)
.SetName("To enum");
yield return new TestCaseData("666", typeof(int?), 666)
.SetName("To int? (with value)");
yield return new TestCaseData(null, typeof(int?), null)
.SetName("To int? (no value)");
yield return new TestCaseData("value3", typeof(TestEnum?), TestEnum.Value3)
.SetName("To enum? (with value)");
yield return new TestCaseData(null, typeof(TestEnum?), null)
.SetName("To enum? (no value)");
yield return new TestCaseData("01:00:00", typeof(TimeSpan?), new TimeSpan(01, 00, 00))
.SetName("To TimeSpan? (with value)");
yield return new TestCaseData(null, typeof(TimeSpan?), null)
.SetName("To TimeSpan? (no value)");
}
[Test]
[TestCaseSource(nameof(GetData_ConvertOption))]
public void ConvertOption_Test(string value, Type targetType, object expectedConvertedValue)
{
// Arrange
var converter = new CommandOptionConverter();
// Act
var convertedValue = converter.ConvertOption(value, targetType);
// Assert
Assert.That(convertedValue, Is.EqualTo(expectedConvertedValue));
if (convertedValue != null)
Assert.That(convertedValue, Is.AssignableTo(targetType));
}
}
}

View File

@@ -0,0 +1,139 @@
using System.Collections.Generic;
using CliFx.Models;
using CliFx.Services;
using NUnit.Framework;
namespace CliFx.Tests
{
[TestFixture]
public class CommandOptionParserTests
{
private static IEnumerable<TestCaseData> GetData_ParseOptions()
{
yield return new TestCaseData(
new string[0],
CommandOptionSet.Empty
).SetName("No arguments");
yield return new TestCaseData(
new[] {"--argument", "value"},
new CommandOptionSet(new Dictionary<string, string>
{
{"argument", "value"}
})
).SetName("Single argument");
yield return new TestCaseData(
new[] {"--argument1", "value1", "--argument2", "value2", "--argument3", "value3"},
new CommandOptionSet(new Dictionary<string, string>
{
{"argument1", "value1"},
{"argument2", "value2"},
{"argument3", "value3"}
})
).SetName("Multiple arguments");
yield return new TestCaseData(
new[] {"-a", "value"},
new CommandOptionSet(new Dictionary<string, string>
{
{"a", "value"}
})
).SetName("Single short argument");
yield return new TestCaseData(
new[] {"-a", "value1", "-b", "value2", "-c", "value3"},
new CommandOptionSet(new Dictionary<string, string>
{
{"a", "value1"},
{"b", "value2"},
{"c", "value3"}
})
).SetName("Multiple short arguments");
yield return new TestCaseData(
new[] {"--argument1", "value1", "-b", "value2", "--argument3", "value3"},
new CommandOptionSet(new Dictionary<string, string>
{
{"argument1", "value1"},
{"b", "value2"},
{"argument3", "value3"}
})
).SetName("Multiple mixed arguments");
yield return new TestCaseData(
new[] {"--switch"},
new CommandOptionSet(new Dictionary<string, string>
{
{"switch", null}
})
).SetName("Single switch");
yield return new TestCaseData(
new[] {"--switch1", "--switch2", "--switch3"},
new CommandOptionSet(new Dictionary<string, string>
{
{"switch1", null},
{"switch2", null},
{"switch3", null}
})
).SetName("Multiple switches");
yield return new TestCaseData(
new[] {"-s"},
new CommandOptionSet(new Dictionary<string, string>
{
{"s", null}
})
).SetName("Single short switch");
yield return new TestCaseData(
new[] {"-a", "-b", "-c"},
new CommandOptionSet(new Dictionary<string, string>
{
{"a", null},
{"b", null},
{"c", null}
})
).SetName("Multiple short switches");
yield return new TestCaseData(
new[] {"-abc"},
new CommandOptionSet(new Dictionary<string, string>
{
{"a", null},
{"b", null},
{"c", null}
})
).SetName("Multiple stacked short switches");
yield return new TestCaseData(
new[] {"command"},
new CommandOptionSet("command")
).SetName("No arguments (with command name)");
yield return new TestCaseData(
new[] {"command", "--argument", "value"},
new CommandOptionSet("command", new Dictionary<string, string>
{
{"argument", "value"}
})
).SetName("Single argument (with command name)");
}
[Test]
[TestCaseSource(nameof(GetData_ParseOptions))]
public void ParseOptions_Test(IReadOnlyList<string> commandLineArguments, CommandOptionSet expectedCommandOptionSet)
{
// Arrange
var parser = new CommandOptionParser();
// Act
var optionSet = parser.ParseOptions(commandLineArguments);
// Assert
Assert.That(optionSet.CommandName, Is.EqualTo(expectedCommandOptionSet.CommandName));
Assert.That(optionSet.Options, Is.EqualTo(expectedCommandOptionSet.Options));
}
}
}

View File

@@ -0,0 +1,116 @@
using System;
using System.Collections.Generic;
using CliFx.Exceptions;
using CliFx.Models;
using CliFx.Services;
using CliFx.Tests.TestObjects;
using Moq;
using NUnit.Framework;
namespace CliFx.Tests
{
[TestFixture]
public class CommandResolverTests
{
private static IEnumerable<TestCaseData> GetData_ResolveCommand()
{
yield return new TestCaseData(
new CommandOptionSet(new Dictionary<string, string>
{
{"int", "13"}
}),
new TestCommand {IntOption = 13}
).SetName("Single option");
yield return new TestCaseData(
new CommandOptionSet(new Dictionary<string, string>
{
{"int", "13"},
{"str", "hello world" }
}),
new TestCommand { IntOption = 13, StringOption = "hello world"}
).SetName("Multiple options");
yield return new TestCaseData(
new CommandOptionSet(new Dictionary<string, string>
{
{"i", "13"}
}),
new TestCommand { IntOption = 13 }
).SetName("Single short option");
yield return new TestCaseData(
new CommandOptionSet("command", new Dictionary<string, string>
{
{"int", "13"}
}),
new TestCommand { IntOption = 13 }
).SetName("Single option (with command name)");
}
[Test]
[TestCaseSource(nameof(GetData_ResolveCommand))]
public void ResolveCommand_Test(CommandOptionSet commandOptionSet, TestCommand expectedCommand)
{
// Arrange
var commandTypes = new[] {typeof(TestCommand)};
var typeProviderMock = new Mock<ITypeProvider>();
typeProviderMock.Setup(m => m.GetTypes()).Returns(commandTypes);
var typeProvider = typeProviderMock.Object;
var optionParserMock = new Mock<ICommandOptionParser>();
optionParserMock.Setup(m => m.ParseOptions(It.IsAny<IReadOnlyList<string>>())).Returns(commandOptionSet);
var optionParser = optionParserMock.Object;
var optionConverter = new CommandOptionConverter();
var resolver = new CommandResolver(typeProvider, optionParser, optionConverter);
// Act
var command = resolver.ResolveCommand() as TestCommand;
// Assert
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> GetData_ResolveCommand_IsRequired()
{
yield return new TestCaseData(
CommandOptionSet.Empty
).SetName("No options");
yield return new TestCaseData(
new CommandOptionSet(new Dictionary<string, string>
{
{"str", "hello world"}
})
).SetName("Required option is not set");
}
[Test]
[TestCaseSource(nameof(GetData_ResolveCommand_IsRequired))]
public void ResolveCommand_IsRequired_Test(CommandOptionSet commandOptionSet)
{
// Arrange
var commandTypes = new[] { typeof(TestCommand) };
var typeProviderMock = new Mock<ITypeProvider>();
typeProviderMock.Setup(m => m.GetTypes()).Returns(commandTypes);
var typeProvider = typeProviderMock.Object;
var optionParserMock = new Mock<ICommandOptionParser>();
optionParserMock.Setup(m => m.ParseOptions(It.IsAny<IReadOnlyList<string>>())).Returns(commandOptionSet);
var optionParser = optionParserMock.Object;
var optionConverter = new CommandOptionConverter();
var resolver = new CommandResolver(typeProvider, optionParser, optionConverter);
// Act & Assert
Assert.Throws<CommandResolveException>(() => resolver.ResolveCommand());
}
}
}

32
CliFx.Tests/DummyTests.cs Normal file
View File

@@ -0,0 +1,32 @@
using System.IO;
using System.Threading.Tasks;
using CliWrap;
using NUnit.Framework;
namespace CliFx.Tests
{
[TestFixture]
public class DummyTests
{
private string DummyFilePath => Path.Combine(TestContext.CurrentContext.TestDirectory, "CliFx.Tests.Dummy.exe");
[Test]
[TestCase("", "Hello world")]
[TestCase("-t .NET", "Hello .NET")]
[TestCase("-e", "Hello world!!!")]
[TestCase("add --a 1 --b 2", "3")]
[TestCase("add --a 2.75 --b 3.6", "6.35")]
[TestCase("log --value 100", "2")]
[TestCase("log --value 256 --base 2", "8")]
public async Task Execute_Test(string arguments, string expectedOutput)
{
// Act
var result = await Cli.Wrap(DummyFilePath).SetArguments(arguments).ExecuteAsync();
// Assert
Assert.That(result.ExitCode, Is.Zero);
Assert.That(result.StandardOutput.Trim(), Is.EqualTo(expectedOutput));
Assert.That(result.StandardError.Trim(), Is.Empty);
}
}
}

View File

@@ -0,0 +1,18 @@
using CliFx.Attributes;
using CliFx.Models;
namespace CliFx.Tests.TestObjects
{
[DefaultCommand]
[Command("command")]
public class TestCommand : Command
{
[CommandOption("int", ShortName = 'i', IsRequired = true)]
public int IntOption { get; set; } = 24;
[CommandOption("str", ShortName = 's')]
public string StringOption { get; set; } = "foo bar";
public override ExitCode Execute() => new ExitCode(IntOption, StringOption);
}
}

View File

@@ -0,0 +1,9 @@
namespace CliFx.Tests.TestObjects
{
public enum TestEnum
{
Value1,
Value2,
Value3
}
}

65
CliFx.sln Normal file
View File

@@ -0,0 +1,65 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.28803.352
MinimumVisualStudioVersion = 15.0.26124.0
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx", "CliFx\CliFx.csproj", "{C0D60B0B-63CF-4DCA-916F-BDB40CEDCE35}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx.Tests", "CliFx.Tests\CliFx.Tests.csproj", "{268CF863-65A5-49BB-93CF-08972B7756DC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CliFx.Tests.Dummy", "CliFx.Tests.Dummy\CliFx.Tests.Dummy.csproj", "{4904B3EB-3286-4F1B-8B74-6FF051C8E787}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{C0D60B0B-63CF-4DCA-916F-BDB40CEDCE35}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C0D60B0B-63CF-4DCA-916F-BDB40CEDCE35}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C0D60B0B-63CF-4DCA-916F-BDB40CEDCE35}.Debug|x64.ActiveCfg = Debug|Any CPU
{C0D60B0B-63CF-4DCA-916F-BDB40CEDCE35}.Debug|x64.Build.0 = Debug|Any CPU
{C0D60B0B-63CF-4DCA-916F-BDB40CEDCE35}.Debug|x86.ActiveCfg = Debug|Any CPU
{C0D60B0B-63CF-4DCA-916F-BDB40CEDCE35}.Debug|x86.Build.0 = Debug|Any CPU
{C0D60B0B-63CF-4DCA-916F-BDB40CEDCE35}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C0D60B0B-63CF-4DCA-916F-BDB40CEDCE35}.Release|Any CPU.Build.0 = Release|Any CPU
{C0D60B0B-63CF-4DCA-916F-BDB40CEDCE35}.Release|x64.ActiveCfg = Release|Any CPU
{C0D60B0B-63CF-4DCA-916F-BDB40CEDCE35}.Release|x64.Build.0 = Release|Any CPU
{C0D60B0B-63CF-4DCA-916F-BDB40CEDCE35}.Release|x86.ActiveCfg = Release|Any CPU
{C0D60B0B-63CF-4DCA-916F-BDB40CEDCE35}.Release|x86.Build.0 = Release|Any CPU
{268CF863-65A5-49BB-93CF-08972B7756DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{268CF863-65A5-49BB-93CF-08972B7756DC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{268CF863-65A5-49BB-93CF-08972B7756DC}.Debug|x64.ActiveCfg = Debug|Any CPU
{268CF863-65A5-49BB-93CF-08972B7756DC}.Debug|x64.Build.0 = Debug|Any CPU
{268CF863-65A5-49BB-93CF-08972B7756DC}.Debug|x86.ActiveCfg = Debug|Any CPU
{268CF863-65A5-49BB-93CF-08972B7756DC}.Debug|x86.Build.0 = Debug|Any CPU
{268CF863-65A5-49BB-93CF-08972B7756DC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{268CF863-65A5-49BB-93CF-08972B7756DC}.Release|Any CPU.Build.0 = Release|Any CPU
{268CF863-65A5-49BB-93CF-08972B7756DC}.Release|x64.ActiveCfg = Release|Any CPU
{268CF863-65A5-49BB-93CF-08972B7756DC}.Release|x64.Build.0 = Release|Any CPU
{268CF863-65A5-49BB-93CF-08972B7756DC}.Release|x86.ActiveCfg = Release|Any CPU
{268CF863-65A5-49BB-93CF-08972B7756DC}.Release|x86.Build.0 = Release|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Debug|x64.ActiveCfg = Debug|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Debug|x64.Build.0 = Debug|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Debug|x86.ActiveCfg = Debug|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Debug|x86.Build.0 = Debug|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Release|Any CPU.Build.0 = Release|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Release|x64.ActiveCfg = Release|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Release|x64.Build.0 = Release|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Release|x86.ActiveCfg = Release|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {6ACC950B-5F93-429C-A204-6315A92AD3A1}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,15 @@
using System;
namespace CliFx.Attributes
{
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class CommandAttribute : Attribute
{
public string Name { get; }
public CommandAttribute(string name)
{
Name = name;
}
}
}

View File

@@ -0,0 +1,21 @@
using System;
namespace CliFx.Attributes
{
[AttributeUsage(AttributeTargets.Property)]
public class CommandOptionAttribute : Attribute
{
public string Name { get; }
public char ShortName { get; set; }
public bool IsRequired { get; set; }
public string Description { get; set; }
public CommandOptionAttribute(string name)
{
Name = name;
}
}
}

View File

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

45
CliFx/CliApplication.cs Normal file
View File

@@ -0,0 +1,45 @@
using System.Collections.Generic;
using System.Reflection;
using System.Threading.Tasks;
using CliFx.Services;
namespace CliFx
{
public partial class CliApplication : ICliApplication
{
private readonly ICommandResolver _commandResolver;
public CliApplication(ICommandResolver commandResolver)
{
_commandResolver = commandResolver;
}
public CliApplication()
: this(GetDefaultCommandResolver(Assembly.GetCallingAssembly()))
{
}
public async Task<int> RunAsync(IReadOnlyList<string> commandLineArguments)
{
// Resolve and execute command
var command = _commandResolver.ResolveCommand(commandLineArguments);
var exitCode = await command.ExecuteAsync();
// TODO: print message if error?
return exitCode.Value;
}
}
public partial class CliApplication
{
private static ICommandResolver GetDefaultCommandResolver(Assembly assembly)
{
var typeProvider = TypeProvider.FromAssembly(assembly);
var commandOptionParser = new CommandOptionParser();
var commandOptionConverter = new CommandOptionConverter();
return new CommandResolver(typeProvider, commandOptionParser, commandOptionConverter);
}
}
}

8
CliFx/CliFx.csproj Normal file
View File

@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard2.0;net45</TargetFrameworks>
<LangVersion>latest</LangVersion>
</PropertyGroup>
</Project>

15
CliFx/Command.cs Normal file
View File

@@ -0,0 +1,15 @@
using System;
using System.Threading.Tasks;
using CliFx.Models;
namespace CliFx
{
public abstract class Command
{
public virtual ExitCode Execute() => throw new InvalidOperationException(
"Can't execute command because its execution method is not defined. " +
$"Override Execute or ExecuteAsync on {GetType().Name} in order to make it executable.");
public virtual Task<ExitCode> ExecuteAsync() => Task.FromResult(Execute());
}
}

View File

@@ -0,0 +1,21 @@
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)
{
}
}
}

12
CliFx/Extensions.cs Normal file
View File

@@ -0,0 +1,12 @@
using System;
using System.Linq;
using System.Threading.Tasks;
namespace CliFx
{
public static class Extensions
{
public static Task<int> RunAsync(this ICliApplication application) =>
application.RunAsync(Environment.GetCommandLineArgs().Skip(1).ToArray());
}
}

10
CliFx/ICliApplication.cs Normal file
View File

@@ -0,0 +1,10 @@
using System.Collections.Generic;
using System.Threading.Tasks;
namespace CliFx
{
public interface ICliApplication
{
Task<int> RunAsync(IReadOnlyList<string> commandLineArguments);
}
}

View File

@@ -0,0 +1,48 @@
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

@@ -0,0 +1,52 @@
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 CommandType(Type type, string name, bool isDefault)
{
_type = type;
Name = name;
IsDefault = isDefault;
}
public IEnumerable<CommandOptionProperty> GetOptionProperties() => _type.GetProperties()
.Where(CommandOptionProperty.IsValid)
.Select(CommandOptionProperty.Initialize);
public Command Activate() => (Command) Activator.CreateInstance(_type);
}
internal partial class CommandType
{
public static bool IsValid(Type type) =>
// Derives from Command
type.IsDerivedFrom(typeof(Command)) &&
// Marked with DefaultCommandAttribute or CommandAttribute
(type.IsDefined(typeof(DefaultCommandAttribute)) || 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 name = type.GetCustomAttribute<CommandAttribute>()?.Name;
var isDefault = type.IsDefined(typeof(DefaultCommandAttribute));
return new CommandType(type, name, isDefault);
}
public static IEnumerable<CommandType> GetCommandTypes(IEnumerable<Type> types) => types.Where(IsValid).Select(Initialize);
}
}

View File

@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
namespace CliFx.Internal
{
internal static class Extensions
{
public static bool IsNullOrWhiteSpace(this string s) => string.IsNullOrWhiteSpace(s);
public static string SubstringUntil(this string s, string sub, StringComparison comparison = StringComparison.Ordinal)
{
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) =>
dic.TryGetValue(key, out var result) ? result : default;
public static string TrimStart(this string s, string sub, StringComparison comparison = StringComparison.Ordinal)
{
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)
{
var currentType = type;
while (currentType != null)
{
if (currentType == baseType)
return true;
currentType = currentType.BaseType;
}
return false;
}
}
}

View File

@@ -0,0 +1,37 @@
using System.Collections.Generic;
using CliFx.Internal;
namespace CliFx.Models
{
public partial class CommandOptionSet
{
public string CommandName { get; }
public IReadOnlyDictionary<string, string> Options { get; }
public CommandOptionSet(string commandName, IReadOnlyDictionary<string, string> options)
{
CommandName = commandName;
Options = options;
}
public CommandOptionSet(IReadOnlyDictionary<string, string> options)
: this(null, options)
{
}
public CommandOptionSet(string commandName)
: this(commandName, new Dictionary<string, string>())
{
}
public override string ToString() => !CommandName.IsNullOrWhiteSpace()
? $"{CommandName} / {Options.Count} option(s)"
: $"{Options.Count} option(s)";
}
public partial class CommandOptionSet
{
public static CommandOptionSet Empty { get; } = new CommandOptionSet(new Dictionary<string, string>());
}
}

26
CliFx/Models/ExitCode.cs Normal file
View File

@@ -0,0 +1,26 @@
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

@@ -0,0 +1,56 @@
using System;
using System.Globalization;
using CliFx.Internal;
namespace CliFx.Services
{
public class CommandOptionConverter : ICommandOptionConverter
{
private readonly IFormatProvider _formatProvider;
public CommandOptionConverter(IFormatProvider formatProvider)
{
_formatProvider = formatProvider;
}
public CommandOptionConverter()
: this(CultureInfo.InvariantCulture)
{
}
public object ConvertOption(string value, Type targetType)
{
// String or object
if (targetType == typeof(string) || targetType == typeof(object))
return value;
// Bool
if (targetType == typeof(bool))
return value.IsNullOrWhiteSpace() || bool.Parse(value);
// DateTime
if (targetType == typeof(DateTime))
return DateTime.Parse(value, _formatProvider);
// DateTimeOffset
if (targetType == typeof(DateTimeOffset))
return DateTimeOffset.Parse(value, _formatProvider);
// TimeSpan
if (targetType == typeof(TimeSpan))
return TimeSpan.Parse(value, _formatProvider);
// Enum
if (targetType.IsEnum)
return Enum.Parse(targetType, value, true);
// Nullable
var nullableUnderlyingType = Nullable.GetUnderlyingType(targetType);
if (nullableUnderlyingType != null)
return !value.IsNullOrWhiteSpace() ? ConvertOption(value, nullableUnderlyingType) : null;
// All other types
return Convert.ChangeType(value, targetType, _formatProvider);
}
}
}

View File

@@ -0,0 +1,71 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using CliFx.Internal;
using CliFx.Models;
namespace CliFx.Services
{
public class CommandOptionParser : ICommandOptionParser
{
public CommandOptionSet ParseOptions(IReadOnlyList<string> commandLineArguments)
{
// Initialize command name placeholder
string commandName = null;
// Initialize options
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
// Keep track of the last option's name
string optionName = null;
// Loop through all arguments
var isFirstArgument = true;
foreach (var commandLineArgument in commandLineArguments)
{
// Option name
if (commandLineArgument.StartsWith("--", StringComparison.OrdinalIgnoreCase))
{
// Extract option name (skip 2 chars)
optionName = commandLineArgument.Substring(2);
options[optionName] = null;
}
// Short option name
else if (commandLineArgument.StartsWith("-", StringComparison.OrdinalIgnoreCase) && commandLineArgument.Length == 2)
{
// Extract option name (skip 1 char)
optionName = commandLineArgument.Substring(1);
options[optionName] = null;
}
// Multiple stacked short options
else if (commandLineArgument.StartsWith("-", StringComparison.OrdinalIgnoreCase))
{
optionName = null;
foreach (var c in commandLineArgument.Substring(1))
{
options[c.ToString(CultureInfo.InvariantCulture)] = null;
}
}
// Command name
else if (isFirstArgument)
{
commandName = commandLineArgument;
}
// Option value
else if (!optionName.IsNullOrWhiteSpace())
{
// ReSharper disable once AssignNullToNotNullAttribute
options[optionName] = commandLineArgument;
}
isFirstArgument = false;
}
return new CommandOptionSet(commandName, options);
}
}
}

View File

@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Internal;
namespace CliFx.Services
{
public class CommandResolver : ICommandResolver
{
private readonly ITypeProvider _typeProvider;
private readonly ICommandOptionParser _commandOptionParser;
private readonly ICommandOptionConverter _commandOptionConverter;
public CommandResolver(ITypeProvider typeProvider,
ICommandOptionParser commandOptionParser, ICommandOptionConverter commandOptionConverter)
{
_typeProvider = typeProvider;
_commandOptionParser = commandOptionParser;
_commandOptionConverter = commandOptionConverter;
}
private IEnumerable<CommandType> GetCommandTypes() => CommandType.GetCommandTypes(_typeProvider.GetTypes());
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 Command ResolveCommand(IReadOnlyList<string> commandLineArguments)
{
var optionSet = _commandOptionParser.ParseOptions(commandLineArguments);
// 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.GetOptionProperties())
{
// If option set contains this property - set value
if (optionSet.Options.TryGetValue(property.Name, out var value) ||
optionSet.Options.TryGetValue(property.ShortName.ToString(CultureInfo.InvariantCulture), out value))
{
var convertedValue = _commandOptionConverter.ConvertOption(value, property.Type);
property.SetValue(command, convertedValue);
}
// If the property is missing but it's required - throw
else if (property.IsRequired)
{
throw new CommandResolveException(
$"Can't resolve command [{optionSet.CommandName}] because required property [{property.Name}] is not set.");
}
}
return command;
}
}
}

View File

@@ -0,0 +1,7 @@
namespace CliFx.Services
{
public static class Extensions
{
public static Command ResolveCommand(this ICommandResolver commandResolver) => commandResolver.ResolveCommand(new string[0]);
}
}

View File

@@ -0,0 +1,9 @@
using System;
namespace CliFx.Services
{
public interface ICommandOptionConverter
{
object ConvertOption(string value, Type targetType);
}
}

View File

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

View File

@@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace CliFx.Services
{
public interface ICommandResolver
{
Command ResolveCommand(IReadOnlyList<string> commandLineArguments);
}
}

View File

@@ -0,0 +1,10 @@
using System;
using System.Collections.Generic;
namespace CliFx.Services
{
public interface ITypeProvider
{
IReadOnlyList<Type> GetTypes();
}
}

View File

@@ -0,0 +1,34 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
namespace CliFx.Services
{
public partial class TypeProvider : ITypeProvider
{
private readonly IReadOnlyList<Type> _types;
public TypeProvider(IReadOnlyList<Type> types)
{
_types = types;
}
public TypeProvider(params Type[] types)
: this((IReadOnlyList<Type>) types)
{
}
public IReadOnlyList<Type> GetTypes() => _types;
}
public partial class TypeProvider
{
public static TypeProvider FromAssembly(Assembly assembly) => new TypeProvider(assembly.GetExportedTypes());
public static TypeProvider FromAssemblies(IReadOnlyList<Assembly> assemblies) =>
new TypeProvider(assemblies.SelectMany(a => a.ExportedTypes).ToArray());
public static TypeProvider FromAssemblies(params Assembly[] assemblies) => FromAssemblies((IReadOnlyList<Assembly>) assemblies);
}
}