From 3883c831e9b69005a15f3f54517baa70a972fa7e Mon Sep 17 00:00:00 2001 From: Alexey Golub Date: Mon, 27 Jan 2020 21:10:14 +0200 Subject: [PATCH] Rework (#36) --- CliFx.Benchmarks/Benchmark.cs | 27 +- CliFx.Benchmarks/CliFx.Benchmarks.csproj | 4 +- CliFx.Benchmarks/Commands/CliFxCommand.cs | 1 - CliFx.Demo/CliFx.Demo.csproj | 2 +- CliFx.Demo/Commands/BookAddCommand.cs | 19 +- CliFx.Demo/Commands/BookCommand.cs | 5 +- CliFx.Demo/Commands/BookListCommand.cs | 1 - CliFx.Demo/Commands/BookRemoveCommand.cs | 5 +- CliFx.Demo/Internal/Extensions.cs | 1 - CliFx.Demo/Models/Isbn.cs | 17 +- CliFx.Demo/Program.cs | 14 +- CliFx.Demo/Readme.md | 2 +- CliFx.Demo/Services/LibraryService.cs | 2 +- CliFx.Tests/CliApplicationBuilderTests.cs | 14 +- CliFx.Tests/CliApplicationTests.cs | 272 +++++- CliFx.Tests/CliFx.Tests.csproj | 6 +- CliFx.Tests/DefaultCommandFactoryTests.cs | 48 + CliFx.Tests/DelegateCommandFactoryTests.cs | 33 + CliFx.Tests/Domain/ApplicationSchemaTests.cs | 888 ++++++++++++++++++ CliFx.Tests/Domain/CommandLineInputTests.cs | 264 ++++++ .../CommandArgumentSchemasValidatorTests.cs | 132 --- CliFx.Tests/Services/CommandFactoryTests.cs | 37 - .../Services/CommandInitializerTests.cs | 245 ----- .../Services/CommandInputConverterTests.cs | 323 ------- .../Services/CommandInputParserTests.cs | 255 ----- .../Services/CommandSchemaResolverTests.cs | 306 ------ .../Services/DelegateCommandFactoryTests.cs | 40 - CliFx.Tests/Services/HelpTextRendererTests.cs | 177 ---- .../EmptyEnvironmentVariablesProviderStub.cs | 10 - .../Stubs/EnvironmentVariablesProviderStub.cs | 18 - .../{Services => }/SystemConsoleTests.cs | 12 +- .../TestCommands/AllRequiredOptionsCommand.cs | 1 - .../TestCommands/AllSupportedTypesCommand.cs | 126 +++ CliFx.Tests/TestCommands/ArgumentCommand.cs | 25 - .../TestCommands/BrokenEnumerableCommand.cs | 15 + .../TestCommands/CancellableCommand.cs | 10 +- .../TestCommands/CommandExceptionCommand.cs | 1 - CliFx.Tests/TestCommands/ConcatCommand.cs | 1 - CliFx.Tests/TestCommands/DivideCommand.cs | 1 - ...teOptionEnvironmentVariableNamesCommand.cs | 17 + .../DuplicateOptionNamesCommand.cs | 1 - .../DuplicateOptionShortNamesCommand.cs | 13 +- .../DuplicateParameterNameCommand.cs | 17 + .../DuplicateParameterOrderCommand.cs | 17 + .../EnvironmentVariableCommand.cs | 1 - ...onmentVariableWithMultipleValuesCommand.cs | 1 - ...ariableWithoutCollectionPropertyCommand.cs | 1 - CliFx.Tests/TestCommands/ExceptionCommand.cs | 1 - .../TestCommands/HelloWorldDefaultCommand.cs | 1 - .../TestCommands/HelpDefaultCommand.cs | 1 - CliFx.Tests/TestCommands/HelpNamedCommand.cs | 1 - CliFx.Tests/TestCommands/HelpSubCommand.cs | 1 - .../MultipleNonScalarParametersCommand.cs | 18 + .../TestCommands/NonAnnotatedCommand.cs | 1 - .../NonLastNonScalarParameterCommand.cs | 18 + CliFx.Tests/TestCommands/ParameterCommand.cs | 24 + .../TestCommands/SimpleArgumentCommand.cs | 21 - .../TestCommands/SimpleParameterCommand.cs | 20 + .../SomeRequiredOptionsCommand.cs | 1 - .../TestCustomTypes/TestCustomEnumerable.cs | 14 + CliFx.Tests/Utilities/ProgressTickerTests.cs | 1 - .../{Services => }/VirtualConsoleTests.cs | 8 +- CliFx.sln | 1 + .../{Models => }/ApplicationConfiguration.cs | 5 +- CliFx/{Models => }/ApplicationMetadata.cs | 2 +- CliFx/Attributes/CommandArgumentAttribute.cs | 42 - CliFx/Attributes/CommandAttribute.cs | 4 +- CliFx/Attributes/CommandOptionAttribute.cs | 4 +- CliFx/Attributes/CommandParameterAttribute.cs | 37 + CliFx/CliApplication.Help.cs | 314 +++++++ CliFx/CliApplication.cs | 218 ++--- CliFx/CliApplicationBuilder.cs | 154 +-- CliFx/CliFx.csproj | 8 +- CliFx/DefaultTypeActivator.cs | 29 + CliFx/DelegateTypeActivator.cs | 20 + CliFx/Domain/ApplicationSchema.cs | 256 +++++ CliFx/Domain/CommandArgumentSchema.cs | 176 ++++ CliFx/Domain/CommandLineInput.cs | 179 ++++ .../{Models => Domain}/CommandOptionInput.cs | 30 +- CliFx/Domain/CommandOptionSchema.cs | 105 +++ CliFx/Domain/CommandParameterSchema.cs | 53 ++ CliFx/Domain/CommandSchema.cs | 196 ++++ CliFx/Exceptions/CommandException.cs | 4 +- CliFx/Extensions.cs | 42 +- CliFx/ICliApplication.cs | 16 - CliFx/ICliApplicationBuilder.cs | 78 -- CliFx/ICommand.cs | 8 +- CliFx/{Services => }/IConsole.cs | 5 +- CliFx/ITypeActivator.cs | 15 + CliFx/Internal/Extensions.cs | 33 +- CliFx/Internal/Polyfills.cs | 16 + CliFx/Models/CommandArgumentSchema.cs | 78 -- CliFx/Models/CommandCandidate.cs | 35 - CliFx/Models/CommandInput.cs | 122 --- CliFx/Models/CommandOptionSchema.cs | 88 -- CliFx/Models/CommandSchema.cs | 77 -- CliFx/Models/Extensions.cs | 131 --- CliFx/Models/HelpTextSource.cs | 37 - .../CommandArgumentSchemasValidator.cs | 92 -- CliFx/Services/CommandFactory.cs | 14 - CliFx/Services/CommandInitializer.cs | 149 --- CliFx/Services/CommandInputConverter.cs | 232 ----- CliFx/Services/CommandInputParser.cs | 99 -- CliFx/Services/CommandSchemaResolver.cs | 193 ---- CliFx/Services/DelegateCommandFactory.cs | 24 - CliFx/Services/EnvironmentVariablesParser.cs | 27 - .../Services/EnvironmentVariablesProvider.cs | 36 - CliFx/Services/Extensions.cs | 42 - CliFx/Services/HelpTextRenderer.cs | 368 -------- .../ICommandArgumentSchemasValidator.cs | 16 - CliFx/Services/ICommandFactory.cs | 15 - CliFx/Services/ICommandInitializer.cs | 15 - CliFx/Services/ICommandInputConverter.cs | 22 - CliFx/Services/ICommandInputParser.cs | 16 - CliFx/Services/ICommandSchemaResolver.cs | 22 - CliFx/Services/IEnvironmentVariablesParser.cs | 15 - .../Services/IEnvironmentVariablesProvider.cs | 16 - CliFx/Services/IHelpTextRenderer.cs | 15 - CliFx/{Services => }/SystemConsole.cs | 30 +- CliFx/Utilities/Extensions.cs | 4 +- CliFx/Utilities/ProgressTicker.cs | 5 +- CliFx/{Services => }/VirtualConsole.cs | 8 +- 122 files changed, 3472 insertions(+), 4180 deletions(-) create mode 100644 CliFx.Tests/DefaultCommandFactoryTests.cs create mode 100644 CliFx.Tests/DelegateCommandFactoryTests.cs create mode 100644 CliFx.Tests/Domain/ApplicationSchemaTests.cs create mode 100644 CliFx.Tests/Domain/CommandLineInputTests.cs delete mode 100644 CliFx.Tests/Services/CommandArgumentSchemasValidatorTests.cs delete mode 100644 CliFx.Tests/Services/CommandFactoryTests.cs delete mode 100644 CliFx.Tests/Services/CommandInitializerTests.cs delete mode 100644 CliFx.Tests/Services/CommandInputConverterTests.cs delete mode 100644 CliFx.Tests/Services/CommandInputParserTests.cs delete mode 100644 CliFx.Tests/Services/CommandSchemaResolverTests.cs delete mode 100644 CliFx.Tests/Services/DelegateCommandFactoryTests.cs delete mode 100644 CliFx.Tests/Services/HelpTextRendererTests.cs delete mode 100644 CliFx.Tests/Stubs/EmptyEnvironmentVariablesProviderStub.cs delete mode 100644 CliFx.Tests/Stubs/EnvironmentVariablesProviderStub.cs rename CliFx.Tests/{Services => }/SystemConsoleTests.cs (81%) create mode 100644 CliFx.Tests/TestCommands/AllSupportedTypesCommand.cs delete mode 100644 CliFx.Tests/TestCommands/ArgumentCommand.cs create mode 100644 CliFx.Tests/TestCommands/BrokenEnumerableCommand.cs create mode 100644 CliFx.Tests/TestCommands/DuplicateOptionEnvironmentVariableNamesCommand.cs create mode 100644 CliFx.Tests/TestCommands/DuplicateParameterNameCommand.cs create mode 100644 CliFx.Tests/TestCommands/DuplicateParameterOrderCommand.cs create mode 100644 CliFx.Tests/TestCommands/MultipleNonScalarParametersCommand.cs create mode 100644 CliFx.Tests/TestCommands/NonLastNonScalarParameterCommand.cs create mode 100644 CliFx.Tests/TestCommands/ParameterCommand.cs delete mode 100644 CliFx.Tests/TestCommands/SimpleArgumentCommand.cs create mode 100644 CliFx.Tests/TestCommands/SimpleParameterCommand.cs create mode 100644 CliFx.Tests/TestCustomTypes/TestCustomEnumerable.cs rename CliFx.Tests/{Services => }/VirtualConsoleTests.cs (87%) rename CliFx/{Models => }/ApplicationConfiguration.cs (90%) rename CliFx/{Models => }/ApplicationMetadata.cs (97%) delete mode 100644 CliFx/Attributes/CommandArgumentAttribute.cs create mode 100644 CliFx/Attributes/CommandParameterAttribute.cs create mode 100644 CliFx/CliApplication.Help.cs create mode 100644 CliFx/DefaultTypeActivator.cs create mode 100644 CliFx/DelegateTypeActivator.cs create mode 100644 CliFx/Domain/ApplicationSchema.cs create mode 100644 CliFx/Domain/CommandArgumentSchema.cs create mode 100644 CliFx/Domain/CommandLineInput.cs rename CliFx/{Models => Domain}/CommandOptionInput.cs (56%) create mode 100644 CliFx/Domain/CommandOptionSchema.cs create mode 100644 CliFx/Domain/CommandParameterSchema.cs create mode 100644 CliFx/Domain/CommandSchema.cs delete mode 100644 CliFx/ICliApplication.cs delete mode 100644 CliFx/ICliApplicationBuilder.cs rename CliFx/{Services => }/IConsole.cs (87%) create mode 100644 CliFx/ITypeActivator.cs create mode 100644 CliFx/Internal/Polyfills.cs delete mode 100644 CliFx/Models/CommandArgumentSchema.cs delete mode 100644 CliFx/Models/CommandCandidate.cs delete mode 100644 CliFx/Models/CommandInput.cs delete mode 100644 CliFx/Models/CommandOptionSchema.cs delete mode 100644 CliFx/Models/CommandSchema.cs delete mode 100644 CliFx/Models/Extensions.cs delete mode 100644 CliFx/Models/HelpTextSource.cs delete mode 100644 CliFx/Services/CommandArgumentSchemasValidator.cs delete mode 100644 CliFx/Services/CommandFactory.cs delete mode 100644 CliFx/Services/CommandInitializer.cs delete mode 100644 CliFx/Services/CommandInputConverter.cs delete mode 100644 CliFx/Services/CommandInputParser.cs delete mode 100644 CliFx/Services/CommandSchemaResolver.cs delete mode 100644 CliFx/Services/DelegateCommandFactory.cs delete mode 100644 CliFx/Services/EnvironmentVariablesParser.cs delete mode 100644 CliFx/Services/EnvironmentVariablesProvider.cs delete mode 100644 CliFx/Services/Extensions.cs delete mode 100644 CliFx/Services/HelpTextRenderer.cs delete mode 100644 CliFx/Services/ICommandArgumentSchemasValidator.cs delete mode 100644 CliFx/Services/ICommandFactory.cs delete mode 100644 CliFx/Services/ICommandInitializer.cs delete mode 100644 CliFx/Services/ICommandInputConverter.cs delete mode 100644 CliFx/Services/ICommandInputParser.cs delete mode 100644 CliFx/Services/ICommandSchemaResolver.cs delete mode 100644 CliFx/Services/IEnvironmentVariablesParser.cs delete mode 100644 CliFx/Services/IEnvironmentVariablesProvider.cs delete mode 100644 CliFx/Services/IHelpTextRenderer.cs rename CliFx/{Services => }/SystemConsole.cs (66%) rename CliFx/{Services => }/VirtualConsole.cs (93%) diff --git a/CliFx.Benchmarks/Benchmark.cs b/CliFx.Benchmarks/Benchmark.cs index ac88b01..9bd0603 100644 --- a/CliFx.Benchmarks/Benchmark.cs +++ b/CliFx.Benchmarks/Benchmark.cs @@ -1,35 +1,42 @@ using System.Threading.Tasks; using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Order; using CliFx.Benchmarks.Commands; +using CommandLine; namespace CliFx.Benchmarks { [SimpleJob] [RankColumn] + [Orderer(SummaryOrderPolicy.FastestToSlowest)] public class Benchmark { private static readonly string[] Arguments = {"--str", "hello world", "-i", "13", "-b"}; [Benchmark(Description = "CliFx", Baseline = true)] - public async ValueTask ExecuteWithCliFx() => await new CliApplicationBuilder().AddCommand(typeof(CliFxCommand)).Build().RunAsync(Arguments); + public async ValueTask ExecuteWithCliFx() => + await new CliApplicationBuilder().AddCommand(typeof(CliFxCommand)).Build().RunAsync(Arguments); [Benchmark(Description = "System.CommandLine")] - public async ValueTask ExecuteWithSystemCommandLine() => await new SystemCommandLineCommand().ExecuteAsync(Arguments); + public async Task ExecuteWithSystemCommandLine() => + await new SystemCommandLineCommand().ExecuteAsync(Arguments); [Benchmark(Description = "McMaster.Extensions.CommandLineUtils")] - public int ExecuteWithMcMaster() => McMaster.Extensions.CommandLineUtils.CommandLineApplication.Execute(Arguments); + public int ExecuteWithMcMaster() => + McMaster.Extensions.CommandLineUtils.CommandLineApplication.Execute(Arguments); [Benchmark(Description = "CommandLineParser")] - public void ExecuteWithCommandLineParser() - { - var parsed = new CommandLine.Parser().ParseArguments(Arguments, typeof(CommandLineParserCommand)); - CommandLine.ParserResultExtensions.WithParsed(parsed, c => c.Execute()); - } + public void ExecuteWithCommandLineParser() => + new CommandLine.Parser() + .ParseArguments(Arguments, typeof(CommandLineParserCommand)) + .WithParsed(c => c.Execute()); [Benchmark(Description = "PowerArgs")] - public void ExecuteWithPowerArgs() => PowerArgs.Args.InvokeMain(Arguments); + public void ExecuteWithPowerArgs() => + PowerArgs.Args.InvokeMain(Arguments); [Benchmark(Description = "Clipr")] - public void ExecuteWithClipr() => clipr.CliParser.Parse(Arguments).Execute(); + public void ExecuteWithClipr() => + clipr.CliParser.Parse(Arguments).Execute(); } } \ No newline at end of file diff --git a/CliFx.Benchmarks/CliFx.Benchmarks.csproj b/CliFx.Benchmarks/CliFx.Benchmarks.csproj index a3d0602..95be2ea 100644 --- a/CliFx.Benchmarks/CliFx.Benchmarks.csproj +++ b/CliFx.Benchmarks/CliFx.Benchmarks.csproj @@ -9,8 +9,8 @@ - - + + diff --git a/CliFx.Benchmarks/Commands/CliFxCommand.cs b/CliFx.Benchmarks/Commands/CliFxCommand.cs index a2f1d3a..f543bbf 100644 --- a/CliFx.Benchmarks/Commands/CliFxCommand.cs +++ b/CliFx.Benchmarks/Commands/CliFxCommand.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; using CliFx.Attributes; -using CliFx.Services; namespace CliFx.Benchmarks.Commands { diff --git a/CliFx.Demo/CliFx.Demo.csproj b/CliFx.Demo/CliFx.Demo.csproj index 068f599..f61a92b 100644 --- a/CliFx.Demo/CliFx.Demo.csproj +++ b/CliFx.Demo/CliFx.Demo.csproj @@ -7,7 +7,7 @@ - + diff --git a/CliFx.Demo/Commands/BookAddCommand.cs b/CliFx.Demo/Commands/BookAddCommand.cs index eac2746..a7a397b 100644 --- a/CliFx.Demo/Commands/BookAddCommand.cs +++ b/CliFx.Demo/Commands/BookAddCommand.cs @@ -5,7 +5,6 @@ using CliFx.Demo.Internal; using CliFx.Demo.Models; using CliFx.Demo.Services; using CliFx.Exceptions; -using CliFx.Services; namespace CliFx.Demo.Commands { @@ -14,17 +13,17 @@ namespace CliFx.Demo.Commands { private readonly LibraryService _libraryService; - [CommandArgument(0, Name = "title", IsRequired = true, Description = "Book title.")] - public string Title { get; set; } + [CommandParameter(0, Name = "title", Description = "Book title.")] + public string Title { get; set; } = ""; [CommandOption("author", 'a', IsRequired = true, Description = "Book author.")] - public string Author { get; set; } + public string Author { get; set; } = ""; [CommandOption("published", 'p', Description = "Book publish date.")] - public DateTimeOffset Published { get; set; } + public DateTimeOffset Published { get; set; } = CreateRandomDate(); [CommandOption("isbn", 'n', Description = "Book ISBN.")] - public Isbn? Isbn { get; set; } + public Isbn Isbn { get; set; } = CreateRandomIsbn(); public BookAddCommand(LibraryService libraryService) { @@ -33,12 +32,6 @@ namespace CliFx.Demo.Commands public ValueTask ExecuteAsync(IConsole console) { - // To make the demo simpler, we will just generate random publish date and ISBN if they were not set - if (Published == default) - Published = CreateRandomDate(); - if (Isbn == default) - Isbn = CreateRandomIsbn(); - if (_libraryService.GetBook(Title) != null) throw new CommandException("Book already exists.", 1); @@ -65,7 +58,7 @@ namespace CliFx.Demo.Commands Random.Next(1, 59), TimeSpan.Zero); - public static Isbn CreateRandomIsbn() => new Isbn( + private static Isbn CreateRandomIsbn() => new Isbn( Random.Next(0, 999), Random.Next(0, 99), Random.Next(0, 99999), diff --git a/CliFx.Demo/Commands/BookCommand.cs b/CliFx.Demo/Commands/BookCommand.cs index 8fdb252..1f9954a 100644 --- a/CliFx.Demo/Commands/BookCommand.cs +++ b/CliFx.Demo/Commands/BookCommand.cs @@ -3,7 +3,6 @@ using CliFx.Attributes; using CliFx.Demo.Internal; using CliFx.Demo.Services; using CliFx.Exceptions; -using CliFx.Services; namespace CliFx.Demo.Commands { @@ -12,8 +11,8 @@ namespace CliFx.Demo.Commands { private readonly LibraryService _libraryService; - [CommandOption("title", 't', IsRequired = true, Description = "Book title.")] - public string Title { get; set; } + [CommandParameter(0, Name = "title", Description = "Book title.")] + public string Title { get; set; } = ""; public BookCommand(LibraryService libraryService) { diff --git a/CliFx.Demo/Commands/BookListCommand.cs b/CliFx.Demo/Commands/BookListCommand.cs index 8740727..9ccfbfb 100644 --- a/CliFx.Demo/Commands/BookListCommand.cs +++ b/CliFx.Demo/Commands/BookListCommand.cs @@ -2,7 +2,6 @@ using CliFx.Attributes; using CliFx.Demo.Internal; using CliFx.Demo.Services; -using CliFx.Services; namespace CliFx.Demo.Commands { diff --git a/CliFx.Demo/Commands/BookRemoveCommand.cs b/CliFx.Demo/Commands/BookRemoveCommand.cs index f5239c9..85ba936 100644 --- a/CliFx.Demo/Commands/BookRemoveCommand.cs +++ b/CliFx.Demo/Commands/BookRemoveCommand.cs @@ -2,7 +2,6 @@ using CliFx.Attributes; using CliFx.Demo.Services; using CliFx.Exceptions; -using CliFx.Services; namespace CliFx.Demo.Commands { @@ -11,8 +10,8 @@ namespace CliFx.Demo.Commands { private readonly LibraryService _libraryService; - [CommandOption("title", 't', IsRequired = true, Description = "Book title.")] - public string Title { get; set; } + [CommandParameter(0, Name = "title", Description = "Book title.")] + public string Title { get; set; } = ""; public BookRemoveCommand(LibraryService libraryService) { diff --git a/CliFx.Demo/Internal/Extensions.cs b/CliFx.Demo/Internal/Extensions.cs index c04eaae..2ec9e10 100644 --- a/CliFx.Demo/Internal/Extensions.cs +++ b/CliFx.Demo/Internal/Extensions.cs @@ -1,6 +1,5 @@ using System; using CliFx.Demo.Models; -using CliFx.Services; namespace CliFx.Demo.Internal { diff --git a/CliFx.Demo/Models/Isbn.cs b/CliFx.Demo/Models/Isbn.cs index 5d2532b..c17b8ff 100644 --- a/CliFx.Demo/Models/Isbn.cs +++ b/CliFx.Demo/Models/Isbn.cs @@ -1,5 +1,4 @@ using System; -using System.Globalization; namespace CliFx.Demo.Models { @@ -24,21 +23,23 @@ namespace CliFx.Demo.Models CheckDigit = checkDigit; } - public override string ToString() => $"{EanPrefix:000}-{RegistrationGroup:00}-{Registrant:00000}-{Publication:00}-{CheckDigit:0}"; + public override string ToString() => + $"{EanPrefix:000}-{RegistrationGroup:00}-{Registrant:00000}-{Publication:00}-{CheckDigit:0}"; } public partial class Isbn { - public static Isbn Parse(string value) + public static Isbn Parse(string value, IFormatProvider formatProvider) { var components = value.Split('-', 5, StringSplitOptions.RemoveEmptyEntries); return new Isbn( - int.Parse(components[0], CultureInfo.InvariantCulture), - int.Parse(components[1], CultureInfo.InvariantCulture), - int.Parse(components[2], CultureInfo.InvariantCulture), - int.Parse(components[3], CultureInfo.InvariantCulture), - int.Parse(components[4], CultureInfo.InvariantCulture)); + int.Parse(components[0], formatProvider), + int.Parse(components[1], formatProvider), + int.Parse(components[2], formatProvider), + int.Parse(components[3], formatProvider), + int.Parse(components[4], formatProvider) + ); } } } \ No newline at end of file diff --git a/CliFx.Demo/Program.cs b/CliFx.Demo/Program.cs index 72850d5..4bb0b64 100644 --- a/CliFx.Demo/Program.cs +++ b/CliFx.Demo/Program.cs @@ -8,7 +8,7 @@ namespace CliFx.Demo { public static class Program { - private static IServiceProvider ConfigureServices() + private static IServiceProvider GetServiceProvider() { // We use Microsoft.Extensions.DependencyInjection for injecting dependencies in commands var services = new ServiceCollection(); @@ -25,15 +25,11 @@ namespace CliFx.Demo return services.BuildServiceProvider(); } - public static async Task Main(string[] args) - { - var serviceProvider = ConfigureServices(); - - return await new CliApplicationBuilder() + public static async Task Main() => + await new CliApplicationBuilder() .AddCommandsFromThisAssembly() - .UseCommandFactory(schema => (ICommand) serviceProvider.GetRequiredService(schema.Type)) + .UseTypeActivator(GetServiceProvider().GetService) .Build() - .RunAsync(args); - } + .RunAsync(); } } \ No newline at end of file diff --git a/CliFx.Demo/Readme.md b/CliFx.Demo/Readme.md index 9ef1228..b2b8d54 100644 --- a/CliFx.Demo/Readme.md +++ b/CliFx.Demo/Readme.md @@ -2,6 +2,6 @@ Sample command line interface for managing a library of books. -This demo project shows basic CliFx functionality such as command routing, option parsing, autogenerated help text, and some other things. +This demo project shows basic CliFx functionality such as command routing, argument parsing, autogenerated help text, and some other things. You can get a list of available commands by running `CliFx.Demo --help`. \ No newline at end of file diff --git a/CliFx.Demo/Services/LibraryService.cs b/CliFx.Demo/Services/LibraryService.cs index 08aca7c..2d86e05 100644 --- a/CliFx.Demo/Services/LibraryService.cs +++ b/CliFx.Demo/Services/LibraryService.cs @@ -25,7 +25,7 @@ namespace CliFx.Demo.Services return JsonConvert.DeserializeObject(data); } - public Book GetBook(string title) => GetLibrary().Books.FirstOrDefault(b => b.Title == title); + public Book? GetBook(string title) => GetLibrary().Books.FirstOrDefault(b => b.Title == title); public void AddBook(Book book) { diff --git a/CliFx.Tests/CliApplicationBuilderTests.cs b/CliFx.Tests/CliApplicationBuilderTests.cs index 420b303..f27577f 100644 --- a/CliFx.Tests/CliApplicationBuilderTests.cs +++ b/CliFx.Tests/CliApplicationBuilderTests.cs @@ -1,8 +1,6 @@ using NUnit.Framework; using System; using System.IO; -using CliFx.Services; -using CliFx.Tests.Stubs; using CliFx.Tests.TestCommands; namespace CliFx.Tests @@ -10,9 +8,8 @@ namespace CliFx.Tests [TestFixture] public class CliApplicationBuilderTests { - // Make sure all builder methods work - [Test] - public void All_Smoke_Test() + [Test(Description = "All builder methods must return without exceptions")] + public void Smoke_Test() { // Arrange var builder = new CliApplicationBuilder(); @@ -31,14 +28,11 @@ namespace CliFx.Tests .UseVersionText("test") .UseDescription("test") .UseConsole(new VirtualConsole(TextWriter.Null)) - .UseCommandFactory(schema => (ICommand) Activator.CreateInstance(schema.Type!)!) - .UseCommandOptionInputConverter(new CommandInputConverter()) - .UseEnvironmentVariablesProvider(new EnvironmentVariablesProviderStub()) + .UseTypeActivator(Activator.CreateInstance) .Build(); } - // Make sure builder can produce an application with no parameters specified - [Test] + [Test(Description = "Builder must be able to produce an application when no parameters are specified")] public void Build_Test() { // Arrange diff --git a/CliFx.Tests/CliApplicationTests.cs b/CliFx.Tests/CliApplicationTests.cs index 6d05e48..e4abbdd 100644 --- a/CliFx.Tests/CliApplicationTests.cs +++ b/CliFx.Tests/CliApplicationTests.cs @@ -5,8 +5,6 @@ using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; -using CliFx.Services; -using CliFx.Tests.Stubs; using CliFx.Tests.TestCommands; namespace CliFx.Tests @@ -14,6 +12,7 @@ namespace CliFx.Tests [TestFixture] public class CliApplicationTests { + private const string TestAppName = "TestApp"; private const string TestVersionText = "v1.0"; private static IEnumerable GetTestCases_RunAsync() @@ -21,102 +20,105 @@ namespace CliFx.Tests yield return new TestCaseData( new[] {typeof(HelloWorldDefaultCommand)}, new string[0], + new Dictionary(), "Hello world." ); yield return new TestCaseData( new[] {typeof(ConcatCommand)}, new[] {"concat", "-i", "foo", "-i", "bar", "-s", " "}, + new Dictionary(), "foo bar" ); yield return new TestCaseData( new[] {typeof(ConcatCommand)}, new[] {"concat", "-i", "one", "two", "three", "-s", ", "}, + new Dictionary(), "one, two, three" ); yield return new TestCaseData( new[] {typeof(DivideCommand)}, new[] {"div", "-D", "24", "-d", "8"}, + new Dictionary(), "3" ); yield return new TestCaseData( new[] {typeof(HelloWorldDefaultCommand)}, new[] {"--version"}, + new Dictionary(), TestVersionText ); yield return new TestCaseData( new[] {typeof(ConcatCommand)}, new[] {"--version"}, + new Dictionary(), TestVersionText ); - yield return new TestCaseData( - new[] {typeof(HelloWorldDefaultCommand)}, - new[] {"-h"}, - null - ); - - yield return new TestCaseData( - new[] {typeof(HelloWorldDefaultCommand)}, - new[] {"--help"}, - null - ); - yield return new TestCaseData( new[] {typeof(ConcatCommand)}, new string[0], + new Dictionary(), null ); yield return new TestCaseData( new[] {typeof(ConcatCommand)}, new[] {"-h"}, + new Dictionary(), null ); yield return new TestCaseData( new[] {typeof(ConcatCommand)}, new[] {"--help"}, + new Dictionary(), null ); yield return new TestCaseData( new[] {typeof(ConcatCommand)}, new[] {"concat", "-h"}, + new Dictionary(), null ); yield return new TestCaseData( new[] {typeof(ExceptionCommand)}, new[] {"exc", "-h"}, + new Dictionary(), null ); yield return new TestCaseData( new[] {typeof(CommandExceptionCommand)}, new[] {"exc", "-h"}, + new Dictionary(), null ); yield return new TestCaseData( new[] {typeof(ConcatCommand)}, new[] {"[preview]"}, + new Dictionary(), null ); yield return new TestCaseData( new[] {typeof(ExceptionCommand)}, - new[] {"exc", "[preview]"}, + new[] {"[preview]", "exc"}, + new Dictionary(), null ); yield return new TestCaseData( new[] {typeof(ConcatCommand)}, - new[] {"concat", "[preview]", "-o", "value"}, + new[] {"[preview]", "concat", "-o", "value"}, + new Dictionary(), null ); } @@ -126,109 +128,273 @@ namespace CliFx.Tests yield return new TestCaseData( new Type[0], new string[0], + new Dictionary(), null, null ); yield return new TestCaseData( new[] {typeof(ConcatCommand)}, new[] {"non-existing"}, + new Dictionary(), null, null ); yield return new TestCaseData( new[] {typeof(ExceptionCommand)}, new[] {"exc"}, + new Dictionary(), null, null ); yield return new TestCaseData( new[] {typeof(CommandExceptionCommand)}, new[] {"exc"}, + new Dictionary(), null, null ); yield return new TestCaseData( new[] {typeof(CommandExceptionCommand)}, new[] {"exc"}, + new Dictionary(), null, null ); yield return new TestCaseData( new[] {typeof(CommandExceptionCommand)}, new[] {"exc", "-m", "foo bar"}, + new Dictionary(), "foo bar", null ); yield return new TestCaseData( new[] {typeof(CommandExceptionCommand)}, new[] {"exc", "-m", "foo bar", "-c", "666"}, + new Dictionary(), "foo bar", 666 ); } - [Test] + private static IEnumerable GetTestCases_RunAsync_Help() + { + yield return new TestCaseData( + new[] {typeof(HelpDefaultCommand), typeof(HelpNamedCommand), typeof(HelpSubCommand)}, + new[] {"--help"}, + new[] + { + TestVersionText, + "Description", + "HelpDefaultCommand description.", + "Usage", + TestAppName, "[command]", "[options]", + "Options", + "-a|--option-a", "OptionA description.", + "-b|--option-b", "OptionB description.", + "-h|--help", "Shows help text.", + "--version", "Shows version information.", + "Commands", + "cmd", "HelpNamedCommand description.", + "You can run", "to show help on a specific command." + } + ); + + yield return new TestCaseData( + new[] {typeof(HelpSubCommand)}, + new[] {"--help"}, + new[] + { + TestVersionText, + "Usage", + TestAppName, "[command]", + "Options", + "-h|--help", "Shows help text.", + "--version", "Shows version information.", + "Commands", + "cmd sub", "HelpSubCommand description.", + "You can run", "to show help on a specific command." + } + ); + + yield return new TestCaseData( + new[] {typeof(HelpDefaultCommand), typeof(HelpNamedCommand), typeof(HelpSubCommand)}, + new[] {"cmd", "--help"}, + new[] + { + "Description", + "HelpNamedCommand description.", + "Usage", + TestAppName, "cmd", "[command]", "[options]", + "Options", + "-c|--option-c", "OptionC description.", + "-d|--option-d", "OptionD description.", + "-h|--help", "Shows help text.", + "Commands", + "sub", "HelpSubCommand description.", + "You can run", "to show help on a specific command." + } + ); + + yield return new TestCaseData( + new[] {typeof(HelpDefaultCommand), typeof(HelpNamedCommand), typeof(HelpSubCommand)}, + new[] {"cmd", "sub", "--help"}, + new[] + { + "Description", + "HelpSubCommand description.", + "Usage", + TestAppName, "cmd sub", "[options]", + "Options", + "-e|--option-e", "OptionE description.", + "-h|--help", "Shows help text." + } + ); + + yield return new TestCaseData( + new[] {typeof(ParameterCommand)}, + new[] {"param", "cmd", "--help"}, + new[] + { + "Description", + "Command using positional parameters", + "Usage", + TestAppName, "param cmd", "", "", "", "[options]", + "Parameters", + "* first", + "* PARAMETERB", + "* third list", "A list of numbers", + "Options", + "-o|--option", + "-h|--help", "Shows help text." + } + ); + + yield return new TestCaseData( + new[] {typeof(AllRequiredOptionsCommand)}, + new[] {"allrequired", "--help"}, + new[] + { + "Description", + "AllRequiredOptionsCommand description.", + "Usage", + TestAppName, "allrequired --option-f --option-g " + } + ); + + yield return new TestCaseData( + new[] {typeof(SomeRequiredOptionsCommand)}, + new[] {"somerequired", "--help"}, + new[] + { + "Description", + "SomeRequiredOptionsCommand description.", + "Usage", + TestAppName, "somerequired --option-f [options]" + } + ); + } + [TestCaseSource(nameof(GetTestCases_RunAsync))] - public async Task RunAsync_Test(IReadOnlyList commandTypes, IReadOnlyList commandLineArguments, + public async Task RunAsync_Test( + IReadOnlyList commandTypes, + IReadOnlyList commandLineArguments, + IReadOnlyDictionary environmentVariables, string? expectedStdOut = null) { // Arrange - await using var stdoutStream = new StringWriter(); - - var console = new VirtualConsole(stdoutStream); - var environmentVariablesProvider = new EnvironmentVariablesProviderStub(); + await using var stdOutStream = new StringWriter(); + var console = new VirtualConsole(stdOutStream); var application = new CliApplicationBuilder() .AddCommands(commandTypes) + .UseTitle(TestAppName) + .UseExecutableName(TestAppName) .UseVersionText(TestVersionText) .UseConsole(console) - .UseEnvironmentVariablesProvider(environmentVariablesProvider) .Build(); // Act - var exitCode = await application.RunAsync(commandLineArguments); - var stdOut = stdoutStream.ToString().Trim(); + var exitCode = await application.RunAsync(commandLineArguments, environmentVariables); + var stdOut = stdOutStream.ToString().Trim(); // Assert exitCode.Should().Be(0); + stdOut.Should().NotBeNullOrWhiteSpace(); if (expectedStdOut != null) stdOut.Should().Be(expectedStdOut); - else - stdOut.Should().NotBeNullOrWhiteSpace(); + + Console.WriteLine(stdOut); } - [Test] [TestCaseSource(nameof(GetTestCases_RunAsync_Negative))] - public async Task RunAsync_Negative_Test(IReadOnlyList commandTypes, IReadOnlyList commandLineArguments, - string? expectedStdErr = null, int? expectedExitCode = null) + public async Task RunAsync_Negative_Test( + IReadOnlyList commandTypes, + IReadOnlyList commandLineArguments, + IReadOnlyDictionary environmentVariables, + string? expectedStdErr = null, + int? expectedExitCode = null) { // Arrange - await using var stderrStream = new StringWriter(); - - var console = new VirtualConsole(TextWriter.Null, stderrStream); - var environmentVariablesProvider = new EnvironmentVariablesProviderStub(); + await using var stdErrStream = new StringWriter(); + var console = new VirtualConsole(TextWriter.Null, stdErrStream); var application = new CliApplicationBuilder() .AddCommands(commandTypes) + .UseTitle(TestAppName) + .UseExecutableName(TestAppName) .UseVersionText(TestVersionText) - .UseEnvironmentVariablesProvider(environmentVariablesProvider) .UseConsole(console) .Build(); // Act - var exitCode = await application.RunAsync(commandLineArguments); - var stderr = stderrStream.ToString().Trim(); + var exitCode = await application.RunAsync(commandLineArguments, environmentVariables); + var stderr = stdErrStream.ToString().Trim(); // Assert + exitCode.Should().NotBe(0); + stderr.Should().NotBeNullOrWhiteSpace(); + if (expectedExitCode != null) exitCode.Should().Be(expectedExitCode); - else - exitCode.Should().NotBe(0); if (expectedStdErr != null) stderr.Should().Be(expectedStdErr); - else - stderr.Should().NotBeNullOrWhiteSpace(); + + Console.WriteLine(stderr); + } + + [TestCaseSource(nameof(GetTestCases_RunAsync_Help))] + public async Task RunAsync_Help_Test( + IReadOnlyList commandTypes, + IReadOnlyList commandLineArguments, + IReadOnlyList? expectedSubstrings = null) + { + // Arrange + await using var stdOutStream = new StringWriter(); + var console = new VirtualConsole(stdOutStream); + + var application = new CliApplicationBuilder() + .AddCommands(commandTypes) + .UseTitle(TestAppName) + .UseExecutableName(TestAppName) + .UseVersionText(TestVersionText) + .UseConsole(console) + .Build(); + + var environmentVariables = new Dictionary(); + + // Act + var exitCode = await application.RunAsync(commandLineArguments, environmentVariables); + var stdOut = stdOutStream.ToString().Trim(); + + // Assert + exitCode.Should().Be(0); + stdOut.Should().NotBeNullOrWhiteSpace(); + + if (expectedSubstrings != null) + stdOut.Should().ContainAll(expectedSubstrings); + + Console.WriteLine(stdOut); } [Test] @@ -236,25 +402,31 @@ namespace CliFx.Tests { // Arrange using var cancellationTokenSource = new CancellationTokenSource(); - await using var stdoutStream = new StringWriter(); - var console = new VirtualConsole(stdoutStream, cancellationTokenSource.Token); + await using var stdOutStream = new StringWriter(); + await using var stdErrStream = new StringWriter(); + var console = new VirtualConsole(stdOutStream, stdErrStream, cancellationTokenSource.Token); var application = new CliApplicationBuilder() .AddCommand(typeof(CancellableCommand)) .UseConsole(console) .Build(); - var args = new[] {"cancel"}; + + var commandLineArguments = new[] {"cancel"}; + var environmentVariables = new Dictionary(); // Act - var runTask = application.RunAsync(args); - cancellationTokenSource.Cancel(); - var exitCode = await runTask.ConfigureAwait(false); - var stdOut = stdoutStream.ToString().Trim(); + cancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(0.2)); + var exitCode = await application.RunAsync(commandLineArguments, environmentVariables); + var stdOut = stdOutStream.ToString().Trim(); + var stdErr = stdErrStream.ToString().Trim(); // Assert - exitCode.Should().Be(-2146233029); - stdOut.Should().Be("Printed"); + exitCode.Should().NotBe(0); + stdOut.Should().BeNullOrWhiteSpace(); + stdErr.Should().NotBeNullOrWhiteSpace(); + + Console.WriteLine(stdErr); } } } \ No newline at end of file diff --git a/CliFx.Tests/CliFx.Tests.csproj b/CliFx.Tests/CliFx.Tests.csproj index 46a7546..bc03e41 100644 --- a/CliFx.Tests/CliFx.Tests.csproj +++ b/CliFx.Tests/CliFx.Tests.csproj @@ -11,11 +11,11 @@ - + - - + + diff --git a/CliFx.Tests/DefaultCommandFactoryTests.cs b/CliFx.Tests/DefaultCommandFactoryTests.cs new file mode 100644 index 0000000..8977875 --- /dev/null +++ b/CliFx.Tests/DefaultCommandFactoryTests.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using CliFx.Exceptions; +using CliFx.Tests.TestCommands; +using CliFx.Tests.TestCustomTypes; +using FluentAssertions; +using NUnit.Framework; + +namespace CliFx.Tests +{ + [TestFixture] + public class DefaultCommandFactoryTests + { + private static IEnumerable GetTestCases_CreateInstance() + { + yield return new TestCaseData(typeof(HelloWorldDefaultCommand)); + } + + private static IEnumerable GetTestCases_CreateInstance_Negative() + { + yield return new TestCaseData(typeof(TestNonStringParseable)); + } + + [TestCaseSource(nameof(GetTestCases_CreateInstance))] + public void CreateInstance_Test(Type type) + { + // Arrange + var activator = new DefaultTypeActivator(); + + // Act + var obj = activator.CreateInstance(type); + + // Assert + obj.Should().BeOfType(type); + } + + [TestCaseSource(nameof(GetTestCases_CreateInstance_Negative))] + public void CreateInstance_Negative_Test(Type type) + { + // Arrange + var activator = new DefaultTypeActivator(); + + // Act & Assert + var ex = Assert.Throws(() => activator.CreateInstance(type)); + Console.WriteLine(ex.Message); + } + } +} \ No newline at end of file diff --git a/CliFx.Tests/DelegateCommandFactoryTests.cs b/CliFx.Tests/DelegateCommandFactoryTests.cs new file mode 100644 index 0000000..f57fc83 --- /dev/null +++ b/CliFx.Tests/DelegateCommandFactoryTests.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using CliFx.Tests.TestCommands; +using FluentAssertions; +using NUnit.Framework; + +namespace CliFx.Tests +{ + [TestFixture] + public class DelegateCommandFactoryTests + { + private static IEnumerable GetTestCases_CreateCommand() + { + yield return new TestCaseData( + new Func(Activator.CreateInstance), + typeof(HelloWorldDefaultCommand) + ); + } + + [TestCaseSource(nameof(GetTestCases_CreateCommand))] + public void CreateCommand_Test(Func activatorFunc, Type type) + { + // Arrange + var activator = new DelegateTypeActivator(activatorFunc); + + // Act + var obj = activator.CreateInstance(type); + + // Assert + obj.Should().BeOfType(type); + } + } +} \ No newline at end of file diff --git a/CliFx.Tests/Domain/ApplicationSchemaTests.cs b/CliFx.Tests/Domain/ApplicationSchemaTests.cs new file mode 100644 index 0000000..14a99bd --- /dev/null +++ b/CliFx.Tests/Domain/ApplicationSchemaTests.cs @@ -0,0 +1,888 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using CliFx.Domain; +using CliFx.Exceptions; +using CliFx.Tests.TestCommands; +using CliFx.Tests.TestCustomTypes; +using FluentAssertions; +using NUnit.Framework; + +namespace CliFx.Tests.Domain +{ + [TestFixture] + internal partial class ApplicationSchemaTests + { + private static IEnumerable GetTestCases_Resolve() + { + yield return new TestCaseData( + new[] + { + typeof(DivideCommand), + typeof(ConcatCommand), + typeof(EnvironmentVariableCommand) + }, + new[] + { + new CommandSchema(typeof(DivideCommand), "div", "Divide one number by another.", + new CommandParameterSchema[0], new[] + { + new CommandOptionSchema(typeof(DivideCommand).GetProperty(nameof(DivideCommand.Dividend)), + "dividend", 'D', null, true, "The number to divide."), + new CommandOptionSchema(typeof(DivideCommand).GetProperty(nameof(DivideCommand.Divisor)), + "divisor", 'd', null, true, "The number to divide by.") + }), + new CommandSchema(typeof(ConcatCommand), "concat", "Concatenate strings.", + new CommandParameterSchema[0], + new[] + { + new CommandOptionSchema(typeof(ConcatCommand).GetProperty(nameof(ConcatCommand.Inputs)), + null, 'i', null, true, "Input strings."), + new CommandOptionSchema(typeof(ConcatCommand).GetProperty(nameof(ConcatCommand.Separator)), + null, 's', null, false, "String separator.") + }), + new CommandSchema(typeof(EnvironmentVariableCommand), null, "Reads option values from environment variables.", + new CommandParameterSchema[0], + new[] + { + new CommandOptionSchema(typeof(EnvironmentVariableCommand).GetProperty(nameof(EnvironmentVariableCommand.Option)), + "opt", null, "ENV_SINGLE_VALUE", false, null) + } + ) + } + ); + + yield return new TestCaseData( + new[] {typeof(SimpleParameterCommand)}, + new[] + { + new CommandSchema(typeof(SimpleParameterCommand), "param cmd2", "Command using positional parameters", + new[] + { + new CommandParameterSchema(typeof(SimpleParameterCommand).GetProperty(nameof(SimpleParameterCommand.ParameterA)), + 0, "first", null), + new CommandParameterSchema(typeof(SimpleParameterCommand).GetProperty(nameof(SimpleParameterCommand.ParameterB)), + 10, null, null) + }, + new[] + { + new CommandOptionSchema(typeof(SimpleParameterCommand).GetProperty(nameof(SimpleParameterCommand.OptionA)), + "option", 'o', null, false, null) + }) + } + ); + + yield return new TestCaseData( + new[] {typeof(HelloWorldDefaultCommand)}, + new[] + { + new CommandSchema(typeof(HelloWorldDefaultCommand), null, null, + new CommandParameterSchema[0], + new CommandOptionSchema[0]) + } + ); + } + + private static IEnumerable GetTestCases_Resolve_Negative() + { + yield return new TestCaseData(new object[] + { + new Type[0] + }); + + // Command validation failure + + yield return new TestCaseData(new object[] + { + new[] {typeof(NonImplementedCommand)} + }); + + yield return new TestCaseData(new object[] + { + // Same name + new[] {typeof(ExceptionCommand), typeof(CommandExceptionCommand)} + }); + + yield return new TestCaseData(new object[] + { + new[] {typeof(NonAnnotatedCommand)} + }); + + // Parameter validation failure + + yield return new TestCaseData(new object[] + { + new[] {typeof(DuplicateParameterOrderCommand)} + }); + + yield return new TestCaseData(new object[] + { + new[] {typeof(DuplicateParameterNameCommand)} + }); + + yield return new TestCaseData(new object[] + { + new[] {typeof(MultipleNonScalarParametersCommand)} + }); + + yield return new TestCaseData(new object[] + { + new[] {typeof(NonLastNonScalarParameterCommand)} + }); + + // Option validation failure + + yield return new TestCaseData(new object[] + { + new[] {typeof(DuplicateOptionNamesCommand)} + }); + + yield return new TestCaseData(new object[] + { + new[] {typeof(DuplicateOptionShortNamesCommand)} + }); + + yield return new TestCaseData(new object[] + { + new[] {typeof(DuplicateOptionEnvironmentVariableNamesCommand)} + }); + } + + [Test] + [TestCaseSource(nameof(GetTestCases_Resolve))] + public void Resolve_Test( + IReadOnlyList commandTypes, + IReadOnlyList expectedCommandSchemas) + { + // Act + var applicationSchema = ApplicationSchema.Resolve(commandTypes); + + // Assert + applicationSchema.Commands.Should().BeEquivalentTo(expectedCommandSchemas); + } + + [Test] + [TestCaseSource(nameof(GetTestCases_Resolve_Negative))] + public void Resolve_Negative_Test(IReadOnlyList commandTypes) + { + // Act & Assert + var ex = Assert.Throws(() => ApplicationSchema.Resolve(commandTypes)); + Console.WriteLine(ex.Message); + } + } + + internal partial class ApplicationSchemaTests + { + private static IEnumerable GetTestCases_InitializeEntryPoint() + { + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.Object), "value") + }), + new Dictionary(), + new AllSupportedTypesCommand {Object = "value"} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.String), "value") + }), + new Dictionary(), + new AllSupportedTypesCommand {String = "value"} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.Bool), "true") + }), + new Dictionary(), + new AllSupportedTypesCommand {Bool = true} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.Bool), "false") + }), + new Dictionary(), + new AllSupportedTypesCommand {Bool = false} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.Bool)) + }), + new Dictionary(), + new AllSupportedTypesCommand {Bool = true} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.Char), "a") + }), + new Dictionary(), + new AllSupportedTypesCommand {Char = 'a'} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.Sbyte), "15") + }), + new Dictionary(), + new AllSupportedTypesCommand {Sbyte = 15} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.Byte), "15") + }), + new Dictionary(), + new AllSupportedTypesCommand {Byte = 15} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.Short), "15") + }), + new Dictionary(), + new AllSupportedTypesCommand {Short = 15} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.Ushort), "15") + }), + new Dictionary(), + new AllSupportedTypesCommand {Ushort = 15} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.Int), "15") + }), + new Dictionary(), + new AllSupportedTypesCommand {Int = 15} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.Uint), "15") + }), + new Dictionary(), + new AllSupportedTypesCommand {Uint = 15} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.Long), "15") + }), + new Dictionary(), + new AllSupportedTypesCommand {Long = 15} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.Ulong), "15") + }), + new Dictionary(), + new AllSupportedTypesCommand {Ulong = 15} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.Float), "123.45") + }), + new Dictionary(), + new AllSupportedTypesCommand {Float = 123.45f} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.Double), "123.45") + }), + new Dictionary(), + new AllSupportedTypesCommand {Double = 123.45} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.Decimal), "123.45") + }), + new Dictionary(), + new AllSupportedTypesCommand {Decimal = 123.45m} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.DateTime), "28 Apr 1995") + }), + new Dictionary(), + new AllSupportedTypesCommand {DateTime = new DateTime(1995, 04, 28)} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.DateTimeOffset), "28 Apr 1995") + }), + new Dictionary(), + new AllSupportedTypesCommand {DateTimeOffset = new DateTime(1995, 04, 28)} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.TimeSpan), "00:14:59") + }), + new Dictionary(), + new AllSupportedTypesCommand {TimeSpan = new TimeSpan(00, 14, 59)} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.TestEnum), "value2") + }), + new Dictionary(), + new AllSupportedTypesCommand {TestEnum = TestEnum.Value2} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.IntNullable), "666") + }), + new Dictionary(), + new AllSupportedTypesCommand {IntNullable = 666} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.IntNullable)) + }), + new Dictionary(), + new AllSupportedTypesCommand {IntNullable = null} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.TestEnumNullable), "value3") + }), + new Dictionary(), + new AllSupportedTypesCommand {TestEnumNullable = TestEnum.Value3} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.TestEnumNullable)) + }), + new Dictionary(), + new AllSupportedTypesCommand {TestEnumNullable = null} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.TimeSpanNullable), "01:00:00") + }), + new Dictionary(), + new AllSupportedTypesCommand {TimeSpanNullable = new TimeSpan(01, 00, 00)} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.TimeSpanNullable)) + }), + new Dictionary(), + new AllSupportedTypesCommand {TimeSpanNullable = null} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.TestStringConstructable), "value") + }), + new Dictionary(), + new AllSupportedTypesCommand {TestStringConstructable = new TestStringConstructable("value")} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.TestStringParseable), "value") + }), + new Dictionary(), + new AllSupportedTypesCommand {TestStringParseable = TestStringParseable.Parse("value")} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.TestStringParseableWithFormatProvider), "value") + }), + new Dictionary(), + new AllSupportedTypesCommand + { + TestStringParseableWithFormatProvider = + TestStringParseableWithFormatProvider.Parse("value", CultureInfo.InvariantCulture) + } + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.ObjectArray), new[] {"value1", "value2"}) + }), + new Dictionary(), + new AllSupportedTypesCommand {ObjectArray = new object[] {"value1", "value2"}} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.StringArray), new[] {"value1", "value2"}) + }), + new Dictionary(), + new AllSupportedTypesCommand {StringArray = new[] {"value1", "value2"}} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.StringArray)) + }), + new Dictionary(), + new AllSupportedTypesCommand {StringArray = new string[0]} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.IntArray), new[] {"47", "69"}) + }), + new Dictionary(), + new AllSupportedTypesCommand {IntArray = new[] {47, 69}} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.TestEnumArray), new[] {"value1", "value3"}) + }), + new Dictionary(), + new AllSupportedTypesCommand {TestEnumArray = new[] {TestEnum.Value1, TestEnum.Value3}} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.IntNullableArray), new[] {"1337", "2441"}) + }), + new Dictionary(), + new AllSupportedTypesCommand {IntNullableArray = new int?[] {1337, 2441}} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.TestStringConstructableArray), new[] {"value1", "value2"}) + }), + new Dictionary(), + new AllSupportedTypesCommand + { + TestStringConstructableArray = new[] + { + new TestStringConstructable("value1"), + new TestStringConstructable("value2") + } + } + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.Enumerable), new[] {"value1", "value3"}) + }), + new Dictionary(), + new AllSupportedTypesCommand {Enumerable = new[] {"value1", "value3"}} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.StringEnumerable), new[] {"value1", "value3"}) + }), + new Dictionary(), + new AllSupportedTypesCommand {StringEnumerable = new[] {"value1", "value3"}} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.StringReadOnlyList), new[] {"value1", "value3"}) + }), + new Dictionary(), + new AllSupportedTypesCommand {StringReadOnlyList = new[] {"value1", "value3"}} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.StringList), new[] {"value1", "value3"}) + }), + new Dictionary(), + new AllSupportedTypesCommand {StringList = new List {"value1", "value3"}} + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] + { + new CommandOptionInput(nameof(AllSupportedTypesCommand.StringHashSet), new[] {"value1", "value3"}) + }), + new Dictionary(), + new AllSupportedTypesCommand {StringHashSet = new HashSet {"value1", "value3"}} + ); + + yield return new TestCaseData( + new[] {typeof(DivideCommand)}, + new CommandLineInput( + new[] {"div"}, + new[] + { + new CommandOptionInput("dividend", "13"), + new CommandOptionInput("divisor", "8"), + }), + new Dictionary(), + new DivideCommand {Dividend = 13, Divisor = 8} + ); + + yield return new TestCaseData( + new[] {typeof(DivideCommand)}, + new CommandLineInput( + new[] {"div"}, + new[] + { + new CommandOptionInput("D", "13"), + new CommandOptionInput("d", "8"), + }), + new Dictionary(), + new DivideCommand {Dividend = 13, Divisor = 8} + ); + + yield return new TestCaseData( + new[] {typeof(DivideCommand)}, + new CommandLineInput( + new[] {"div"}, + new[] + { + new CommandOptionInput("dividend", "13"), + new CommandOptionInput("d", "8"), + }), + new Dictionary(), + new DivideCommand {Dividend = 13, Divisor = 8} + ); + + yield return new TestCaseData( + new[] {typeof(ConcatCommand)}, + new CommandLineInput( + new[] {"concat"}, + new[] {new CommandOptionInput("i", new[] {"foo", " ", "bar"}),}), + new Dictionary(), + new ConcatCommand {Inputs = new[] {"foo", " ", "bar"}} + ); + + yield return new TestCaseData( + new[] {typeof(ConcatCommand)}, + new CommandLineInput( + new[] {"concat"}, + new[] + { + new CommandOptionInput("i", new[] {"foo", "bar"}), + new CommandOptionInput("s", " "), + }), + new Dictionary(), + new ConcatCommand {Inputs = new[] {"foo", "bar"}, Separator = " "} + ); + + yield return new TestCaseData( + new[] {typeof(EnvironmentVariableCommand)}, + CommandLineInput.Empty, + new Dictionary + { + ["ENV_SINGLE_VALUE"] = "A" + }, + new EnvironmentVariableCommand {Option = "A"} + ); + + yield return new TestCaseData( + new[] {typeof(EnvironmentVariableWithMultipleValuesCommand)}, + CommandLineInput.Empty, + new Dictionary + { + ["ENV_MULTIPLE_VALUES"] = string.Join(Path.PathSeparator, "A", "B", "C") + }, + new EnvironmentVariableWithMultipleValuesCommand {Option = new[] {"A", "B", "C"}} + ); + + yield return new TestCaseData( + new[] {typeof(EnvironmentVariableCommand)}, + new CommandLineInput(new[] {new CommandOptionInput("opt", "X")}), + new Dictionary + { + ["ENV_SINGLE_VALUE"] = "A" + }, + new EnvironmentVariableCommand {Option = "X"} + ); + + yield return new TestCaseData( + new[] {typeof(EnvironmentVariableWithoutCollectionPropertyCommand)}, + CommandLineInput.Empty, + new Dictionary + { + ["ENV_MULTIPLE_VALUES"] = string.Join(Path.PathSeparator, "A", "B", "C") + }, + new EnvironmentVariableWithoutCollectionPropertyCommand {Option = string.Join(Path.PathSeparator, "A", "B", "C")} + ); + + yield return new TestCaseData( + new[] {typeof(ParameterCommand)}, + new CommandLineInput( + new[] {"param", "cmd", "abc", "123", "1", "2"}, + new[] {new CommandOptionInput("o", "option value")}), + new Dictionary(), + new ParameterCommand + { + ParameterA = "abc", + ParameterB = 123, + ParameterC = new[] {1, 2}, + OptionA = "option value" + } + ); + } + + private static IEnumerable GetTestCases_InitializeEntryPoint_Negative() + { + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] {new CommandOptionInput(nameof(AllSupportedTypesCommand.Int), "1234.5")}), + new Dictionary() + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] {new CommandOptionInput(nameof(AllSupportedTypesCommand.Int), new[] {"123", "456"})}), + new Dictionary() + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] {new CommandOptionInput(nameof(AllSupportedTypesCommand.Int))}), + new Dictionary() + ); + + yield return new TestCaseData( + new[] {typeof(AllSupportedTypesCommand)}, + new CommandLineInput( + new[] {new CommandOptionInput(nameof(AllSupportedTypesCommand.NonConvertible), "123")}), + new Dictionary() + ); + + yield return new TestCaseData( + new[] {typeof(DivideCommand)}, + new CommandLineInput(new[] {"div"}), + new Dictionary() + ); + + yield return new TestCaseData( + new[] {typeof(DivideCommand)}, + new CommandLineInput(new[] {"div", "-D", "13"}), + new Dictionary() + ); + + yield return new TestCaseData( + new[] {typeof(ConcatCommand)}, + new CommandLineInput(new[] {"concat"}), + new Dictionary() + ); + + yield return new TestCaseData( + new[] {typeof(ConcatCommand)}, + new CommandLineInput( + new[] {"concat"}, + new[] {new CommandOptionInput("s", "_")}), + new Dictionary() + ); + + yield return new TestCaseData( + new[] {typeof(ParameterCommand)}, + new CommandLineInput( + new[] {"param", "cmd"}, + new[] {new CommandOptionInput("o", "option value")}), + new Dictionary() + ); + + yield return new TestCaseData( + new[] {typeof(ParameterCommand)}, + new CommandLineInput( + new[] {"param", "cmd", "abc", "123", "invalid"}, + new[] {new CommandOptionInput("o", "option value")}), + new Dictionary() + ); + + yield return new TestCaseData( + new[] {typeof(DivideCommand)}, + new CommandLineInput(new[] {"non-existing"}), + new Dictionary() + ); + + yield return new TestCaseData( + new[] {typeof(BrokenEnumerableCommand)}, + new CommandLineInput(new[] {"value1", "value2"}), + new Dictionary() + ); + } + + [TestCaseSource(nameof(GetTestCases_InitializeEntryPoint))] + public void InitializeEntryPoint_Test( + IReadOnlyList commandTypes, + CommandLineInput commandLineInput, + IReadOnlyDictionary environmentVariables, + ICommand expectedResult) + { + // Arrange + var applicationSchema = ApplicationSchema.Resolve(commandTypes); + var typeActivator = new DefaultTypeActivator(); + + // Act + var command = applicationSchema.InitializeEntryPoint(commandLineInput, environmentVariables, typeActivator); + + // Assert + command.Should().BeEquivalentTo(expectedResult, o => o.RespectingRuntimeTypes()); + } + + [TestCaseSource(nameof(GetTestCases_InitializeEntryPoint_Negative))] + public void InitializeEntryPoint_Negative_Test( + IReadOnlyList commandTypes, + CommandLineInput commandLineInput, + IReadOnlyDictionary environmentVariables) + { + // Arrange + var applicationSchema = ApplicationSchema.Resolve(commandTypes); + var typeActivator = new DefaultTypeActivator(); + + // Act & Assert + var ex = Assert.Throws(() => + applicationSchema.InitializeEntryPoint(commandLineInput, environmentVariables, typeActivator)); + Console.WriteLine(ex.Message); + } + } +} \ No newline at end of file diff --git a/CliFx.Tests/Domain/CommandLineInputTests.cs b/CliFx.Tests/Domain/CommandLineInputTests.cs new file mode 100644 index 0000000..3075e14 --- /dev/null +++ b/CliFx.Tests/Domain/CommandLineInputTests.cs @@ -0,0 +1,264 @@ +using System.Collections.Generic; +using CliFx.Domain; +using FluentAssertions; +using NUnit.Framework; + +namespace CliFx.Tests.Domain +{ + [TestFixture] + internal class CommandLineInputTests + { + private static IEnumerable GetTestCases_Parse() + { + yield return new TestCaseData( + new string[0], + CommandLineInput.Empty + ); + + yield return new TestCaseData( + new[] {"param"}, + new CommandLineInput( + new[] {"param"}) + ); + + yield return new TestCaseData( + new[] {"cmd", "param"}, + new CommandLineInput( + new[] {"cmd", "param"}) + ); + + yield return new TestCaseData( + new[] {"--option", "value"}, + new CommandLineInput( + new[] + { + new CommandOptionInput("option", "value") + }) + ); + + yield return new TestCaseData( + new[] {"--option1", "value1", "--option2", "value2"}, + new CommandLineInput( + new[] + { + new CommandOptionInput("option1", "value1"), + new CommandOptionInput("option2", "value2") + }) + ); + + yield return new TestCaseData( + new[] {"--option", "value1", "value2"}, + new CommandLineInput( + new[] + { + new CommandOptionInput("option", new[] {"value1", "value2"}) + }) + ); + + yield return new TestCaseData( + new[] {"--option", "value1", "--option", "value2"}, + new CommandLineInput( + new[] + { + new CommandOptionInput("option", new[] {"value1", "value2"}) + }) + ); + + yield return new TestCaseData( + new[] {"-a", "value"}, + new CommandLineInput( + new[] + { + new CommandOptionInput("a", "value") + }) + ); + + yield return new TestCaseData( + new[] {"-a", "value1", "-b", "value2"}, + new CommandLineInput( + new[] + { + new CommandOptionInput("a", "value1"), + new CommandOptionInput("b", "value2") + }) + ); + + yield return new TestCaseData( + new[] {"-a", "value1", "value2"}, + new CommandLineInput( + new[] + { + new CommandOptionInput("a", new[] {"value1", "value2"}) + }) + ); + + yield return new TestCaseData( + new[] {"-a", "value1", "-a", "value2"}, + new CommandLineInput( + new[] + { + new CommandOptionInput("a", new[] {"value1", "value2"}) + }) + ); + + yield return new TestCaseData( + new[] {"--option1", "value1", "-b", "value2"}, + new CommandLineInput( + new[] + { + new CommandOptionInput("option1", "value1"), + new CommandOptionInput("b", "value2") + }) + ); + + yield return new TestCaseData( + new[] {"--switch"}, + new CommandLineInput( + new[] + { + new CommandOptionInput("switch") + }) + ); + + yield return new TestCaseData( + new[] {"--switch1", "--switch2"}, + new CommandLineInput( + new[] + { + new CommandOptionInput("switch1"), + new CommandOptionInput("switch2") + }) + ); + + yield return new TestCaseData( + new[] {"-s"}, + new CommandLineInput( + new[] + { + new CommandOptionInput("s") + }) + ); + + yield return new TestCaseData( + new[] {"-a", "-b"}, + new CommandLineInput( + new[] + { + new CommandOptionInput("a"), + new CommandOptionInput("b") + }) + ); + + yield return new TestCaseData( + new[] {"-ab"}, + new CommandLineInput( + new[] + { + new CommandOptionInput("a"), + new CommandOptionInput("b") + }) + ); + + yield return new TestCaseData( + new[] {"-ab", "value"}, + new CommandLineInput( + new[] + { + new CommandOptionInput("a"), + new CommandOptionInput("b", "value") + }) + ); + + yield return new TestCaseData( + new[] {"cmd", "--option", "value"}, + new CommandLineInput( + new[] {"cmd"}, + new[] + { + new CommandOptionInput("option", "value") + }) + ); + + yield return new TestCaseData( + new[] {"[debug]"}, + new CommandLineInput( + new[] {"debug"}, + new string[0], + new CommandOptionInput[0]) + ); + + yield return new TestCaseData( + new[] {"[debug]", "[preview]"}, + new CommandLineInput( + new[] {"debug", "preview"}, + new string[0], + new CommandOptionInput[0]) + ); + + yield return new TestCaseData( + new[] {"cmd", "param1", "param2", "--option", "value"}, + new CommandLineInput( + new[] {"cmd", "param1", "param2"}, + new[] + { + new CommandOptionInput("option", "value") + }) + ); + + yield return new TestCaseData( + new[] {"[debug]", "[preview]", "-o", "value"}, + new CommandLineInput( + new[] {"debug", "preview"}, + new string[0], + new[] + { + new CommandOptionInput("o", "value") + }) + ); + + yield return new TestCaseData( + new[] {"cmd", "[debug]", "[preview]", "-o", "value"}, + new CommandLineInput( + new[] {"debug", "preview"}, + new[] {"cmd"}, + new[] + { + new CommandOptionInput("o", "value") + }) + ); + + yield return new TestCaseData( + new[] {"cmd", "[debug]", "[preview]", "-o", "value"}, + new CommandLineInput( + new[] {"debug", "preview"}, + new[] {"cmd"}, + new[] + { + new CommandOptionInput("o", "value") + }) + ); + + yield return new TestCaseData( + new[] {"cmd", "param", "[debug]", "[preview]", "-o", "value"}, + new CommandLineInput( + new[] {"debug", "preview"}, + new[] {"cmd", "param"}, + new[] + { + new CommandOptionInput("o", "value") + }) + ); + } + + [Test] + [TestCaseSource(nameof(GetTestCases_Parse))] + public void Parse_Test(IReadOnlyList commandLineArguments, CommandLineInput expectedResult) + { + // Act + var result = CommandLineInput.Parse(commandLineArguments); + + // Assert + result.Should().BeEquivalentTo(expectedResult); + } + } +} \ No newline at end of file diff --git a/CliFx.Tests/Services/CommandArgumentSchemasValidatorTests.cs b/CliFx.Tests/Services/CommandArgumentSchemasValidatorTests.cs deleted file mode 100644 index 92f1eb3..0000000 --- a/CliFx.Tests/Services/CommandArgumentSchemasValidatorTests.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using CliFx.Models; -using CliFx.Services; -using FluentAssertions; -using NUnit.Framework; - -namespace CliFx.Tests.Services -{ - [TestFixture] - public class CommandArgumentSchemasValidatorTests - { - private static CommandArgumentSchema GetValidArgumentSchema(string propertyName, string name, bool isRequired, int order, string? description = null) - { - return new CommandArgumentSchema(typeof(TestCommand).GetProperty(propertyName)!, name, isRequired, description, order); - } - - private static IEnumerable GetTestCases_ValidatorTest() - { - // Validation should succeed when no arguments are supplied - yield return new TestCaseData(new ValidatorTest(new List(), true)); - - // Multiple sequence arguments - yield return new TestCaseData(new ValidatorTest( - new [] - { - GetValidArgumentSchema(nameof(TestCommand.EnumerableProperty), "A", false, 0), - GetValidArgumentSchema(nameof(TestCommand.EnumerableProperty), "B", false, 1) - }, false)); - - // Argument after sequence - yield return new TestCaseData(new ValidatorTest( - new [] - { - GetValidArgumentSchema(nameof(TestCommand.EnumerableProperty), "A", false, 0), - GetValidArgumentSchema(nameof(TestCommand.StringProperty), "B", false, 1) - }, false)); - yield return new TestCaseData(new ValidatorTest( - new [] - { - GetValidArgumentSchema(nameof(TestCommand.StringProperty), "B", false, 0), - GetValidArgumentSchema(nameof(TestCommand.EnumerableProperty), "A", false, 1) - }, true)); - - // Required arguments must appear before optional arguments - yield return new TestCaseData(new ValidatorTest( - new [] - { - GetValidArgumentSchema(nameof(TestCommand.StringProperty), "A", false, 0), - GetValidArgumentSchema(nameof(TestCommand.StringProperty), "B", true, 1) - }, false)); - yield return new TestCaseData(new ValidatorTest( - new [] - { - GetValidArgumentSchema(nameof(TestCommand.StringProperty), "A", false, 0), - GetValidArgumentSchema(nameof(TestCommand.StringProperty), "B", true, 1), - GetValidArgumentSchema(nameof(TestCommand.StringProperty), "C", false, 2), - }, false)); - yield return new TestCaseData(new ValidatorTest( - new [] - { - GetValidArgumentSchema(nameof(TestCommand.StringProperty), "A", true, 0), - GetValidArgumentSchema(nameof(TestCommand.StringProperty), "B", false, 1), - GetValidArgumentSchema(nameof(TestCommand.StringProperty), "C", true, 2), - }, false)); - yield return new TestCaseData(new ValidatorTest( - new [] - { - GetValidArgumentSchema(nameof(TestCommand.StringProperty), "A", true, 0), - GetValidArgumentSchema(nameof(TestCommand.StringProperty), "B", false, 1), - GetValidArgumentSchema(nameof(TestCommand.StringProperty), "C", false, 2), - }, true)); - - // Argument order must be unique - yield return new TestCaseData(new ValidatorTest( - new [] - { - GetValidArgumentSchema(nameof(TestCommand.StringProperty), "A", false, 0), - GetValidArgumentSchema(nameof(TestCommand.StringProperty), "B", false, 1), - GetValidArgumentSchema(nameof(TestCommand.StringProperty), "C", false, 2) - }, true)); - yield return new TestCaseData(new ValidatorTest( - new [] - { - GetValidArgumentSchema(nameof(TestCommand.StringProperty), "A", false, 0), - GetValidArgumentSchema(nameof(TestCommand.StringProperty), "B", false, 1), - GetValidArgumentSchema(nameof(TestCommand.StringProperty), "C", false, 1) - }, false)); - - // No arguments with the same name - yield return new TestCaseData(new ValidatorTest( - new [] - { - GetValidArgumentSchema(nameof(TestCommand.StringProperty), "A", false, 0), - GetValidArgumentSchema(nameof(TestCommand.EnumerableProperty), "A", false, 1) - }, false)); - } - - private class TestCommand - { - public IEnumerable EnumerableProperty { get; set; } - public string StringProperty { get; set; } - } - - public class ValidatorTest - { - public ValidatorTest(IReadOnlyCollection schemas, bool succeedsValidation) - { - Schemas = schemas; - SucceedsValidation = succeedsValidation; - } - - public IReadOnlyCollection Schemas { get; } - public bool SucceedsValidation { get; } - } - - [Test] - [TestCaseSource(nameof(GetTestCases_ValidatorTest))] - public void Validation_Test(ValidatorTest testCase) - { - // Arrange - var validator = new CommandArgumentSchemasValidator(); - - // Act - var result = validator.ValidateArgumentSchemas(testCase.Schemas); - - // Assert - result.Any().Should().Be(!testCase.SucceedsValidation); - } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Services/CommandFactoryTests.cs b/CliFx.Tests/Services/CommandFactoryTests.cs deleted file mode 100644 index 14c2b54..0000000 --- a/CliFx.Tests/Services/CommandFactoryTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using CliFx.Models; -using CliFx.Services; -using CliFx.Tests.TestCommands; -using FluentAssertions; -using NUnit.Framework; - -namespace CliFx.Tests.Services -{ - [TestFixture] - public class CommandFactoryTests - { - private static CommandSchema GetCommandSchema(Type commandType) => - new CommandSchemaResolver(new CommandArgumentSchemasValidator()).GetCommandSchemas(new[] {commandType}).Single(); - - private static IEnumerable GetTestCases_CreateCommand() - { - yield return new TestCaseData(GetCommandSchema(typeof(HelloWorldDefaultCommand))); - } - - [Test] - [TestCaseSource(nameof(GetTestCases_CreateCommand))] - public void CreateCommand_Test(CommandSchema commandSchema) - { - // Arrange - var factory = new CommandFactory(); - - // Act - var command = factory.CreateCommand(commandSchema); - - // Assert - command.Should().BeOfType(commandSchema.Type); - } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Services/CommandInitializerTests.cs b/CliFx.Tests/Services/CommandInitializerTests.cs deleted file mode 100644 index f12a478..0000000 --- a/CliFx.Tests/Services/CommandInitializerTests.cs +++ /dev/null @@ -1,245 +0,0 @@ -using FluentAssertions; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Linq; -using CliFx.Exceptions; -using CliFx.Models; -using CliFx.Services; -using CliFx.Tests.TestCommands; -using CliFx.Tests.Stubs; -using System.IO; - -namespace CliFx.Tests.Services -{ - [TestFixture] - public class CommandInitializerTests - { - private static CommandSchema GetCommandSchema(Type commandType) => - new CommandSchemaResolver(new CommandArgumentSchemasValidator()).GetCommandSchemas(new[] { commandType }).Single(); - - private static IEnumerable GetTestCases_InitializeCommand() - { - yield return new TestCaseData( - new DivideCommand(), - new CommandCandidate( - GetCommandSchema(typeof(DivideCommand)), - new string[0], - new CommandInput(new[] { "div" }, new[] - { - new CommandOptionInput("dividend", "13"), - new CommandOptionInput("divisor", "8") - })), - new DivideCommand { Dividend = 13, Divisor = 8 } - ); - - yield return new TestCaseData( - new DivideCommand(), - new CommandCandidate( - GetCommandSchema(typeof(DivideCommand)), - new string[0], - new CommandInput(new[] { "div" }, new[] - { - new CommandOptionInput("dividend", "13"), - new CommandOptionInput("d", "8") - })), - new DivideCommand { Dividend = 13, Divisor = 8 } - ); - - yield return new TestCaseData( - new DivideCommand(), - new CommandCandidate( - GetCommandSchema(typeof(DivideCommand)), - new string[0], - new CommandInput(new[] { "div" }, new[] - { - new CommandOptionInput("D", "13"), - new CommandOptionInput("d", "8") - })), - new DivideCommand { Dividend = 13, Divisor = 8 } - ); - - yield return new TestCaseData( - new ConcatCommand(), - new CommandCandidate( - GetCommandSchema(typeof(ConcatCommand)), - new string[0], - new CommandInput(new[] { "concat" }, new[] - { - new CommandOptionInput("i", new[] { "foo", " ", "bar" }) - })), - new ConcatCommand { Inputs = new[] { "foo", " ", "bar" } } - ); - - yield return new TestCaseData( - new ConcatCommand(), - new CommandCandidate( - GetCommandSchema(typeof(ConcatCommand)), - new string[0], - new CommandInput(new[] { "concat" }, new[] - { - new CommandOptionInput("i", new[] { "foo", "bar" }), - new CommandOptionInput("s", " ") - })), - new ConcatCommand { Inputs = new[] { "foo", "bar" }, Separator = " " } - ); - - //Will read a value from environment variables because none is supplied via CommandInput - yield return new TestCaseData( - new EnvironmentVariableCommand(), - new CommandCandidate( - GetCommandSchema(typeof(EnvironmentVariableCommand)), - new string[0], - new CommandInput(new string[0], new CommandOptionInput[0], EnvironmentVariablesProviderStub.EnvironmentVariables)), - new EnvironmentVariableCommand { Option = "A" } - ); - - //Will read multiple values from environment variables because none is supplied via CommandInput - yield return new TestCaseData( - new EnvironmentVariableWithMultipleValuesCommand(), - new CommandCandidate( - GetCommandSchema(typeof(EnvironmentVariableWithMultipleValuesCommand)), - new string[0], - new CommandInput(new string[0], new CommandOptionInput[0], EnvironmentVariablesProviderStub.EnvironmentVariables)), - new EnvironmentVariableWithMultipleValuesCommand { Option = new[] { "A", "B", "C" } } - ); - - //Will not read a value from environment variables because one is supplied via CommandInput - yield return new TestCaseData( - new EnvironmentVariableCommand(), - new CommandCandidate( - GetCommandSchema(typeof(EnvironmentVariableCommand)), - new string[0], - new CommandInput(new string[0], new[] - { - new CommandOptionInput("opt", new[] { "X" }) - }, - EnvironmentVariablesProviderStub.EnvironmentVariables)), - new EnvironmentVariableCommand { Option = "X" } - ); - - //Will not split environment variable values because underlying property is not a collection - yield return new TestCaseData( - new EnvironmentVariableWithoutCollectionPropertyCommand(), - new CommandCandidate( - GetCommandSchema(typeof(EnvironmentVariableWithoutCollectionPropertyCommand)), - new string[0], - new CommandInput(new string[0], new CommandOptionInput[0], EnvironmentVariablesProviderStub.EnvironmentVariables)), - new EnvironmentVariableWithoutCollectionPropertyCommand { Option = $"A{Path.PathSeparator}B{Path.PathSeparator}C{Path.PathSeparator}" } - ); - - // Positional arguments - yield return new TestCaseData( - new ArgumentCommand(), - new CommandCandidate( - GetCommandSchema(typeof(ArgumentCommand)), - new [] { "abc", "123", "1", "2" }, - new CommandInput(new [] { "arg", "cmd", "abc", "123", "1", "2" }, new []{ new CommandOptionInput("o", "option value") }, new Dictionary())), - new ArgumentCommand { FirstArgument = "abc", SecondArgument = 123, ThirdArguments = new List{1, 2}, Option = "option value" } - ); - yield return new TestCaseData( - new ArgumentCommand(), - new CommandCandidate( - GetCommandSchema(typeof(ArgumentCommand)), - new [] { "abc" }, - new CommandInput(new [] { "arg", "cmd", "abc" }, new []{ new CommandOptionInput("o", "option value") }, new Dictionary())), - new ArgumentCommand { FirstArgument = "abc", Option = "option value" } - ); - } - - private static IEnumerable GetTestCases_InitializeCommand_Negative() - { - yield return new TestCaseData( - new DivideCommand(), - new CommandCandidate( - GetCommandSchema(typeof(DivideCommand)), - new string[0], - new CommandInput(new[] { "div" }) - )); - - yield return new TestCaseData( - new DivideCommand(), - new CommandCandidate( - GetCommandSchema(typeof(DivideCommand)), - new string[0], - new CommandInput(new[] { "div" }, new[] - { - new CommandOptionInput("D", "13") - }) - )); - - yield return new TestCaseData( - new ConcatCommand(), - new CommandCandidate( - GetCommandSchema(typeof(ConcatCommand)), - new string[0], - new CommandInput(new[] { "concat" }) - )); - - yield return new TestCaseData( - new ConcatCommand(), - new CommandCandidate( - GetCommandSchema(typeof(ConcatCommand)), - new string[0], - new CommandInput(new[] { "concat" }, new[] - { - new CommandOptionInput("s", "_") - }) - )); - - // Missing required positional argument - yield return new TestCaseData( - new ArgumentCommand(), - new CommandCandidate( - GetCommandSchema(typeof(ArgumentCommand)), - new string[0], - new CommandInput(new string[0], new []{ new CommandOptionInput("o", "option value") }, new Dictionary())) - ); - - // Incorrect data type in list - yield return new TestCaseData( - new ArgumentCommand(), - new CommandCandidate( - GetCommandSchema(typeof(ArgumentCommand)), - new []{ "abc", "123", "invalid" }, - new CommandInput(new [] { "arg", "cmd", "abc", "123", "invalid" }, new []{ new CommandOptionInput("o", "option value") }, new Dictionary())) - ); - - // Extraneous unused arguments - yield return new TestCaseData( - new SimpleArgumentCommand(), - new CommandCandidate( - GetCommandSchema(typeof(SimpleArgumentCommand)), - new []{ "abc", "123", "unused" }, - new CommandInput(new [] { "arg", "cmd2", "abc", "123", "unused" }, new []{ new CommandOptionInput("o", "option value") }, new Dictionary())) - ); - } - - [Test] - [TestCaseSource(nameof(GetTestCases_InitializeCommand))] - public void InitializeCommand_Test(ICommand command, CommandCandidate commandCandidate, - ICommand expectedCommand) - { - // Arrange - var initializer = new CommandInitializer(); - - // Act - initializer.InitializeCommand(command, commandCandidate); - - // Assert - command.Should().BeEquivalentTo(expectedCommand, o => o.RespectingRuntimeTypes()); - } - - [Test] - [TestCaseSource(nameof(GetTestCases_InitializeCommand_Negative))] - public void InitializeCommand_Negative_Test(ICommand command, CommandCandidate commandCandidate) - { - // Arrange - var initializer = new CommandInitializer(); - - // Act & Assert - initializer.Invoking(i => i.InitializeCommand(command, commandCandidate)) - .Should().ThrowExactly(); - } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Services/CommandInputConverterTests.cs b/CliFx.Tests/Services/CommandInputConverterTests.cs deleted file mode 100644 index 585199d..0000000 --- a/CliFx.Tests/Services/CommandInputConverterTests.cs +++ /dev/null @@ -1,323 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Globalization; -using CliFx.Exceptions; -using CliFx.Models; -using CliFx.Services; -using CliFx.Tests.TestCustomTypes; -using FluentAssertions; -using NUnit.Framework; - -namespace CliFx.Tests.Services -{ - [TestFixture] - public class CommandInputConverterTests - { - private static IEnumerable GetTestCases_ConvertOptionInput() - { - yield return new TestCaseData( - new CommandOptionInput("option", "value"), - typeof(string), - "value" - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "value"), - typeof(object), - "value" - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "true"), - typeof(bool), - true - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "false"), - typeof(bool), - false - ); - - yield return new TestCaseData( - new CommandOptionInput("option"), - typeof(bool), - true - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "a"), - typeof(char), - 'a' - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "15"), - typeof(sbyte), - (sbyte) 15 - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "15"), - typeof(byte), - (byte) 15 - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "15"), - typeof(short), - (short) 15 - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "15"), - typeof(ushort), - (ushort) 15 - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "123"), - typeof(int), - 123 - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "123"), - typeof(uint), - 123u - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "123"), - typeof(long), - 123L - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "123"), - typeof(ulong), - 123UL - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "123.45"), - typeof(float), - 123.45f - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "123.45"), - typeof(double), - 123.45 - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "123.45"), - typeof(decimal), - 123.45m - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "28 Apr 1995"), - typeof(DateTime), - new DateTime(1995, 04, 28) - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "28 Apr 1995"), - typeof(DateTimeOffset), - new DateTimeOffset(new DateTime(1995, 04, 28)) - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "00:14:59"), - typeof(TimeSpan), - new TimeSpan(00, 14, 59) - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "value2"), - typeof(TestEnum), - TestEnum.Value2 - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "666"), - typeof(int?), - 666 - ); - - yield return new TestCaseData( - new CommandOptionInput("option"), - typeof(int?), - null - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "value3"), - typeof(TestEnum?), - TestEnum.Value3 - ); - - yield return new TestCaseData( - new CommandOptionInput("option"), - typeof(TestEnum?), - null - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "01:00:00"), - typeof(TimeSpan?), - new TimeSpan(01, 00, 00) - ); - - yield return new TestCaseData( - new CommandOptionInput("option"), - typeof(TimeSpan?), - null - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "value"), - typeof(TestStringConstructable), - new TestStringConstructable("value") - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "value"), - typeof(TestStringParseable), - TestStringParseable.Parse("value") - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "value"), - typeof(TestStringParseableWithFormatProvider), - TestStringParseableWithFormatProvider.Parse("value", CultureInfo.InvariantCulture) - ); - - yield return new TestCaseData( - new CommandOptionInput("option", new[] {"value1", "value2"}), - typeof(string[]), - new[] {"value1", "value2"} - ); - - yield return new TestCaseData( - new CommandOptionInput("option", new[] {"value1", "value2"}), - typeof(object[]), - new[] {"value1", "value2"} - ); - - yield return new TestCaseData( - new CommandOptionInput("option", new[] {"47", "69"}), - typeof(int[]), - new[] {47, 69} - ); - - yield return new TestCaseData( - new CommandOptionInput("option", new[] {"47"}), - typeof(int[]), - new[] {47} - ); - - yield return new TestCaseData( - new CommandOptionInput("option", new[] {"value1", "value3"}), - typeof(TestEnum[]), - new[] {TestEnum.Value1, TestEnum.Value3} - ); - - yield return new TestCaseData( - new CommandOptionInput("option", new[] {"1337", "2441"}), - typeof(int?[]), - new int?[] {1337, 2441} - ); - - yield return new TestCaseData( - new CommandOptionInput("option", new[] {"value1", "value2"}), - typeof(TestStringConstructable[]), - new[] {new TestStringConstructable("value1"), new TestStringConstructable("value2")} - ); - - yield return new TestCaseData( - new CommandOptionInput("option", new[] {"value1", "value2"}), - typeof(IEnumerable), - new[] {"value1", "value2"} - ); - - yield return new TestCaseData( - new CommandOptionInput("option", new[] {"value1", "value2"}), - typeof(IEnumerable), - new[] {"value1", "value2"} - ); - - yield return new TestCaseData( - new CommandOptionInput("option", new[] {"value1", "value2"}), - typeof(IReadOnlyList), - new[] {"value1", "value2"} - ); - - yield return new TestCaseData( - new CommandOptionInput("option", new[] {"value1", "value2"}), - typeof(List), - new List {"value1", "value2"} - ); - - yield return new TestCaseData( - new CommandOptionInput("option", new[] {"value1", "value2"}), - typeof(HashSet), - new HashSet {"value1", "value2"} - ); - } - - private static IEnumerable GetTestCases_ConvertOptionInput_Negative() - { - yield return new TestCaseData( - new CommandOptionInput("option", "1234.5"), - typeof(int) - ); - - yield return new TestCaseData( - new CommandOptionInput("option", new[] {"123", "456"}), - typeof(int) - ); - - yield return new TestCaseData( - new CommandOptionInput("option"), - typeof(int) - ); - - yield return new TestCaseData( - new CommandOptionInput("option", "123"), - typeof(TestNonStringParseable) - ); - } - - [Test] - [TestCaseSource(nameof(GetTestCases_ConvertOptionInput))] - public void ConvertOptionInput_Test(CommandOptionInput optionInput, Type targetType, - object expectedConvertedValue) - { - // Arrange - var converter = new CommandInputConverter(); - - // Act - var convertedValue = converter.ConvertOptionInput(optionInput, targetType); - - // Assert - convertedValue.Should().BeEquivalentTo(expectedConvertedValue); - convertedValue?.Should().BeAssignableTo(targetType); - } - - [Test] - [TestCaseSource(nameof(GetTestCases_ConvertOptionInput_Negative))] - public void ConvertOptionInput_Negative_Test(CommandOptionInput optionInput, Type targetType) - { - // Arrange - var converter = new CommandInputConverter(); - - // Act & Assert - converter.Invoking(c => c.ConvertOptionInput(optionInput, targetType)) - .Should().ThrowExactly(); - } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Services/CommandInputParserTests.cs b/CliFx.Tests/Services/CommandInputParserTests.cs deleted file mode 100644 index 1d886aa..0000000 --- a/CliFx.Tests/Services/CommandInputParserTests.cs +++ /dev/null @@ -1,255 +0,0 @@ -using FluentAssertions; -using NUnit.Framework; -using System.Collections.Generic; -using CliFx.Models; -using CliFx.Services; -using CliFx.Tests.Stubs; - -namespace CliFx.Tests.Services -{ - [TestFixture] - public class CommandInputParserTests - { - private static IEnumerable GetTestCases_ParseCommandInput() - { - yield return new TestCaseData(new string[0], CommandInput.Empty, new EmptyEnvironmentVariablesProviderStub()); - - yield return new TestCaseData( - new[] { "--option", "value" }, - new CommandInput(new[] - { - new CommandOptionInput("option", "value") - }), - new EmptyEnvironmentVariablesProviderStub() - ); - - yield return new TestCaseData( - new[] { "--option1", "value1", "--option2", "value2" }, - new CommandInput(new[] - { - new CommandOptionInput("option1", "value1"), - new CommandOptionInput("option2", "value2") - }), - new EmptyEnvironmentVariablesProviderStub() - ); - - yield return new TestCaseData( - new[] { "--option", "value1", "value2" }, - new CommandInput(new[] - { - new CommandOptionInput("option", new[] {"value1", "value2"}) - }), - new EmptyEnvironmentVariablesProviderStub() - ); - - yield return new TestCaseData( - new[] { "--option", "value1", "--option", "value2" }, - new CommandInput(new[] - { - new CommandOptionInput("option", new[] {"value1", "value2"}) - }), - new EmptyEnvironmentVariablesProviderStub() - ); - - yield return new TestCaseData( - new[] { "-a", "value" }, - new CommandInput(new[] - { - new CommandOptionInput("a", "value") - }), - new EmptyEnvironmentVariablesProviderStub() - ); - - yield return new TestCaseData( - new[] { "-a", "value1", "-b", "value2" }, - new CommandInput(new[] - { - new CommandOptionInput("a", "value1"), - new CommandOptionInput("b", "value2") - }), - new EmptyEnvironmentVariablesProviderStub() - ); - - yield return new TestCaseData( - new[] { "-a", "value1", "value2" }, - new CommandInput(new[] - { - new CommandOptionInput("a", new[] {"value1", "value2"}) - }), - new EmptyEnvironmentVariablesProviderStub() - ); - - yield return new TestCaseData( - new[] { "-a", "value1", "-a", "value2" }, - new CommandInput(new[] - { - new CommandOptionInput("a", new[] {"value1", "value2"}) - }), - new EmptyEnvironmentVariablesProviderStub() - ); - - yield return new TestCaseData( - new[] { "--option1", "value1", "-b", "value2" }, - new CommandInput(new[] - { - new CommandOptionInput("option1", "value1"), - new CommandOptionInput("b", "value2") - }), - new EmptyEnvironmentVariablesProviderStub() - ); - - yield return new TestCaseData( - new[] { "--switch" }, - new CommandInput(new[] - { - new CommandOptionInput("switch") - }), - new EmptyEnvironmentVariablesProviderStub() - ); - - yield return new TestCaseData( - new[] { "--switch1", "--switch2" }, - new CommandInput(new[] - { - new CommandOptionInput("switch1"), - new CommandOptionInput("switch2") - }), - new EmptyEnvironmentVariablesProviderStub() - ); - - yield return new TestCaseData( - new[] { "-s" }, - new CommandInput(new[] - { - new CommandOptionInput("s") - }), - new EmptyEnvironmentVariablesProviderStub() - ); - - yield return new TestCaseData( - new[] { "-a", "-b" }, - new CommandInput(new[] - { - new CommandOptionInput("a"), - new CommandOptionInput("b") - }), - new EmptyEnvironmentVariablesProviderStub() - ); - - yield return new TestCaseData( - new[] { "-ab" }, - new CommandInput(new[] - { - new CommandOptionInput("a"), - new CommandOptionInput("b") - }), - new EmptyEnvironmentVariablesProviderStub() - ); - - yield return new TestCaseData( - new[] { "-ab", "value" }, - new CommandInput(new[] - { - new CommandOptionInput("a"), - new CommandOptionInput("b", "value") - }), - new EmptyEnvironmentVariablesProviderStub() - ); - - yield return new TestCaseData( - new[] { "command" }, - new CommandInput(new []{ "command" }), - new EmptyEnvironmentVariablesProviderStub() - ); - - yield return new TestCaseData( - new[] { "command", "--option", "value" }, - new CommandInput(new []{ "command" }, new[] - { - new CommandOptionInput("option", "value") - }), - new EmptyEnvironmentVariablesProviderStub() - ); - - yield return new TestCaseData( - new[] { "long", "command", "name" }, - new CommandInput(new []{ "long", "command", "name"}), - new EmptyEnvironmentVariablesProviderStub() - ); - - yield return new TestCaseData( - new[] { "long", "command", "name", "--option", "value" }, - new CommandInput(new []{ "long", "command", "name" }, new[] - { - new CommandOptionInput("option", "value") - }), - new EmptyEnvironmentVariablesProviderStub() - ); - - yield return new TestCaseData( - new[] { "[debug]" }, - new CommandInput(new string[0], - new[] { "debug" }, - new CommandOptionInput[0]), - new EmptyEnvironmentVariablesProviderStub() - ); - - yield return new TestCaseData( - new[] { "[debug]", "[preview]" }, - new CommandInput(new string[0], - new[] { "debug", "preview" }, - new CommandOptionInput[0]), - new EmptyEnvironmentVariablesProviderStub() - ); - - yield return new TestCaseData( - new[] { "[debug]", "[preview]", "-o", "value" }, - new CommandInput(new string[0], - new[] { "debug", "preview" }, - new[] - { - new CommandOptionInput("o", "value") - }), - new EmptyEnvironmentVariablesProviderStub() - ); - - yield return new TestCaseData( - new[] { "command", "[debug]", "[preview]", "-o", "value" }, - new CommandInput(new []{"command"}, - new[] { "debug", "preview" }, - new[] - { - new CommandOptionInput("o", "value") - }), - new EmptyEnvironmentVariablesProviderStub() - ); - - yield return new TestCaseData( - new[] { "command", "[debug]", "[preview]", "-o", "value" }, - new CommandInput(new []{ "command"}, - new[] { "debug", "preview" }, - new[] - { - new CommandOptionInput("o", "value") - }, - EnvironmentVariablesProviderStub.EnvironmentVariables), - new EnvironmentVariablesProviderStub() - ); - } - - [Test] - [TestCaseSource(nameof(GetTestCases_ParseCommandInput))] - public void ParseCommandInput_Test(IReadOnlyList commandLineArguments, - CommandInput expectedCommandInput, IEnvironmentVariablesProvider environmentVariablesProvider) - { - // Arrange - var parser = new CommandInputParser(environmentVariablesProvider); - - // Act - var commandInput = parser.ParseCommandInput(commandLineArguments); - - // Assert - commandInput.Should().BeEquivalentTo(expectedCommandInput); - } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Services/CommandSchemaResolverTests.cs b/CliFx.Tests/Services/CommandSchemaResolverTests.cs deleted file mode 100644 index 3203634..0000000 --- a/CliFx.Tests/Services/CommandSchemaResolverTests.cs +++ /dev/null @@ -1,306 +0,0 @@ -using FluentAssertions; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using CliFx.Exceptions; -using CliFx.Models; -using CliFx.Services; -using CliFx.Tests.TestCommands; - -namespace CliFx.Tests.Services -{ - [TestFixture] - public class CommandSchemaResolverTests - { - private static IEnumerable GetTestCases_GetCommandSchemas() - { - yield return new TestCaseData( - new[] { typeof(DivideCommand), typeof(ConcatCommand), typeof(EnvironmentVariableCommand) }, - new[] - { - new CommandSchema(typeof(DivideCommand), "div", "Divide one number by another.", - new CommandArgumentSchema[0], new[] - { - new CommandOptionSchema(typeof(DivideCommand).GetProperty(nameof(DivideCommand.Dividend)), - "dividend", 'D', true, "The number to divide.", null), - new CommandOptionSchema(typeof(DivideCommand).GetProperty(nameof(DivideCommand.Divisor)), - "divisor", 'd', true, "The number to divide by.", null) - }), - new CommandSchema(typeof(ConcatCommand), "concat", "Concatenate strings.", - new CommandArgumentSchema[0], - new[] - { - new CommandOptionSchema(typeof(ConcatCommand).GetProperty(nameof(ConcatCommand.Inputs)), - null, 'i', true, "Input strings.", null), - new CommandOptionSchema(typeof(ConcatCommand).GetProperty(nameof(ConcatCommand.Separator)), - null, 's', false, "String separator.", null) - }), - new CommandSchema(typeof(EnvironmentVariableCommand), null, "Reads option values from environment variables.", - new CommandArgumentSchema[0], - new[] - { - new CommandOptionSchema(typeof(EnvironmentVariableCommand).GetProperty(nameof(EnvironmentVariableCommand.Option)), - "opt", null, false, null, "ENV_SINGLE_VALUE") - } - ) - } - ); - - yield return new TestCaseData( - new[] { typeof(HelloWorldDefaultCommand) }, - new[] - { - new CommandSchema(typeof(HelloWorldDefaultCommand), null, null, new CommandArgumentSchema[0], new CommandOptionSchema[0]) - } - ); - } - - private static IEnumerable GetTestCases_GetCommandSchemas_Negative() - { - yield return new TestCaseData(new object[] - { - new Type[0] - }); - - yield return new TestCaseData(new object[] - { - new[] { typeof(NonImplementedCommand) } - }); - - yield return new TestCaseData(new object[] - { - new[] { typeof(NonAnnotatedCommand) } - }); - - yield return new TestCaseData(new object[] - { - new[] { typeof(DuplicateOptionNamesCommand) } - }); - - yield return new TestCaseData(new object[] - { - new[] { typeof(DuplicateOptionShortNamesCommand) } - }); - - yield return new TestCaseData(new object[] - { - new[] { typeof(ExceptionCommand), typeof(CommandExceptionCommand) } - }); - } - - private static IEnumerable GetTestCases_GetTargetCommandSchema_Positive() - { - yield return new TestCaseData( - new [] - { - new CommandSchema(null, "command1", null, null, null), - new CommandSchema(null, "command2", null, null, null), - new CommandSchema(null, "command3", null, null, null) - }, - new CommandInput(new[] { "command1", "argument1", "argument2" }), - new[] { "argument1", "argument2" }, - "command1" - ); - yield return new TestCaseData( - new [] - { - new CommandSchema(null, "", null, null, null), - new CommandSchema(null, "command1", null, null, null), - new CommandSchema(null, "command2", null, null, null), - new CommandSchema(null, "command3", null, null, null) - }, - new CommandInput(new[] { "argument1", "argument2" }), - new[] { "argument1", "argument2" }, - "" - ); - yield return new TestCaseData( - new [] - { - new CommandSchema(null, "command1 subcommand1", null, null, null), - }, - new CommandInput(new[] { "command1", "subcommand1", "argument1" }), - new[] { "argument1" }, - "command1 subcommand1" - ); - yield return new TestCaseData( - new [] - { - new CommandSchema(null, "", null, null, null), - new CommandSchema(null, "a", null, null, null), - new CommandSchema(null, "a b", null, null, null), - new CommandSchema(null, "a b c", null, null, null), - new CommandSchema(null, "b", null, null, null), - new CommandSchema(null, "b c", null, null, null), - new CommandSchema(null, "c", null, null, null), - }, - new CommandInput(new[] { "a", "b", "d" }), - new[] { "d" }, - "a b" - ); - yield return new TestCaseData( - new [] - { - new CommandSchema(null, "", null, null, null), - new CommandSchema(null, "a", null, null, null), - new CommandSchema(null, "a b", null, null, null), - new CommandSchema(null, "a b c", null, null, null), - new CommandSchema(null, "b", null, null, null), - new CommandSchema(null, "b c", null, null, null), - new CommandSchema(null, "c", null, null, null), - }, - new CommandInput(new[] { "a", "b", "c", "d" }), - new[] { "d" }, - "a b c" - ); - yield return new TestCaseData( - new [] - { - new CommandSchema(null, "", null, null, null), - new CommandSchema(null, "a", null, null, null), - new CommandSchema(null, "a b", null, null, null), - new CommandSchema(null, "a b c", null, null, null), - new CommandSchema(null, "b", null, null, null), - new CommandSchema(null, "b c", null, null, null), - new CommandSchema(null, "c", null, null, null), - }, - new CommandInput(new[] { "b", "c" }), - new string[0], - "b c" - ); - yield return new TestCaseData( - new [] - { - new CommandSchema(null, "", null, null, null), - new CommandSchema(null, "a", null, null, null), - new CommandSchema(null, "a b", null, null, null), - new CommandSchema(null, "a b c", null, null, null), - new CommandSchema(null, "b", null, null, null), - new CommandSchema(null, "b c", null, null, null), - new CommandSchema(null, "c", null, null, null), - }, - new CommandInput(new[] { "d", "a", "b"}), - new[] { "d", "a", "b" }, - "" - ); - yield return new TestCaseData( - new [] - { - new CommandSchema(null, "", null, null, null), - new CommandSchema(null, "a", null, null, null), - new CommandSchema(null, "a b", null, null, null), - new CommandSchema(null, "a b c", null, null, null), - new CommandSchema(null, "b", null, null, null), - new CommandSchema(null, "b c", null, null, null), - new CommandSchema(null, "c", null, null, null), - }, - new CommandInput(new[] { "a", "b c", "d" }), - new[] { "b c", "d" }, - "a" - ); - yield return new TestCaseData( - new [] - { - new CommandSchema(null, "", null, null, null), - new CommandSchema(null, "a", null, null, null), - new CommandSchema(null, "a b", null, null, null), - new CommandSchema(null, "a b c", null, null, null), - new CommandSchema(null, "b", null, null, null), - new CommandSchema(null, "b c", null, null, null), - new CommandSchema(null, "c", null, null, null), - }, - new CommandInput(new[] { "a b", "c", "d" }), - new[] { "a b", "c", "d" }, - "" - ); - } - - private static IEnumerable GetTestCases_GetTargetCommandSchema_Negative() - { - yield return new TestCaseData( - new [] - { - new CommandSchema(null, "command1", null, null, null), - new CommandSchema(null, "command2", null, null, null), - new CommandSchema(null, "command3", null, null, null), - }, - new CommandInput(new[] { "command4", "argument1" }) - ); - yield return new TestCaseData( - new [] - { - new CommandSchema(null, "command1", null, null, null), - new CommandSchema(null, "command2", null, null, null), - new CommandSchema(null, "command3", null, null, null), - }, - new CommandInput(new[] { "argument1" }) - ); - yield return new TestCaseData( - new [] - { - new CommandSchema(null, "command1 subcommand1", null, null, null), - }, - new CommandInput(new[] { "command1", "argument1" }) - ); - } - - [Test] - [TestCaseSource(nameof(GetTestCases_GetCommandSchemas))] - public void GetCommandSchemas_Test(IReadOnlyList commandTypes, - IReadOnlyList expectedCommandSchemas) - { - // Arrange - var commandSchemaResolver = new CommandSchemaResolver(new CommandArgumentSchemasValidator()); - - // Act - var commandSchemas = commandSchemaResolver.GetCommandSchemas(commandTypes); - - // Assert - commandSchemas.Should().BeEquivalentTo(expectedCommandSchemas); - } - - [Test] - [TestCaseSource(nameof(GetTestCases_GetCommandSchemas_Negative))] - public void GetCommandSchemas_Negative_Test(IReadOnlyList commandTypes) - { - // Arrange - var resolver = new CommandSchemaResolver(new CommandArgumentSchemasValidator()); - - // Act & Assert - resolver.Invoking(r => r.GetCommandSchemas(commandTypes)) - .Should().ThrowExactly(); - } - - [Test] - [TestCaseSource(nameof(GetTestCases_GetTargetCommandSchema_Positive))] - public void GetTargetCommandSchema_Positive_Test(IReadOnlyList availableCommandSchemas, - CommandInput commandInput, - IReadOnlyList expectedPositionalArguments, - string expectedCommandSchemaName) - { - // Arrange - var resolver = new CommandSchemaResolver(new CommandArgumentSchemasValidator()); - - // Act - var commandCandidate = resolver.GetTargetCommandSchema(availableCommandSchemas, commandInput); - - // Assert - commandCandidate.Should().NotBeNull(); - commandCandidate.PositionalArgumentsInput.Should().BeEquivalentTo(expectedPositionalArguments); - commandCandidate.Schema.Name.Should().Be(expectedCommandSchemaName); - } - - [Test] - [TestCaseSource(nameof(GetTestCases_GetTargetCommandSchema_Negative))] - public void GetTargetCommandSchema_Negative_Test(IReadOnlyList availableCommandSchemas, CommandInput commandInput) - { - // Arrange - var resolver = new CommandSchemaResolver(new CommandArgumentSchemasValidator()); - - // Act - var commandCandidate = resolver.GetTargetCommandSchema(availableCommandSchemas, commandInput); - - // Assert - commandCandidate.Should().BeNull(); - } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Services/DelegateCommandFactoryTests.cs b/CliFx.Tests/Services/DelegateCommandFactoryTests.cs deleted file mode 100644 index 8bc13be..0000000 --- a/CliFx.Tests/Services/DelegateCommandFactoryTests.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using CliFx.Models; -using CliFx.Services; -using CliFx.Tests.TestCommands; -using FluentAssertions; -using NUnit.Framework; - -namespace CliFx.Tests.Services -{ - [TestFixture] - public class DelegateCommandFactoryTests - { - private static CommandSchema GetCommandSchema(Type commandType) => - new CommandSchemaResolver(new CommandArgumentSchemasValidator()).GetCommandSchemas(new[] {commandType}).Single(); - - private static IEnumerable GetTestCases_CreateCommand() - { - yield return new TestCaseData( - new Func(schema => (ICommand) Activator.CreateInstance(schema.Type!)!), - GetCommandSchema(typeof(HelloWorldDefaultCommand)) - ); - } - - [Test] - [TestCaseSource(nameof(GetTestCases_CreateCommand))] - public void CreateCommand_Test(Func factoryMethod, CommandSchema commandSchema) - { - // Arrange - var factory = new DelegateCommandFactory(factoryMethod); - - // Act - var command = factory.CreateCommand(commandSchema); - - // Assert - command.Should().BeOfType(commandSchema.Type); - } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Services/HelpTextRendererTests.cs b/CliFx.Tests/Services/HelpTextRendererTests.cs deleted file mode 100644 index f8ebb8a..0000000 --- a/CliFx.Tests/Services/HelpTextRendererTests.cs +++ /dev/null @@ -1,177 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using CliFx.Models; -using CliFx.Services; -using CliFx.Tests.TestCommands; -using FluentAssertions; -using NUnit.Framework; - -namespace CliFx.Tests.Services -{ - [TestFixture] - public class HelpTextRendererTests - { - private static HelpTextSource CreateHelpTextSource(IReadOnlyList availableCommandTypes, Type targetCommandType) - { - var commandSchemaResolver = new CommandSchemaResolver(new CommandArgumentSchemasValidator()); - - var applicationMetadata = new ApplicationMetadata("TestApp", "testapp", "1.0", null); - var availableCommandSchemas = commandSchemaResolver.GetCommandSchemas(availableCommandTypes); - var targetCommandSchema = availableCommandSchemas.Single(s => s.Type == targetCommandType); - - return new HelpTextSource(applicationMetadata, availableCommandSchemas, targetCommandSchema); - } - - private static IEnumerable GetTestCases_RenderHelpText() - { - yield return new TestCaseData( - CreateHelpTextSource( - new[] {typeof(HelpDefaultCommand), typeof(HelpNamedCommand), typeof(HelpSubCommand)}, - typeof(HelpDefaultCommand)), - - new[] - { - "Description", - "HelpDefaultCommand description.", - "Usage", - "[command]", "[options]", - "Options", - "-a|--option-a", "OptionA description.", - "-b|--option-b", "OptionB description.", - "-h|--help", "Shows help text.", - "--version", "Shows version information.", - "Commands", - "cmd", "HelpNamedCommand description.", - "You can run", "to show help on a specific command." - }, - - new string[0] - ); - - yield return new TestCaseData( - CreateHelpTextSource( - new[] {typeof(HelpDefaultCommand), typeof(HelpNamedCommand), typeof(HelpSubCommand)}, - typeof(HelpNamedCommand)), - - new[] - { - "Description", - "HelpNamedCommand description.", - "Usage", - "cmd", "[command]", "[options]", - "Options", - "-c|--option-c", "OptionC description.", - "-d|--option-d", "OptionD description.", - "-h|--help", "Shows help text.", - "Commands", - "sub", "HelpSubCommand description.", - "You can run", "to show help on a specific command." - }, - - new string[0] - ); - - yield return new TestCaseData( - CreateHelpTextSource( - new[] {typeof(HelpDefaultCommand), typeof(HelpNamedCommand), typeof(HelpSubCommand)}, - typeof(HelpSubCommand)), - - new[] - { - "Description", - "HelpSubCommand description.", - "Usage", - "cmd sub", "[options]", - "Options", - "-e|--option-e", "OptionE description.", - "-h|--help", "Shows help text." - }, - - new string[0] - ); - - yield return new TestCaseData( - CreateHelpTextSource( - new[] {typeof(ArgumentCommand)}, - typeof(ArgumentCommand)), - - new[] - { - "Description", - "Command using positional arguments", - "Usage", - "arg cmd", "", "[]", "[]", "[options]", - "Arguments", - "* first", - "secondargument", - "third list", "A list of numbers", - "Options", - "-o|--option", - "-h|--help", "Shows help text." - }, - - new string[0] - ); - - yield return new TestCaseData( - CreateHelpTextSource( - new[] { typeof(AllRequiredOptionsCommand) }, - typeof(AllRequiredOptionsCommand)), - - new[] - { - "Description", - "AllRequiredOptionsCommand description.", - "Usage", - "testapp allrequired --option-f --option-g " - }, - - new [] - { - "[options]" - } - ); - - yield return new TestCaseData( - CreateHelpTextSource( - new[] { typeof(SomeRequiredOptionsCommand) }, - typeof(SomeRequiredOptionsCommand)), - - new[] - { - "Description", - "SomeRequiredOptionsCommand description.", - "Usage", - "testapp somerequired --option-f [options]" - }, - - new string[0] - ); - } - - [Test] - [TestCaseSource(nameof(GetTestCases_RenderHelpText))] - public void RenderHelpText_Test(HelpTextSource source, - IReadOnlyList expectedSubstrings, - IReadOnlyList notExpectedSubstrings) - { - // Arrange - using var stdout = new StringWriter(); - - var console = new VirtualConsole(stdout); - var renderer = new HelpTextRenderer(); - - // Act - renderer.RenderHelpText(console, source); - - // Assert - stdout.ToString().Should().ContainAll(expectedSubstrings); - if (notExpectedSubstrings != null && notExpectedSubstrings.Any()) - { - stdout.ToString().Should().NotContainAll(notExpectedSubstrings); - } - } - } -} \ No newline at end of file diff --git a/CliFx.Tests/Stubs/EmptyEnvironmentVariablesProviderStub.cs b/CliFx.Tests/Stubs/EmptyEnvironmentVariablesProviderStub.cs deleted file mode 100644 index 6a30403..0000000 --- a/CliFx.Tests/Stubs/EmptyEnvironmentVariablesProviderStub.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Collections.Generic; -using CliFx.Services; - -namespace CliFx.Tests.Stubs -{ - public class EmptyEnvironmentVariablesProviderStub : IEnvironmentVariablesProvider - { - public IReadOnlyDictionary GetEnvironmentVariables() => new Dictionary(); - } -} diff --git a/CliFx.Tests/Stubs/EnvironmentVariablesProviderStub.cs b/CliFx.Tests/Stubs/EnvironmentVariablesProviderStub.cs deleted file mode 100644 index 26e82df..0000000 --- a/CliFx.Tests/Stubs/EnvironmentVariablesProviderStub.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using CliFx.Services; - -namespace CliFx.Tests.Stubs -{ - public class EnvironmentVariablesProviderStub : IEnvironmentVariablesProvider - { - public static readonly Dictionary EnvironmentVariables = new Dictionary - { - ["ENV_SINGLE_VALUE"] = "A", - ["ENV_MULTIPLE_VALUES"] = $"A{Path.PathSeparator}B{Path.PathSeparator}C{Path.PathSeparator}", - ["ENV_ESCAPED_MULTIPLE_VALUES"] = $"\"A{Path.PathSeparator}B{Path.PathSeparator}C{Path.PathSeparator}\"" - }; - - public IReadOnlyDictionary GetEnvironmentVariables() => EnvironmentVariables; - } -} diff --git a/CliFx.Tests/Services/SystemConsoleTests.cs b/CliFx.Tests/SystemConsoleTests.cs similarity index 81% rename from CliFx.Tests/Services/SystemConsoleTests.cs rename to CliFx.Tests/SystemConsoleTests.cs index 031ab50..80c1e6b 100644 --- a/CliFx.Tests/Services/SystemConsoleTests.cs +++ b/CliFx.Tests/SystemConsoleTests.cs @@ -1,9 +1,8 @@ using System; -using CliFx.Services; using FluentAssertions; using NUnit.Framework; -namespace CliFx.Tests.Services +namespace CliFx.Tests { [TestFixture] public class SystemConsoleTests @@ -11,13 +10,12 @@ namespace CliFx.Tests.Services [TearDown] public void TearDown() { - // Reset console color so it doesn't carry on into next tests + // Reset console color so it doesn't carry on into the next tests Console.ResetColor(); } - - // Make sure console correctly wraps around System.Console - [Test] - public void All_Smoke_Test() + + [Test(Description = "Must be in sync with system console")] + public void Smoke_Test() { // Arrange var console = new SystemConsole(); diff --git a/CliFx.Tests/TestCommands/AllRequiredOptionsCommand.cs b/CliFx.Tests/TestCommands/AllRequiredOptionsCommand.cs index 4078945..98b6036 100644 --- a/CliFx.Tests/TestCommands/AllRequiredOptionsCommand.cs +++ b/CliFx.Tests/TestCommands/AllRequiredOptionsCommand.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; using CliFx.Attributes; -using CliFx.Services; namespace CliFx.Tests.TestCommands { diff --git a/CliFx.Tests/TestCommands/AllSupportedTypesCommand.cs b/CliFx.Tests/TestCommands/AllSupportedTypesCommand.cs new file mode 100644 index 0000000..291e6f4 --- /dev/null +++ b/CliFx.Tests/TestCommands/AllSupportedTypesCommand.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Threading.Tasks; +using CliFx.Attributes; +using CliFx.Tests.TestCustomTypes; + +namespace CliFx.Tests.TestCommands +{ + [Command] + public class AllSupportedTypesCommand : ICommand + { + [CommandOption(nameof(Object))] + public object? Object { get; set; } = 42; + + [CommandOption(nameof(String))] + public string? String { get; set; } = "foo bar"; + + [CommandOption(nameof(Bool))] + public bool Bool { get; set; } + + [CommandOption(nameof(Char))] + public char Char { get; set; } + + [CommandOption(nameof(Sbyte))] + public sbyte Sbyte { get; set; } + + [CommandOption(nameof(Byte))] + public byte Byte { get; set; } + + [CommandOption(nameof(Short))] + public short Short { get; set; } + + [CommandOption(nameof(Ushort))] + public ushort Ushort { get; set; } + + [CommandOption(nameof(Int))] + public int Int { get; set; } + + [CommandOption(nameof(Uint))] + public uint Uint { get; set; } + + [CommandOption(nameof(Long))] + public long Long { get; set; } + + [CommandOption(nameof(Ulong))] + public ulong Ulong { get; set; } + + [CommandOption(nameof(Float))] + public float Float { get; set; } + + [CommandOption(nameof(Double))] + public double Double { get; set; } + + [CommandOption(nameof(Decimal))] + public decimal Decimal { get; set; } + + [CommandOption(nameof(DateTime))] + public DateTime DateTime { get; set; } + + [CommandOption(nameof(DateTimeOffset))] + public DateTimeOffset DateTimeOffset { get; set; } + + [CommandOption(nameof(TimeSpan))] + public TimeSpan TimeSpan { get; set; } + + [CommandOption(nameof(TestEnum))] + public TestEnum TestEnum { get; set; } + + [CommandOption(nameof(IntNullable))] + public int? IntNullable { get; set; } + + [CommandOption(nameof(TestEnumNullable))] + public TestEnum? TestEnumNullable { get; set; } + + [CommandOption(nameof(TimeSpanNullable))] + public TimeSpan? TimeSpanNullable { get; set; } + + [CommandOption(nameof(TestStringConstructable))] + public TestStringConstructable? TestStringConstructable { get; set; } + + [CommandOption(nameof(TestStringParseable))] + public TestStringParseable? TestStringParseable { get; set; } + + [CommandOption(nameof(TestStringParseableWithFormatProvider))] + public TestStringParseableWithFormatProvider? TestStringParseableWithFormatProvider { get; set; } + + [CommandOption(nameof(ObjectArray))] + public object[]? ObjectArray { get; set; } + + [CommandOption(nameof(StringArray))] + public string[]? StringArray { get; set; } + + [CommandOption(nameof(IntArray))] + public int[]? IntArray { get; set; } + + [CommandOption(nameof(TestEnumArray))] + public TestEnum[]? TestEnumArray { get; set; } + + [CommandOption(nameof(IntNullableArray))] + public int?[]? IntNullableArray { get; set; } + + [CommandOption(nameof(TestStringConstructableArray))] + public TestStringConstructable[]? TestStringConstructableArray { get; set; } + + [CommandOption(nameof(Enumerable))] + public IEnumerable? Enumerable { get; set; } + + [CommandOption(nameof(StringEnumerable))] + public IEnumerable? StringEnumerable { get; set; } + + [CommandOption(nameof(StringReadOnlyList))] + public IReadOnlyList? StringReadOnlyList { get; set; } + + [CommandOption(nameof(StringList))] + public List? StringList { get; set; } + + [CommandOption(nameof(StringHashSet))] + public HashSet? StringHashSet { get; set; } + + [CommandOption(nameof(NonConvertible))] + public TestNonStringParseable? NonConvertible { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; + } +} \ No newline at end of file diff --git a/CliFx.Tests/TestCommands/ArgumentCommand.cs b/CliFx.Tests/TestCommands/ArgumentCommand.cs deleted file mode 100644 index 4065fe3..0000000 --- a/CliFx.Tests/TestCommands/ArgumentCommand.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using CliFx.Attributes; -using CliFx.Services; - -namespace CliFx.Tests.TestCommands -{ - [Command("arg cmd", Description = "Command using positional arguments")] - public class ArgumentCommand : ICommand - { - [CommandArgument(0, IsRequired = true, Name = "first")] - public string? FirstArgument { get; set; } - - [CommandArgument(10)] - public int? SecondArgument { get; set; } - - [CommandArgument(20, Description = "A list of numbers", Name = "third list")] - public IEnumerable ThirdArguments { get; set; } - - [CommandOption("option", 'o')] - public string Option { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; - } -} \ No newline at end of file diff --git a/CliFx.Tests/TestCommands/BrokenEnumerableCommand.cs b/CliFx.Tests/TestCommands/BrokenEnumerableCommand.cs new file mode 100644 index 0000000..0485403 --- /dev/null +++ b/CliFx.Tests/TestCommands/BrokenEnumerableCommand.cs @@ -0,0 +1,15 @@ +using System.Threading.Tasks; +using CliFx.Attributes; +using CliFx.Tests.TestCustomTypes; + +namespace CliFx.Tests.TestCommands +{ + [Command] + public class BrokenEnumerableCommand : ICommand + { + [CommandParameter(0)] + public TestCustomEnumerable? Test { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; + } +} \ No newline at end of file diff --git a/CliFx.Tests/TestCommands/CancellableCommand.cs b/CliFx.Tests/TestCommands/CancellableCommand.cs index 5546fda..0beb0eb 100644 --- a/CliFx.Tests/TestCommands/CancellableCommand.cs +++ b/CliFx.Tests/TestCommands/CancellableCommand.cs @@ -1,7 +1,6 @@ using System; using System.Threading.Tasks; using CliFx.Attributes; -using CliFx.Services; namespace CliFx.Tests.TestCommands { @@ -10,13 +9,8 @@ namespace CliFx.Tests.TestCommands { public async ValueTask ExecuteAsync(IConsole console) { - await Task.Yield(); - - console.Output.WriteLine("Printed"); - - await Task.Delay(TimeSpan.FromSeconds(1), console.GetCancellationToken()).ConfigureAwait(false); - + await Task.Delay(TimeSpan.FromSeconds(3), console.GetCancellationToken()); console.Output.WriteLine("Never printed"); } } -} +} \ No newline at end of file diff --git a/CliFx.Tests/TestCommands/CommandExceptionCommand.cs b/CliFx.Tests/TestCommands/CommandExceptionCommand.cs index 303dd1e..adf9475 100644 --- a/CliFx.Tests/TestCommands/CommandExceptionCommand.cs +++ b/CliFx.Tests/TestCommands/CommandExceptionCommand.cs @@ -1,7 +1,6 @@ using System.Threading.Tasks; using CliFx.Attributes; using CliFx.Exceptions; -using CliFx.Services; namespace CliFx.Tests.TestCommands { diff --git a/CliFx.Tests/TestCommands/ConcatCommand.cs b/CliFx.Tests/TestCommands/ConcatCommand.cs index a864e67..d75ff67 100644 --- a/CliFx.Tests/TestCommands/ConcatCommand.cs +++ b/CliFx.Tests/TestCommands/ConcatCommand.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; using CliFx.Attributes; -using CliFx.Services; namespace CliFx.Tests.TestCommands { diff --git a/CliFx.Tests/TestCommands/DivideCommand.cs b/CliFx.Tests/TestCommands/DivideCommand.cs index 8e95992..8653cb8 100644 --- a/CliFx.Tests/TestCommands/DivideCommand.cs +++ b/CliFx.Tests/TestCommands/DivideCommand.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; using CliFx.Attributes; -using CliFx.Services; namespace CliFx.Tests.TestCommands { diff --git a/CliFx.Tests/TestCommands/DuplicateOptionEnvironmentVariableNamesCommand.cs b/CliFx.Tests/TestCommands/DuplicateOptionEnvironmentVariableNamesCommand.cs new file mode 100644 index 0000000..19a87bd --- /dev/null +++ b/CliFx.Tests/TestCommands/DuplicateOptionEnvironmentVariableNamesCommand.cs @@ -0,0 +1,17 @@ +using System.Threading.Tasks; +using CliFx.Attributes; + +namespace CliFx.Tests.TestCommands +{ + [Command] + public class DuplicateOptionEnvironmentVariableNamesCommand : ICommand + { + [CommandOption("option-a", EnvironmentVariableName = "ENV_VAR")] + public string? OptionA { get; set; } + + [CommandOption("option-b", EnvironmentVariableName = "ENV_VAR")] + public string? OptionB { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; + } +} \ No newline at end of file diff --git a/CliFx.Tests/TestCommands/DuplicateOptionNamesCommand.cs b/CliFx.Tests/TestCommands/DuplicateOptionNamesCommand.cs index 064e827..d5bd654 100644 --- a/CliFx.Tests/TestCommands/DuplicateOptionNamesCommand.cs +++ b/CliFx.Tests/TestCommands/DuplicateOptionNamesCommand.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; using CliFx.Attributes; -using CliFx.Services; namespace CliFx.Tests.TestCommands { diff --git a/CliFx.Tests/TestCommands/DuplicateOptionShortNamesCommand.cs b/CliFx.Tests/TestCommands/DuplicateOptionShortNamesCommand.cs index 39c87b7..6d5fa34 100644 --- a/CliFx.Tests/TestCommands/DuplicateOptionShortNamesCommand.cs +++ b/CliFx.Tests/TestCommands/DuplicateOptionShortNamesCommand.cs @@ -1,18 +1,17 @@ using System.Threading.Tasks; using CliFx.Attributes; -using CliFx.Services; namespace CliFx.Tests.TestCommands { [Command] public class DuplicateOptionShortNamesCommand : ICommand { - [CommandOption('f')] - public string? Apples { get; set; } - - [CommandOption('f')] - public string? Oranges { get; set; } - + [CommandOption('x')] + public string? OptionA { get; set; } + + [CommandOption('x')] + public string? OptionB { get; set; } + public ValueTask ExecuteAsync(IConsole console) => default; } } \ No newline at end of file diff --git a/CliFx.Tests/TestCommands/DuplicateParameterNameCommand.cs b/CliFx.Tests/TestCommands/DuplicateParameterNameCommand.cs new file mode 100644 index 0000000..30c9f36 --- /dev/null +++ b/CliFx.Tests/TestCommands/DuplicateParameterNameCommand.cs @@ -0,0 +1,17 @@ +using System.Threading.Tasks; +using CliFx.Attributes; + +namespace CliFx.Tests.TestCommands +{ + [Command] + public class DuplicateParameterNameCommand : ICommand + { + [CommandParameter(0, Name = "param")] + public string? ParameterA { get; set; } + + [CommandParameter(1, Name = "param")] + public string? ParameterB { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; + } +} \ No newline at end of file diff --git a/CliFx.Tests/TestCommands/DuplicateParameterOrderCommand.cs b/CliFx.Tests/TestCommands/DuplicateParameterOrderCommand.cs new file mode 100644 index 0000000..b193a6b --- /dev/null +++ b/CliFx.Tests/TestCommands/DuplicateParameterOrderCommand.cs @@ -0,0 +1,17 @@ +using System.Threading.Tasks; +using CliFx.Attributes; + +namespace CliFx.Tests.TestCommands +{ + [Command] + public class DuplicateParameterOrderCommand : ICommand + { + [CommandParameter(13)] + public string? ParameterA { get; set; } + + [CommandParameter(13)] + public string? ParameterB { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; + } +} \ No newline at end of file diff --git a/CliFx.Tests/TestCommands/EnvironmentVariableCommand.cs b/CliFx.Tests/TestCommands/EnvironmentVariableCommand.cs index 1ccdd81..4ac9bc1 100644 --- a/CliFx.Tests/TestCommands/EnvironmentVariableCommand.cs +++ b/CliFx.Tests/TestCommands/EnvironmentVariableCommand.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; using CliFx.Attributes; -using CliFx.Services; namespace CliFx.Tests.TestCommands { diff --git a/CliFx.Tests/TestCommands/EnvironmentVariableWithMultipleValuesCommand.cs b/CliFx.Tests/TestCommands/EnvironmentVariableWithMultipleValuesCommand.cs index b33e721..ab6d836 100644 --- a/CliFx.Tests/TestCommands/EnvironmentVariableWithMultipleValuesCommand.cs +++ b/CliFx.Tests/TestCommands/EnvironmentVariableWithMultipleValuesCommand.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; using CliFx.Attributes; -using CliFx.Services; namespace CliFx.Tests.TestCommands { diff --git a/CliFx.Tests/TestCommands/EnvironmentVariableWithoutCollectionPropertyCommand.cs b/CliFx.Tests/TestCommands/EnvironmentVariableWithoutCollectionPropertyCommand.cs index 68b747c..6ef7386 100644 --- a/CliFx.Tests/TestCommands/EnvironmentVariableWithoutCollectionPropertyCommand.cs +++ b/CliFx.Tests/TestCommands/EnvironmentVariableWithoutCollectionPropertyCommand.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; using CliFx.Attributes; -using CliFx.Services; namespace CliFx.Tests.TestCommands { diff --git a/CliFx.Tests/TestCommands/ExceptionCommand.cs b/CliFx.Tests/TestCommands/ExceptionCommand.cs index d78b350..7f8f5ad 100644 --- a/CliFx.Tests/TestCommands/ExceptionCommand.cs +++ b/CliFx.Tests/TestCommands/ExceptionCommand.cs @@ -1,7 +1,6 @@ using System; using System.Threading.Tasks; using CliFx.Attributes; -using CliFx.Services; namespace CliFx.Tests.TestCommands { diff --git a/CliFx.Tests/TestCommands/HelloWorldDefaultCommand.cs b/CliFx.Tests/TestCommands/HelloWorldDefaultCommand.cs index 7490130..2ee07b7 100644 --- a/CliFx.Tests/TestCommands/HelloWorldDefaultCommand.cs +++ b/CliFx.Tests/TestCommands/HelloWorldDefaultCommand.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; using CliFx.Attributes; -using CliFx.Services; namespace CliFx.Tests.TestCommands { diff --git a/CliFx.Tests/TestCommands/HelpDefaultCommand.cs b/CliFx.Tests/TestCommands/HelpDefaultCommand.cs index f388826..78018fc 100644 --- a/CliFx.Tests/TestCommands/HelpDefaultCommand.cs +++ b/CliFx.Tests/TestCommands/HelpDefaultCommand.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; using CliFx.Attributes; -using CliFx.Services; namespace CliFx.Tests.TestCommands { diff --git a/CliFx.Tests/TestCommands/HelpNamedCommand.cs b/CliFx.Tests/TestCommands/HelpNamedCommand.cs index 4fda3e5..29f3a0d 100644 --- a/CliFx.Tests/TestCommands/HelpNamedCommand.cs +++ b/CliFx.Tests/TestCommands/HelpNamedCommand.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; using CliFx.Attributes; -using CliFx.Services; namespace CliFx.Tests.TestCommands { diff --git a/CliFx.Tests/TestCommands/HelpSubCommand.cs b/CliFx.Tests/TestCommands/HelpSubCommand.cs index 152498a..7dba17f 100644 --- a/CliFx.Tests/TestCommands/HelpSubCommand.cs +++ b/CliFx.Tests/TestCommands/HelpSubCommand.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; using CliFx.Attributes; -using CliFx.Services; namespace CliFx.Tests.TestCommands { diff --git a/CliFx.Tests/TestCommands/MultipleNonScalarParametersCommand.cs b/CliFx.Tests/TestCommands/MultipleNonScalarParametersCommand.cs new file mode 100644 index 0000000..a2a5c9d --- /dev/null +++ b/CliFx.Tests/TestCommands/MultipleNonScalarParametersCommand.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using CliFx.Attributes; + +namespace CliFx.Tests.TestCommands +{ + [Command] + public class MultipleNonScalarParametersCommand : ICommand + { + [CommandParameter(0)] + public IReadOnlyList? ParameterA { get; set; } + + [CommandParameter(1)] + public IReadOnlyList? ParameterB { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; + } +} \ No newline at end of file diff --git a/CliFx.Tests/TestCommands/NonAnnotatedCommand.cs b/CliFx.Tests/TestCommands/NonAnnotatedCommand.cs index 65aeedf..7fe2798 100644 --- a/CliFx.Tests/TestCommands/NonAnnotatedCommand.cs +++ b/CliFx.Tests/TestCommands/NonAnnotatedCommand.cs @@ -1,5 +1,4 @@ using System.Threading.Tasks; -using CliFx.Services; namespace CliFx.Tests.TestCommands { diff --git a/CliFx.Tests/TestCommands/NonLastNonScalarParameterCommand.cs b/CliFx.Tests/TestCommands/NonLastNonScalarParameterCommand.cs new file mode 100644 index 0000000..81e5c0b --- /dev/null +++ b/CliFx.Tests/TestCommands/NonLastNonScalarParameterCommand.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using CliFx.Attributes; + +namespace CliFx.Tests.TestCommands +{ + [Command] + public class NonLastNonScalarParameterCommand : ICommand + { + [CommandParameter(0)] + public IReadOnlyList? ParameterA { get; set; } + + [CommandParameter(1)] + public string? ParameterB { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; + } +} \ No newline at end of file diff --git a/CliFx.Tests/TestCommands/ParameterCommand.cs b/CliFx.Tests/TestCommands/ParameterCommand.cs new file mode 100644 index 0000000..3170f1f --- /dev/null +++ b/CliFx.Tests/TestCommands/ParameterCommand.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using CliFx.Attributes; + +namespace CliFx.Tests.TestCommands +{ + [Command("param cmd", Description = "Command using positional parameters")] + public class ParameterCommand : ICommand + { + [CommandParameter(0, Name = "first")] + public string? ParameterA { get; set; } + + [CommandParameter(10)] + public int? ParameterB { get; set; } + + [CommandParameter(20, Description = "A list of numbers", Name = "third list")] + public IEnumerable? ParameterC { get; set; } + + [CommandOption("option", 'o')] + public string? OptionA { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; + } +} \ No newline at end of file diff --git a/CliFx.Tests/TestCommands/SimpleArgumentCommand.cs b/CliFx.Tests/TestCommands/SimpleArgumentCommand.cs deleted file mode 100644 index cf5a2cb..0000000 --- a/CliFx.Tests/TestCommands/SimpleArgumentCommand.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Threading.Tasks; -using CliFx.Attributes; -using CliFx.Services; - -namespace CliFx.Tests.TestCommands -{ - [Command("arg cmd2", Description = "Command using positional arguments")] - public class SimpleArgumentCommand : ICommand - { - [CommandArgument(0, IsRequired = true, Name = "first")] - public string? FirstArgument { get; set; } - - [CommandArgument(10)] - public int? SecondArgument { get; set; } - - [CommandOption("option", 'o')] - public string Option { get; set; } - - public ValueTask ExecuteAsync(IConsole console) => default; - } -} \ No newline at end of file diff --git a/CliFx.Tests/TestCommands/SimpleParameterCommand.cs b/CliFx.Tests/TestCommands/SimpleParameterCommand.cs new file mode 100644 index 0000000..59cfd07 --- /dev/null +++ b/CliFx.Tests/TestCommands/SimpleParameterCommand.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; +using CliFx.Attributes; + +namespace CliFx.Tests.TestCommands +{ + [Command("param cmd2", Description = "Command using positional parameters")] + public class SimpleParameterCommand : ICommand + { + [CommandParameter(0, Name = "first")] + public string? ParameterA { get; set; } + + [CommandParameter(10)] + public int? ParameterB { get; set; } + + [CommandOption("option", 'o')] + public string? OptionA { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; + } +} \ No newline at end of file diff --git a/CliFx.Tests/TestCommands/SomeRequiredOptionsCommand.cs b/CliFx.Tests/TestCommands/SomeRequiredOptionsCommand.cs index 14c059a..a41d4fb 100644 --- a/CliFx.Tests/TestCommands/SomeRequiredOptionsCommand.cs +++ b/CliFx.Tests/TestCommands/SomeRequiredOptionsCommand.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; using CliFx.Attributes; -using CliFx.Services; namespace CliFx.Tests.TestCommands { diff --git a/CliFx.Tests/TestCustomTypes/TestCustomEnumerable.cs b/CliFx.Tests/TestCustomTypes/TestCustomEnumerable.cs new file mode 100644 index 0000000..55ac197 --- /dev/null +++ b/CliFx.Tests/TestCustomTypes/TestCustomEnumerable.cs @@ -0,0 +1,14 @@ +using System.Collections; +using System.Collections.Generic; + +namespace CliFx.Tests.TestCustomTypes +{ + public class TestCustomEnumerable : IEnumerable + { + private readonly T[] _arr = new T[0]; + + public IEnumerator GetEnumerator() => ((IEnumerable) _arr).GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} \ No newline at end of file diff --git a/CliFx.Tests/Utilities/ProgressTickerTests.cs b/CliFx.Tests/Utilities/ProgressTickerTests.cs index 0a3492b..1343e80 100644 --- a/CliFx.Tests/Utilities/ProgressTickerTests.cs +++ b/CliFx.Tests/Utilities/ProgressTickerTests.cs @@ -1,7 +1,6 @@ using System.Globalization; using System.IO; using System.Linq; -using CliFx.Services; using CliFx.Utilities; using FluentAssertions; using NUnit.Framework; diff --git a/CliFx.Tests/Services/VirtualConsoleTests.cs b/CliFx.Tests/VirtualConsoleTests.cs similarity index 87% rename from CliFx.Tests/Services/VirtualConsoleTests.cs rename to CliFx.Tests/VirtualConsoleTests.cs index be01d62..5c0b414 100644 --- a/CliFx.Tests/Services/VirtualConsoleTests.cs +++ b/CliFx.Tests/VirtualConsoleTests.cs @@ -1,17 +1,15 @@ using System; using System.IO; -using CliFx.Services; using FluentAssertions; using NUnit.Framework; -namespace CliFx.Tests.Services +namespace CliFx.Tests { [TestFixture] public class VirtualConsoleTests { - // Make sure console uses specified streams and doesn't leak to System.Console - [Test] - public void All_Smoke_Test() + [Test(Description = "Must not leak to system console")] + public void Smoke_Test() { // Arrange using var stdin = new StringReader("hello world"); diff --git a/CliFx.sln b/CliFx.sln index c896a26..828c836 100644 --- a/CliFx.sln +++ b/CliFx.sln @@ -12,6 +12,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Changelog.md = Changelog.md License.txt = License.txt Readme.md = Readme.md + CliFx.props = CliFx.props EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx.Benchmarks", "CliFx.Benchmarks\CliFx.Benchmarks.csproj", "{8ACD6DC2-D768-4850-9223-5B7C83A78513}" diff --git a/CliFx/Models/ApplicationConfiguration.cs b/CliFx/ApplicationConfiguration.cs similarity index 90% rename from CliFx/Models/ApplicationConfiguration.cs rename to CliFx/ApplicationConfiguration.cs index ec9f16a..243f268 100644 --- a/CliFx/Models/ApplicationConfiguration.cs +++ b/CliFx/ApplicationConfiguration.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace CliFx.Models +namespace CliFx { /// /// Configuration of an application. @@ -26,7 +26,8 @@ namespace CliFx.Models /// /// Initializes an instance of . /// - public ApplicationConfiguration(IReadOnlyList commandTypes, + public ApplicationConfiguration( + IReadOnlyList commandTypes, bool isDebugModeAllowed, bool isPreviewModeAllowed) { CommandTypes = commandTypes; diff --git a/CliFx/Models/ApplicationMetadata.cs b/CliFx/ApplicationMetadata.cs similarity index 97% rename from CliFx/Models/ApplicationMetadata.cs rename to CliFx/ApplicationMetadata.cs index 5c3f3b6..5674724 100644 --- a/CliFx/Models/ApplicationMetadata.cs +++ b/CliFx/ApplicationMetadata.cs @@ -1,4 +1,4 @@ -namespace CliFx.Models +namespace CliFx { /// /// Metadata associated with an application. diff --git a/CliFx/Attributes/CommandArgumentAttribute.cs b/CliFx/Attributes/CommandArgumentAttribute.cs deleted file mode 100644 index d6aed66..0000000 --- a/CliFx/Attributes/CommandArgumentAttribute.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System; - -namespace CliFx.Attributes -{ - /// - /// Annotates a property that defines a command argument. - /// - [AttributeUsage(AttributeTargets.Property)] - public class CommandArgumentAttribute : Attribute - { - /// - /// The name of the argument, which is used in help text. - /// - public string? Name { get; set; } - - /// - /// Whether the argument is required. - /// - public bool IsRequired { get; set; } - - /// - /// Argument description, which is used in help text. - /// - public string? Description { get; set; } - - /// - /// The ordering of the argument. Lower values will appear before higher values. - /// - /// Two arguments of the same command cannot have the same . - /// - /// - public int Order { get; } - - /// - /// Initializes an instance of with a given order. - /// - public CommandArgumentAttribute(int order) - { - Order = order; - } - } -} \ No newline at end of file diff --git a/CliFx/Attributes/CommandAttribute.cs b/CliFx/Attributes/CommandAttribute.cs index aaa613f..3bd7f81 100644 --- a/CliFx/Attributes/CommandAttribute.cs +++ b/CliFx/Attributes/CommandAttribute.cs @@ -10,7 +10,9 @@ namespace CliFx.Attributes { /// /// Command name. - /// This can be null if this is the default command. + /// If the name is not set, the command is treated as a default command, i.e. the one that gets executed when the user + /// does not specify a command name in the arguments. + /// All commands in an application must have different names. Likewise, only one command without a name is allowed. /// public string? Name { get; } diff --git a/CliFx/Attributes/CommandOptionAttribute.cs b/CliFx/Attributes/CommandOptionAttribute.cs index a1b9ac3..772b573 100644 --- a/CliFx/Attributes/CommandOptionAttribute.cs +++ b/CliFx/Attributes/CommandOptionAttribute.cs @@ -11,12 +11,14 @@ namespace CliFx.Attributes /// /// Option name. /// Either or must be set. + /// All options in a command must have different names (comparison is not case-sensitive). /// public string? Name { get; } /// /// Option short name. /// Either or must be set. + /// All options in a command must have different short names (comparison is case-sensitive). /// public char? ShortName { get; } @@ -31,7 +33,7 @@ namespace CliFx.Attributes public string? Description { get; set; } /// - /// Optional environment variable name that will be used as fallback value if no option value is specified. + /// Environment variable that will be used as fallback if no option value is specified. /// public string? EnvironmentVariableName { get; set; } diff --git a/CliFx/Attributes/CommandParameterAttribute.cs b/CliFx/Attributes/CommandParameterAttribute.cs new file mode 100644 index 0000000..5ef1d27 --- /dev/null +++ b/CliFx/Attributes/CommandParameterAttribute.cs @@ -0,0 +1,37 @@ +using System; + +namespace CliFx.Attributes +{ + /// + /// Annotates a property that defines a command parameter. + /// + [AttributeUsage(AttributeTargets.Property)] + public class CommandParameterAttribute : Attribute + { + /// + /// Order of this parameter compared to other parameters. + /// All parameters in a command must have different order. + /// Parameter whose type is a non-scalar (e.g. array), must be the last in order and only one such parameter is allowed. + /// + public int Order { get; } + + /// + /// Parameter name, which is only used in help text. + /// If this isn't specified, property name is used instead. + /// + public string? Name { get; set; } + + /// + /// Parameter description, which is used in help text. + /// + public string? Description { get; set; } + + /// + /// Initializes an instance of . + /// + public CommandParameterAttribute(int order) + { + Order = order; + } + } +} \ No newline at end of file diff --git a/CliFx/CliApplication.Help.cs b/CliFx/CliApplication.Help.cs new file mode 100644 index 0000000..0a20c22 --- /dev/null +++ b/CliFx/CliApplication.Help.cs @@ -0,0 +1,314 @@ +using System; +using System.Linq; +using CliFx.Domain; +using CliFx.Internal; + +namespace CliFx +{ + public partial class CliApplication + { + private void RenderHelp(ApplicationSchema applicationSchema, CommandSchema command) + { + var column = 0; + var row = 0; + + var childCommands = applicationSchema.GetChildCommands(command.Name); + + bool IsEmpty() => column == 0 && row == 0; + + void Render(string text) + { + _console.Output.Write(text); + + column += text.Length; + } + + void RenderNewLine() + { + _console.Output.WriteLine(); + + column = 0; + row++; + } + + void RenderMargin(int lines = 1) + { + if (!IsEmpty()) + { + for (var i = 0; i < lines; i++) + RenderNewLine(); + } + } + + void RenderIndent(int spaces = 2) + { + Render(' '.Repeat(spaces)); + } + + void RenderColumnIndent(int spaces = 20, int margin = 2) + { + if (column + margin >= spaces) + { + RenderNewLine(); + RenderIndent(spaces); + } + else + { + RenderIndent(spaces - column); + } + } + + void RenderWithColor(string text, ConsoleColor foregroundColor) + { + _console.WithForegroundColor(foregroundColor, () => Render(text)); + } + + void RenderWithColors(string text, ConsoleColor foregroundColor, ConsoleColor backgroundColor) + { + _console.WithColors(foregroundColor, backgroundColor, () => Render(text)); + } + + void RenderHeader(string text) + { + RenderWithColors(text, ConsoleColor.Black, ConsoleColor.DarkGray); + RenderNewLine(); + } + + void RenderApplicationInfo() + { + if (!command.IsDefault) + return; + + // Title and version + RenderWithColor(_metadata.Title, ConsoleColor.Yellow); + Render(" "); + RenderWithColor(_metadata.VersionText, ConsoleColor.Yellow); + RenderNewLine(); + + // Description + if (!string.IsNullOrWhiteSpace(_metadata.Description)) + { + Render(_metadata.Description); + RenderNewLine(); + } + } + + void RenderDescription() + { + if (string.IsNullOrWhiteSpace(command.Description)) + return; + + RenderMargin(); + RenderHeader("Description"); + + RenderIndent(); + Render(command.Description); + RenderNewLine(); + } + + void RenderUsage() + { + RenderMargin(); + RenderHeader("Usage"); + + // Exe name + RenderIndent(); + Render(_metadata.ExecutableName); + + // Command name + if (!string.IsNullOrWhiteSpace(command.Name)) + { + Render(" "); + RenderWithColor(command.Name, ConsoleColor.Cyan); + } + + // Child command placeholder + if (childCommands.Any()) + { + Render(" "); + RenderWithColor("[command]", ConsoleColor.Cyan); + } + + // Parameters + foreach (var parameter in command.Parameters) + { + Render(" "); + Render($"<{parameter.DisplayName}>"); + } + + // Required options + var requiredOptionSchemas = command.Options + .Where(o => o.IsRequired) + .ToArray(); + + foreach (var option in requiredOptionSchemas) + { + Render(" "); + if (!string.IsNullOrWhiteSpace(option.Name)) + { + RenderWithColor($"--{option.Name}", ConsoleColor.White); + Render(" "); + Render(""); + } + else + { + RenderWithColor($"-{option.ShortName} ", ConsoleColor.White); + Render(" "); + Render(""); + } + } + + // Options placeholder + if (command.Options.Count != requiredOptionSchemas.Length) + { + Render(" "); + RenderWithColor("[options]", ConsoleColor.White); + } + + RenderNewLine(); + } + + void RenderParameters() + { + if (!command.Parameters.Any()) + return; + + RenderMargin(); + RenderHeader("Parameters"); + + var parameters = command.Parameters + .OrderBy(p => p.Order) + .ToArray(); + + foreach (var parameter in parameters) + { + RenderWithColor("* ", ConsoleColor.Red); + RenderWithColor($"{parameter.DisplayName}", ConsoleColor.White); + + // Description + if (!string.IsNullOrWhiteSpace(parameter.Description)) + { + RenderColumnIndent(); + Render(parameter.Description); + } + + RenderNewLine(); + } + } + + void RenderOptions() + { + RenderMargin(); + RenderHeader("Options"); + + var options = command.Options + .OrderByDescending(o => o.IsRequired) + .ToList(); + + // Add built-in options + options.Add(CommandOptionSchema.HelpOption); + if (command.IsDefault) + options.Add(CommandOptionSchema.VersionOption); + + foreach (var option in options) + { + if (option.IsRequired) + { + RenderWithColor("* ", ConsoleColor.Red); + } + else + { + RenderIndent(); + } + + // Short name + if (option.ShortName != null) + { + RenderWithColor($"-{option.ShortName}", ConsoleColor.White); + } + + // Delimiter + if (!string.IsNullOrWhiteSpace(option.Name) && option.ShortName != null) + { + Render("|"); + } + + // Name + if (!string.IsNullOrWhiteSpace(option.Name)) + { + RenderWithColor($"--{option.Name}", ConsoleColor.White); + } + + // Description + if (!string.IsNullOrWhiteSpace(option.Description)) + { + RenderColumnIndent(); + Render(option.Description); + } + + RenderNewLine(); + } + } + + void RenderChildCommands() + { + if (!childCommands.Any()) + return; + + RenderMargin(); + RenderHeader("Commands"); + + foreach (var childCommand in childCommands) + { + var relativeCommandName = + string.IsNullOrWhiteSpace(childCommand.Name) || string.IsNullOrWhiteSpace(command.Name) + ? childCommand.Name + : childCommand.Name.Substring(command.Name.Length + 1); + + // Name + RenderIndent(); + RenderWithColor(relativeCommandName, ConsoleColor.Cyan); + + // Description + if (!string.IsNullOrWhiteSpace(childCommand.Description)) + { + RenderColumnIndent(); + Render(childCommand.Description); + } + + RenderNewLine(); + } + + RenderMargin(); + + // Child command help tip + Render("You can run `"); + Render(_metadata.ExecutableName); + + if (!string.IsNullOrWhiteSpace(command.Name)) + { + Render(" "); + RenderWithColor(command.Name, ConsoleColor.Cyan); + } + + Render(" "); + RenderWithColor("[command]", ConsoleColor.Cyan); + + Render(" "); + RenderWithColor("--help", ConsoleColor.White); + + Render("` to show help on a specific command."); + + RenderNewLine(); + } + + _console.ResetColor(); + RenderApplicationInfo(); + RenderDescription(); + RenderUsage(); + RenderParameters(); + RenderOptions(); + RenderChildCommands(); + } + } +} \ No newline at end of file diff --git a/CliFx/CliApplication.cs b/CliFx/CliApplication.cs index 417d62a..c3a10f3 100644 --- a/CliFx/CliApplication.cs +++ b/CliFx/CliApplication.cs @@ -1,83 +1,65 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Threading.Tasks; +using CliFx.Domain; using CliFx.Exceptions; -using CliFx.Models; -using CliFx.Services; namespace CliFx { /// - /// Default implementation of . + /// Command line application facade. /// - public class CliApplication : ICliApplication + public partial class CliApplication { private readonly ApplicationMetadata _metadata; private readonly ApplicationConfiguration _configuration; - private readonly IConsole _console; - private readonly ICommandInputParser _commandInputParser; - private readonly ICommandSchemaResolver _commandSchemaResolver; - private readonly ICommandFactory _commandFactory; - private readonly ICommandInitializer _commandInitializer; - private readonly IHelpTextRenderer _helpTextRenderer; + private readonly ITypeActivator _typeActivator; /// /// Initializes an instance of . /// - public CliApplication(ApplicationMetadata metadata, ApplicationConfiguration configuration, - IConsole console, ICommandInputParser commandInputParser, ICommandSchemaResolver commandSchemaResolver, - ICommandFactory commandFactory, ICommandInitializer commandInitializer, IHelpTextRenderer helpTextRenderer) + public CliApplication( + ApplicationMetadata metadata, ApplicationConfiguration configuration, + IConsole console, ITypeActivator typeActivator) { _metadata = metadata; _configuration = configuration; - _console = console; - _commandInputParser = commandInputParser; - _commandSchemaResolver = commandSchemaResolver; - _commandFactory = commandFactory; - _commandInitializer = commandInitializer; - _helpTextRenderer = helpTextRenderer; + _typeActivator = typeActivator; } - private async ValueTask HandleDebugDirectiveAsync(CommandInput commandInput) + private async ValueTask HandleDebugDirectiveAsync(CommandLineInput commandLineInput) { - // Debug mode is enabled if it's allowed in the application and it was requested via corresponding directive - var isDebugMode = _configuration.IsDebugModeAllowed && commandInput.IsDebugDirectiveSpecified(); - - // If not in debug mode, pass execution to the next handler + var isDebugMode = _configuration.IsDebugModeAllowed && commandLineInput.IsDebugDirectiveSpecified; if (!isDebugMode) return null; - // Inform user which process they need to attach debugger to - _console.WithForegroundColor(ConsoleColor.Green, - () => _console.Output.WriteLine($"Attach debugger to PID {Process.GetCurrentProcess().Id} to continue.")); + _console.WithForegroundColor(ConsoleColor.Green, () => + _console.Output.WriteLine($"Attach debugger to PID {Process.GetCurrentProcess().Id} to continue.")); - // Wait until debugger is attached while (!Debugger.IsAttached) await Task.Delay(100); - // Debug directive never short-circuits return null; } - private int? HandlePreviewDirective(CommandInput commandInput) + private int? HandlePreviewDirective(CommandLineInput commandLineInput) { - // Preview mode is enabled if it's allowed in the application and it was requested via corresponding directive - var isPreviewMode = _configuration.IsPreviewModeAllowed && commandInput.IsPreviewDirectiveSpecified(); - - // If not in preview mode, pass execution to the next handler + var isPreviewMode = _configuration.IsPreviewModeAllowed && commandLineInput.IsPreviewDirectiveSpecified; if (!isPreviewMode) return null; // Render command name - _console.Output.WriteLine($"Arguments: {string.Join(" ", commandInput.Arguments)}"); + _console.Output.WriteLine($"Arguments: {string.Join(" ", commandLineInput.Arguments)}"); _console.Output.WriteLine(); // Render directives _console.Output.WriteLine("Directives:"); - foreach (var directive in commandInput.Directives) + foreach (var directive in commandLineInput.Directives) { _console.Output.Write(" "); _console.Output.WriteLine(directive); @@ -88,110 +70,79 @@ namespace CliFx // Render options _console.Output.WriteLine("Options:"); - foreach (var option in commandInput.Options) + foreach (var option in commandLineInput.Options) { _console.Output.Write(" "); _console.Output.WriteLine(option); } - // Short-circuit with exit code 0 return 0; } - private int? HandleVersionOption(CommandInput commandInput) + private int? HandleVersionOption(CommandLineInput commandLineInput) { - // Version should be rendered if it was requested on a default command - var shouldRenderVersion = !commandInput.HasArguments() && commandInput.IsVersionOptionSpecified(); - - // If shouldn't render version, pass execution to the next handler + // Version option is available only on the default command (i.e. when arguments are not specified) + var shouldRenderVersion = !commandLineInput.Arguments.Any() && commandLineInput.IsVersionOptionSpecified; if (!shouldRenderVersion) return null; - // Render version text _console.Output.WriteLine(_metadata.VersionText); - // Short-circuit with exit code 0 return 0; } - private int? HandleHelpOption(CommandInput commandInput, - IReadOnlyList availableCommandSchemas, CommandCandidate? commandCandidate) + private int? HandleHelpOption( + ApplicationSchema applicationSchema, + CommandLineInput commandLineInput) { - // Help should be rendered if it was requested, or when executing a command which isn't defined - var shouldRenderHelp = commandInput.IsHelpOptionSpecified() || commandCandidate == null; + // Help is rendered either when it's requested or when the user provides no arguments and there is no default command + var shouldRenderHelp = + commandLineInput.IsHelpOptionSpecified || + !applicationSchema.Commands.Any(c => c.IsDefault) && !commandLineInput.Arguments.Any() && !commandLineInput.Options.Any(); - // If shouldn't render help, pass execution to the next handler if (!shouldRenderHelp) return null; - // Keep track whether there was an error in the input - var isError = false; + // Get the command schema that matches the input or use a dummy default command as a fallback + var commandSchema = + applicationSchema.TryFindCommand(commandLineInput) ?? + CommandSchema.StubDefaultCommand; - // Report error if no command matched the arguments - if (commandCandidate is null) - { - // If a command was specified, inform the user that the command is not defined - if (commandInput.HasArguments()) - { - _console.WithForegroundColor(ConsoleColor.Red, - () => _console.Error.WriteLine($"No command could be matched for input [{string.Join(" ", commandInput.Arguments)}]")); - isError = true; - } + RenderHelp(applicationSchema, commandSchema); - commandCandidate = new CommandCandidate(CommandSchema.StubDefaultCommand, new string[0], commandInput); - } - - // Build help text source - var helpTextSource = new HelpTextSource(_metadata, availableCommandSchemas, commandCandidate.Schema); - - // Render help text - _helpTextRenderer.RenderHelpText(_console, helpTextSource); - - // Short-circuit with appropriate exit code - return isError ? -1 : 0; - } - - private async ValueTask HandleCommandExecutionAsync(CommandCandidate? commandCandidate) - { - if (commandCandidate is null) - { - throw new ArgumentException("Cannot execute command because it was not found."); - } - - // Create an instance of the command - var command = _commandFactory.CreateCommand(commandCandidate.Schema); - - // Populate command with options and arguments according to its schema - _commandInitializer.InitializeCommand(command, commandCandidate); - - // Execute command - await command.ExecuteAsync(_console); - - // Finish the chain with exit code 0 return 0; } - /// - public async ValueTask RunAsync(IReadOnlyList commandLineArguments) + private async ValueTask HandleCommandExecutionAsync( + ApplicationSchema applicationSchema, + CommandLineInput commandLineInput, + IReadOnlyDictionary environmentVariables) + { + await applicationSchema + .InitializeEntryPoint(commandLineInput, environmentVariables, _typeActivator) + .ExecuteAsync(_console); + + return 0; + } + + /// + /// Runs the application with specified command line arguments and environment variables, and returns the exit code. + /// + public async ValueTask RunAsync( + IReadOnlyList commandLineArguments, + IReadOnlyDictionary environmentVariables) { try { - // Parse command input from arguments - var commandInput = _commandInputParser.ParseCommandInput(commandLineArguments); + var applicationSchema = ApplicationSchema.Resolve(_configuration.CommandTypes); + var commandLineInput = CommandLineInput.Parse(commandLineArguments); - // Get schemas for all available command types - var availableCommandSchemas = _commandSchemaResolver.GetCommandSchemas(_configuration.CommandTypes); - - // Find command schema matching the name specified in the input - var commandCandidate = _commandSchemaResolver.GetTargetCommandSchema(availableCommandSchemas, commandInput); - - // Chain handlers until the first one that produces an exit code return - await HandleDebugDirectiveAsync(commandInput) ?? - HandlePreviewDirective(commandInput) ?? - HandleVersionOption(commandInput) ?? - HandleHelpOption(commandInput, availableCommandSchemas, commandCandidate) ?? - await HandleCommandExecutionAsync(commandCandidate); + await HandleDebugDirectiveAsync(commandLineInput) ?? + HandlePreviewDirective(commandLineInput) ?? + HandleVersionOption(commandLineInput) ?? + HandleHelpOption(applicationSchema, commandLineInput) ?? + await HandleCommandExecutionAsync(applicationSchema, commandLineInput, environmentVariables); } catch (Exception ex) { @@ -199,25 +150,42 @@ namespace CliFx // Doing this also gets rid of the annoying Windows troubleshooting dialog that shows up on unhandled exceptions. // Prefer showing message without stack trace on exceptions coming from CliFx or on CommandException - if (!string.IsNullOrWhiteSpace(ex.Message) && (ex is CliFxException || ex is CommandException)) - { - _console.WithForegroundColor(ConsoleColor.Red, () => _console.Error.WriteLine(ex.Message)); - } - else - { - _console.WithForegroundColor(ConsoleColor.Red, () => _console.Error.WriteLine(ex)); - } + var errorMessage = !string.IsNullOrWhiteSpace(ex.Message) && (ex is CliFxException || ex is CommandException) + ? ex.Message + : ex.ToString(); - // Return exit code if it was specified via CommandException - if (ex is CommandException commandException) - { - return commandException.ExitCode; - } - else - { - return ex.HResult; - } + _console.WithForegroundColor(ConsoleColor.Red, () => _console.Error.WriteLine(errorMessage)); + + return ex is CommandException commandException + ? commandException.ExitCode + : ex.HResult; } } + + /// + /// Runs the application with specified command line arguments and returns the exit code. + /// Environment variables are retrieved automatically. + /// + public async ValueTask RunAsync(IReadOnlyList commandLineArguments) + { + var environmentVariables = Environment.GetEnvironmentVariables() + .Cast() + .ToDictionary(e => (string) e.Key, e => (string) e.Value, StringComparer.OrdinalIgnoreCase); + + return await RunAsync(commandLineArguments, environmentVariables); + } + + /// + /// Runs the application and returns the exit code. + /// Command line arguments and environment variables are retrieved automatically. + /// + public async ValueTask RunAsync() + { + var commandLineArguments = Environment.GetCommandLineArgs() + .Skip(1) + .ToArray(); + + return await RunAsync(commandLineArguments); + } } } \ No newline at end of file diff --git a/CliFx/CliApplicationBuilder.cs b/CliFx/CliApplicationBuilder.cs index 999349c..83986a2 100644 --- a/CliFx/CliApplicationBuilder.cs +++ b/CliFx/CliApplicationBuilder.cs @@ -3,17 +3,14 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; -using CliFx.Attributes; -using CliFx.Internal; -using CliFx.Models; -using CliFx.Services; +using CliFx.Domain; namespace CliFx { /// - /// Default implementation of . + /// Builds an instance of . /// - public partial class CliApplicationBuilder : ICliApplicationBuilder + public partial class CliApplicationBuilder { private readonly HashSet _commandTypes = new HashSet(); @@ -24,121 +21,153 @@ namespace CliFx private string? _versionText; private string? _description; private IConsole? _console; - private ICommandFactory? _commandFactory; - private ICommandInputConverter? _commandInputConverter; - private IEnvironmentVariablesProvider? _environmentVariablesProvider; + private ITypeActivator? _typeActivator; - /// - public ICliApplicationBuilder AddCommand(Type commandType) + /// + /// Adds a command of specified type to the application. + /// + public CliApplicationBuilder AddCommand(Type commandType) { _commandTypes.Add(commandType); return this; } - /// - public ICliApplicationBuilder AddCommandsFrom(Assembly commandAssembly) + /// + /// Adds multiple commands to the application. + /// + public CliApplicationBuilder AddCommands(IEnumerable commandTypes) { - var commandTypes = commandAssembly.ExportedTypes - .Where(t => t.Implements(typeof(ICommand))) - .Where(t => t.IsDefined(typeof(CommandAttribute))) - .Where(t => !t.IsAbstract && !t.IsInterface); - foreach (var commandType in commandTypes) AddCommand(commandType); return this; } - /// - public ICliApplicationBuilder AllowDebugMode(bool isAllowed = true) + /// + /// Adds commands from the specified assembly to the application. + /// Only the public types are added. + /// + public CliApplicationBuilder AddCommandsFrom(Assembly commandAssembly) + { + foreach (var commandType in commandAssembly.ExportedTypes.Where(CommandSchema.IsCommandType)) + AddCommand(commandType); + + return this; + } + + /// + /// Adds commands from the specified assemblies to the application. + /// Only the public types are added. + /// + public CliApplicationBuilder AddCommandsFrom(IEnumerable commandAssemblies) + { + foreach (var commandAssembly in commandAssemblies) + AddCommandsFrom(commandAssembly); + + return this; + } + + /// + /// Adds commands from the calling assembly to the application. + /// Only the public types are added. + /// + public CliApplicationBuilder AddCommandsFromThisAssembly() => AddCommandsFrom(Assembly.GetCallingAssembly()); + + /// + /// Specifies whether debug mode (enabled with [debug] directive) is allowed in the application. + /// + public CliApplicationBuilder AllowDebugMode(bool isAllowed = true) { _isDebugModeAllowed = isAllowed; return this; } - /// - public ICliApplicationBuilder AllowPreviewMode(bool isAllowed = true) + /// + /// Specifies whether preview mode (enabled with [preview] directive) is allowed in the application. + /// + public CliApplicationBuilder AllowPreviewMode(bool isAllowed = true) { _isPreviewModeAllowed = isAllowed; return this; } - /// - public ICliApplicationBuilder UseTitle(string title) + /// + /// Sets application title, which appears in the help text. + /// + public CliApplicationBuilder UseTitle(string title) { _title = title; return this; } - /// - public ICliApplicationBuilder UseExecutableName(string executableName) + /// + /// Sets application executable name, which appears in the help text. + /// + public CliApplicationBuilder UseExecutableName(string executableName) { _executableName = executableName; return this; } - /// - public ICliApplicationBuilder UseVersionText(string versionText) + /// + /// Sets application version text, which appears in the help text and when the user requests version information. + /// + public CliApplicationBuilder UseVersionText(string versionText) { _versionText = versionText; return this; } - /// - public ICliApplicationBuilder UseDescription(string? description) + /// + /// Sets application description, which appears in the help text. + /// + public CliApplicationBuilder UseDescription(string? description) { _description = description; return this; } - /// - public ICliApplicationBuilder UseConsole(IConsole console) + /// + /// Configures the application to use the specified implementation of . + /// + public CliApplicationBuilder UseConsole(IConsole console) { _console = console; return this; } - /// - public ICliApplicationBuilder UseCommandFactory(ICommandFactory factory) + /// + /// Configures the application to use the specified implementation of . + /// + public CliApplicationBuilder UseTypeActivator(ITypeActivator typeActivator) { - _commandFactory = factory; + _typeActivator = typeActivator; return this; } - /// - public ICliApplicationBuilder UseCommandOptionInputConverter(ICommandInputConverter converter) - { - _commandInputConverter = converter; - return this; - } + /// + /// Configures the application to use the specified function for activating types. + /// + public CliApplicationBuilder UseTypeActivator(Func typeActivator) => + UseTypeActivator(new DelegateTypeActivator(typeActivator)); - /// - public ICliApplicationBuilder UseEnvironmentVariablesProvider(IEnvironmentVariablesProvider environmentVariablesProvider) + /// + /// Creates an instance of using configured parameters. + /// Default values are used in place of parameters that were not specified. + /// + public CliApplication Build() { - _environmentVariablesProvider = environmentVariablesProvider; - return this; - } - - /// - public ICliApplication Build() - { - // Use defaults for required parameters that were not configured _title ??= GetDefaultTitle() ?? "App"; _executableName ??= GetDefaultExecutableName() ?? "app"; _versionText ??= GetDefaultVersionText() ?? "v1.0"; _console ??= new SystemConsole(); - _commandFactory ??= new CommandFactory(); - _commandInputConverter ??= new CommandInputConverter(); - _environmentVariablesProvider ??= new EnvironmentVariablesProvider(); + _typeActivator ??= new DefaultTypeActivator(); - // Project parameters to expected types var metadata = new ApplicationMetadata(_title, _executableName, _versionText, _description); var configuration = new ApplicationConfiguration(_commandTypes.ToArray(), _isDebugModeAllowed, _isPreviewModeAllowed); - return new CliApplication(metadata, configuration, - _console, new CommandInputParser(_environmentVariablesProvider), new CommandSchemaResolver(new CommandArgumentSchemasValidator()), - _commandFactory, new CommandInitializer(_commandInputConverter, new EnvironmentVariablesParser()), new HelpTextRenderer()); + return new CliApplication(metadata, configuration, _console, _typeActivator); } } @@ -149,9 +178,9 @@ namespace CliFx // Entry assembly is null in tests private static Assembly EntryAssembly => LazyEntryAssembly.Value; - private static string GetDefaultTitle() => EntryAssembly?.GetName().Name ?? ""; + private static string? GetDefaultTitle() => EntryAssembly?.GetName().Name; - private static string GetDefaultExecutableName() + private static string? GetDefaultExecutableName() { var entryAssemblyLocation = EntryAssembly?.Location; @@ -165,6 +194,9 @@ namespace CliFx return Path.GetFileNameWithoutExtension(entryAssemblyLocation); } - private static string GetDefaultVersionText() => EntryAssembly != null ? $"v{EntryAssembly.GetName().Version}" : ""; + private static string? GetDefaultVersionText() => + EntryAssembly != null + ? $"v{EntryAssembly.GetName().Version}" + : null; } } \ No newline at end of file diff --git a/CliFx/CliFx.csproj b/CliFx/CliFx.csproj index f42e701..b03d63f 100644 --- a/CliFx/CliFx.csproj +++ b/CliFx/CliFx.csproj @@ -18,6 +18,12 @@ snupkg + + + <_Parameter1>$(AssemblyName).Tests + + + @@ -25,7 +31,7 @@ - + diff --git a/CliFx/DefaultTypeActivator.cs b/CliFx/DefaultTypeActivator.cs new file mode 100644 index 0000000..0178a04 --- /dev/null +++ b/CliFx/DefaultTypeActivator.cs @@ -0,0 +1,29 @@ +using System; +using System.Text; +using CliFx.Exceptions; + +namespace CliFx +{ + /// + /// Type activator that uses the class to instantiate objects. + /// + public class DefaultTypeActivator : ITypeActivator + { + /// + public object CreateInstance(Type type) + { + try + { + return Activator.CreateInstance(type); + } + catch (Exception ex) + { + throw new CliFxException(new StringBuilder() + .Append($"Failed to create an instance of {type.FullName}.").Append(" ") + .AppendLine("The type must have a public parameter-less constructor in order to be instantiated by the default activator.") + .Append($"To supply a custom activator (for example when using dependency injection), call {nameof(CliApplicationBuilder)}.{nameof(CliApplicationBuilder.UseTypeActivator)}(...).") + .ToString(), ex); + } + } + } +} \ No newline at end of file diff --git a/CliFx/DelegateTypeActivator.cs b/CliFx/DelegateTypeActivator.cs new file mode 100644 index 0000000..b3e7fae --- /dev/null +++ b/CliFx/DelegateTypeActivator.cs @@ -0,0 +1,20 @@ +using System; + +namespace CliFx +{ + /// + /// Type activator that uses the specified delegate to instantiate objects. + /// + public class DelegateTypeActivator : ITypeActivator + { + private readonly Func _func; + + /// + /// Initializes an instance of . + /// + public DelegateTypeActivator(Func func) => _func = func; + + /// + public object CreateInstance(Type type) => _func(type); + } +} \ No newline at end of file diff --git a/CliFx/Domain/ApplicationSchema.cs b/CliFx/Domain/ApplicationSchema.cs new file mode 100644 index 0000000..cbd859c --- /dev/null +++ b/CliFx/Domain/ApplicationSchema.cs @@ -0,0 +1,256 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using CliFx.Attributes; +using CliFx.Exceptions; +using CliFx.Internal; + +namespace CliFx.Domain +{ + internal partial class ApplicationSchema + { + public IReadOnlyList Commands { get; } + + public ApplicationSchema(IReadOnlyList commands) + { + Commands = commands; + } + + public CommandSchema? TryFindParentCommand(string? childCommandName) + { + // Default command has no parent + if (string.IsNullOrWhiteSpace(childCommandName)) + return null; + + // Try to find the parent command by repeatedly biting off chunks of its name + var route = childCommandName.Split(' '); + for (var i = route.Length - 1; i >= 1; i--) + { + var potentialParentCommandName = string.Join(" ", route.Take(i)); + var matchingParentCommand = Commands.FirstOrDefault(c => c.MatchesName(potentialParentCommandName)); + + if (matchingParentCommand != null) + return matchingParentCommand; + } + + // If there's no parent - fall back to default command + return Commands.FirstOrDefault(c => c.IsDefault); + } + + public IReadOnlyList GetChildCommands(string? parentCommandName) => + !string.IsNullOrWhiteSpace(parentCommandName) || Commands.Any(c => c.IsDefault) + ? Commands.Where(c => TryFindParentCommand(c.Name)?.MatchesName(parentCommandName) == true).ToArray() + : Commands.Where(c => !string.IsNullOrWhiteSpace(c.Name) && TryFindParentCommand(c.Name) == null).ToArray(); + + private CommandSchema? TryFindCommand(CommandLineInput commandLineInput, out int argumentOffset) + { + // Try to find the command that contains the most of the input arguments in its name + for (var i = commandLineInput.Arguments.Count; i >= 0; i--) + { + var potentialCommandName = string.Join(" ", commandLineInput.Arguments.Take(i)); + var matchingCommand = Commands.FirstOrDefault(c => c.MatchesName(potentialCommandName)); + + if (matchingCommand != null) + { + argumentOffset = i; + return matchingCommand; + } + } + + argumentOffset = 0; + return Commands.FirstOrDefault(c => c.IsDefault); + } + + public CommandSchema? TryFindCommand(CommandLineInput commandLineInput) => + TryFindCommand(commandLineInput, out _); + + public ICommand InitializeEntryPoint( + CommandLineInput commandLineInput, + IReadOnlyDictionary environmentVariables, + ITypeActivator activator) + { + var command = TryFindCommand(commandLineInput, out var argumentOffset); + if (command == null) + { + throw new CliFxException( + $"Can't find a command that matches arguments [{string.Join(" ", commandLineInput.Arguments)}]."); + } + + var parameterInputs = argumentOffset == 0 + ? commandLineInput.Arguments + : commandLineInput.Arguments.Skip(argumentOffset).ToArray(); + + return command.CreateInstance(parameterInputs, commandLineInput.Options, environmentVariables, activator); + } + } + + internal partial class ApplicationSchema + { + private static void ValidateParameters(CommandSchema command) + { + var duplicateOrderGroup = command.Parameters + .GroupBy(a => a.Order) + .FirstOrDefault(g => g.Count() > 1); + + if (duplicateOrderGroup != null) + { + throw new CliFxException(new StringBuilder() + .AppendLine($"Command {command.Type.FullName} contains two or more parameters that have the same order ({duplicateOrderGroup.Key}):") + .AppendBulletList(duplicateOrderGroup.Select(o => o.Property.Name)) + .AppendLine() + .Append("Parameters in a command must all have unique order.") + .ToString()); + } + + var duplicateNameGroup = command.Parameters + .Where(a => !string.IsNullOrWhiteSpace(a.Name)) + .GroupBy(a => a.Name, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(g => g.Count() > 1); + + if (duplicateNameGroup != null) + { + throw new CliFxException(new StringBuilder() + .AppendLine($"Command {command.Type.FullName} contains two or more parameters that have the same name ({duplicateNameGroup.Key}):") + .AppendBulletList(duplicateNameGroup.Select(o => o.Property.Name)) + .AppendLine() + .Append("Parameters in a command must all have unique names.").Append(" ") + .Append("Comparison is NOT case-sensitive.") + .ToString()); + } + + var nonScalarParameters = command.Parameters + .Where(p => !p.IsScalar) + .ToArray(); + + if (nonScalarParameters.Length > 1) + { + throw new CliFxException(new StringBuilder() + .AppendLine($"Command [{command.Type.FullName}] contains two or more parameters of an enumerable type:") + .AppendBulletList(nonScalarParameters.Select(o => o.Property.Name)) + .AppendLine() + .AppendLine("There can only be one parameter of an enumerable type in a command.") + .Append("Note, the string type is not considered enumerable in this context.") + .ToString()); + } + + var nonLastNonScalarParameter = command.Parameters + .OrderByDescending(a => a.Order) + .Skip(1) + .LastOrDefault(p => !p.IsScalar); + + if (nonLastNonScalarParameter != null) + { + throw new CliFxException(new StringBuilder() + .AppendLine($"Command {command.Type.FullName} contains a parameter of an enumerable type which doesn't appear last in order:") + .AppendLine($"- {nonLastNonScalarParameter.Property.Name}") + .AppendLine() + .Append("Parameter of an enumerable type must always come last to avoid ambiguity.") + .ToString()); + } + } + + private static void ValidateOptions(CommandSchema command) + { + var duplicateNameGroup = command.Options + .Where(o => !string.IsNullOrWhiteSpace(o.Name)) + .GroupBy(o => o.Name, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(g => g.Count() > 1); + + if (duplicateNameGroup != null) + { + throw new CliFxException(new StringBuilder() + .AppendLine($"Command {command.Type.FullName} contains two or more options that have the same name ({duplicateNameGroup.Key}):") + .AppendBulletList(duplicateNameGroup.Select(o => o.Property.Name)) + .AppendLine() + .Append("Options in a command must all have unique names.").Append(" ") + .Append("Comparison is NOT case-sensitive.") + .ToString()); + } + + var duplicateShortNameGroup = command.Options + .Where(o => o.ShortName != null) + .GroupBy(o => o.ShortName) + .FirstOrDefault(g => g.Count() > 1); + + if (duplicateShortNameGroup != null) + { + throw new CliFxException(new StringBuilder() + .AppendLine($"Command {command.Type.FullName} contains two or more options that have the same short name ({duplicateShortNameGroup.Key}):") + .AppendBulletList(duplicateShortNameGroup.Select(o => o.Property.Name)) + .AppendLine() + .Append("Options in a command must all have unique short names.").Append(" ") + .Append("Comparison is case-sensitive.") + .ToString()); + } + + var duplicateEnvironmentVariableNameGroup = command.Options + .Where(o => !string.IsNullOrWhiteSpace(o.EnvironmentVariableName)) + .GroupBy(o => o.EnvironmentVariableName, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(g => g.Count() > 1); + + if (duplicateEnvironmentVariableNameGroup != null) + { + throw new CliFxException(new StringBuilder() + .AppendLine($"Command {command.Type.FullName} contains two or more options that have the same environment variable name ({duplicateEnvironmentVariableNameGroup.Key}):") + .AppendBulletList(duplicateEnvironmentVariableNameGroup.Select(o => o.Property.Name)) + .AppendLine() + .Append("Options in a command must all have unique environment variable names.").Append(" ") + .Append("Comparison is NOT case-sensitive.") + .ToString()); + } + } + + private static void ValidateCommands(IReadOnlyList commands) + { + if (!commands.Any()) + { + throw new CliFxException("There are no commands configured for this application."); + } + + var duplicateNameGroup = commands + .GroupBy(c => c.Name, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(g => g.Count() > 1); + + if (duplicateNameGroup != null) + { + throw new CliFxException(new StringBuilder() + .AppendLine($"Application contains two or more commands that have the same name ({duplicateNameGroup.Key}):") + .AppendBulletList(duplicateNameGroup.Select(o => o.Type.FullName)) + .AppendLine() + .Append("Commands must all have unique names. Likewise, there must not be more than one command without a name.").Append(" ") + .Append("Comparison is NOT case-sensitive.") + .ToString()); + } + } + + public static ApplicationSchema Resolve(IReadOnlyList commandTypes) + { + var commands = new List(); + + foreach (var commandType in commandTypes) + { + var command = CommandSchema.TryResolve(commandType); + if (command == null) + { + throw new CliFxException(new StringBuilder() + .Append($"Command {commandType.FullName} is not a valid command type.").Append(" ") + .AppendLine("In order to be a valid command type it must:") + .AppendLine($" - Be annotated with {typeof(CommandAttribute).FullName}") + .AppendLine($" - Implement {typeof(ICommand).FullName}") + .AppendLine(" - Not be an abstract class") + .ToString()); + } + + ValidateParameters(command); + ValidateOptions(command); + + commands.Add(command); + } + + ValidateCommands(commands); + + return new ApplicationSchema(commands); + } + } +} \ No newline at end of file diff --git a/CliFx/Domain/CommandArgumentSchema.cs b/CliFx/Domain/CommandArgumentSchema.cs new file mode 100644 index 0000000..49a1b89 --- /dev/null +++ b/CliFx/Domain/CommandArgumentSchema.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; +using System.Text; +using CliFx.Exceptions; +using CliFx.Internal; + +namespace CliFx.Domain +{ + internal abstract partial class CommandArgumentSchema + { + public PropertyInfo Property { get; } + + public string? Description { get; } + + public bool IsScalar => GetEnumerableArgumentUnderlyingType() == null; + + protected CommandArgumentSchema(PropertyInfo property, string? description) + { + Property = property; + Description = description; + } + + private Type? GetEnumerableArgumentUnderlyingType() => + Property.PropertyType != typeof(string) + ? Property.PropertyType.GetEnumerableUnderlyingType() + : null; + + private object Convert(IReadOnlyList values) + { + var targetType = Property.PropertyType; + var enumerableUnderlyingType = GetEnumerableArgumentUnderlyingType(); + + // Scalar + if (enumerableUnderlyingType == null) + { + if (values.Count > 1) + { + throw new CliFxException(new StringBuilder() + .AppendLine($"Can't convert a sequence of values [{string.Join(", ", values)}] to type {targetType.FullName}.") + .Append("Target type is not enumerable and can't accept more than one value.") + .ToString()); + } + + return ConvertScalar(values.SingleOrDefault(), targetType); + } + // Non-scalar + else + { + return ConvertNonScalar(values, targetType, enumerableUnderlyingType); + } + } + + public void Inject(ICommand command, IReadOnlyList values) => + Property.SetValue(command, Convert(values)); + + public void Inject(ICommand command, params string[] values) => + Inject(command, (IReadOnlyList) values); + } + + internal partial class CommandArgumentSchema + { + private static readonly IFormatProvider ConversionFormatProvider = CultureInfo.InvariantCulture; + + private static readonly IReadOnlyDictionary> PrimitiveConverters = + new Dictionary> + { + [typeof(object)] = v => v, + [typeof(string)] = v => v, + [typeof(bool)] = v => string.IsNullOrWhiteSpace(v) || bool.Parse(v), + [typeof(char)] = v => v.Single(), + [typeof(sbyte)] = v => sbyte.Parse(v, ConversionFormatProvider), + [typeof(byte)] = v => byte.Parse(v, ConversionFormatProvider), + [typeof(short)] = v => short.Parse(v, ConversionFormatProvider), + [typeof(ushort)] = v => ushort.Parse(v, ConversionFormatProvider), + [typeof(int)] = v => int.Parse(v, ConversionFormatProvider), + [typeof(uint)] = v => uint.Parse(v, ConversionFormatProvider), + [typeof(long)] = v => long.Parse(v, ConversionFormatProvider), + [typeof(ulong)] = v => ulong.Parse(v, ConversionFormatProvider), + [typeof(float)] = v => float.Parse(v, ConversionFormatProvider), + [typeof(double)] = v => double.Parse(v, ConversionFormatProvider), + [typeof(decimal)] = v => decimal.Parse(v, ConversionFormatProvider), + [typeof(DateTime)] = v => DateTime.Parse(v, ConversionFormatProvider), + [typeof(DateTimeOffset)] = v => DateTimeOffset.Parse(v, ConversionFormatProvider), + [typeof(TimeSpan)] = v => TimeSpan.Parse(v, ConversionFormatProvider), + }; + + private static ConstructorInfo? GetStringConstructor(Type type) => + type.GetConstructor(new[] {typeof(string)}); + + private static MethodInfo? GetStaticParseMethod(Type type) => + type.GetMethod("Parse", + BindingFlags.Public | BindingFlags.Static, + null, new[] {typeof(string)}, null); + + private static MethodInfo? GetStaticParseMethodWithFormatProvider(Type type) => + type.GetMethod("Parse", + BindingFlags.Public | BindingFlags.Static, + null, new[] {typeof(string), typeof(IFormatProvider)}, null); + + private static object ConvertScalar(string? value, Type targetType) + { + try + { + // Primitive + var primitiveConverter = PrimitiveConverters.GetValueOrDefault(targetType); + if (primitiveConverter != null) + return primitiveConverter(value); + + // Enum + if (targetType.IsEnum) + return Enum.Parse(targetType, value, true); + + // Nullable + var nullableUnderlyingType = targetType.GetNullableUnderlyingType(); + if (nullableUnderlyingType != null) + return !string.IsNullOrWhiteSpace(value) + ? ConvertScalar(value, nullableUnderlyingType) + : null; + + // String-constructable + var stringConstructor = GetStringConstructor(targetType); + if (stringConstructor != null) + return stringConstructor.Invoke(new object[] {value}); + + // String-parseable (with format provider) + var parseMethodWithFormatProvider = GetStaticParseMethodWithFormatProvider(targetType); + if (parseMethodWithFormatProvider != null) + return parseMethodWithFormatProvider.Invoke(null, new object[] {value, ConversionFormatProvider}); + + // String-parseable (without format provider) + var parseMethod = GetStaticParseMethod(targetType); + if (parseMethod != null) + return parseMethod.Invoke(null, new object[] {value}); + } + catch (Exception ex) + { + throw new CliFxException(new StringBuilder() + .AppendLine($"Failed to convert value '{value ?? ""}' to type {targetType.FullName}.") + .Append(ex.Message) + .ToString(), ex); + } + + throw new CliFxException(new StringBuilder() + .AppendLine($"Can't convert value '{value ?? ""}' to type {targetType.FullName}.") + .Append("Target type is not supported by CliFx.") + .ToString()); + } + + private static object ConvertNonScalar(IReadOnlyList values, Type targetEnumerableType, Type targetElementType) + { + var array = values + .Select(v => ConvertScalar(v, targetElementType)) + .ToNonGenericArray(targetElementType); + + var arrayType = array.GetType(); + + // Assignable from an array + if (targetEnumerableType.IsAssignableFrom(arrayType)) + return array; + + // Constructable from an array + var arrayConstructor = targetEnumerableType.GetConstructor(new[] {arrayType}); + if (arrayConstructor != null) + return arrayConstructor.Invoke(new object[] {array}); + + throw new CliFxException(new StringBuilder() + .AppendLine($"Can't convert a sequence of values [{string.Join(", ", values)}] to type {targetEnumerableType.FullName}.") + .AppendLine($"Underlying element type is [{targetElementType.FullName}].") + .Append("Target type must either be assignable from an array or have a public constructor that takes a single array argument.") + .ToString()); + } + } +} \ No newline at end of file diff --git a/CliFx/Domain/CommandLineInput.cs b/CliFx/Domain/CommandLineInput.cs new file mode 100644 index 0000000..647b82d --- /dev/null +++ b/CliFx/Domain/CommandLineInput.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using CliFx.Internal; + +namespace CliFx.Domain +{ + internal partial class CommandLineInput + { + public IReadOnlyList Directives { get; } + + public IReadOnlyList Arguments { get; } + + public IReadOnlyList Options { get; } + + public bool IsDebugDirectiveSpecified => Directives.Contains("debug", StringComparer.OrdinalIgnoreCase); + + public bool IsPreviewDirectiveSpecified => Directives.Contains("preview", StringComparer.OrdinalIgnoreCase); + + public bool IsHelpOptionSpecified => + Options.Any(o => CommandOptionSchema.HelpOption.MatchesNameOrShortName(o.Alias)); + + public bool IsVersionOptionSpecified => + Options.Any(o => CommandOptionSchema.VersionOption.MatchesNameOrShortName(o.Alias)); + + public CommandLineInput( + IReadOnlyList directives, + IReadOnlyList arguments, + IReadOnlyList options) + { + Directives = directives; + Arguments = arguments; + Options = options; + } + + public CommandLineInput( + IReadOnlyList arguments, + IReadOnlyList options) + : this(new string[0], arguments, options) + { + } + + public CommandLineInput(IReadOnlyList arguments) + : this(arguments, new CommandOptionInput[0]) + { + } + + public CommandLineInput(IReadOnlyList options) + : this(new string[0], options) + { + } + + public override string ToString() + { + var buffer = new StringBuilder(); + + foreach (var directive in Directives) + { + buffer.AppendIfNotEmpty(' '); + buffer + .Append('[') + .Append(directive) + .Append(']'); + } + + foreach (var argument in Arguments) + { + buffer.AppendIfNotEmpty(' '); + buffer.Append(argument); + } + + foreach (var option in Options) + { + buffer.AppendIfNotEmpty(' '); + buffer.Append(option); + } + + return buffer.ToString(); + } + } + + internal partial class CommandLineInput + { + public static CommandLineInput Parse(IReadOnlyList commandLineArguments) + { + var directives = new List(); + var arguments = new List(); + var optionsDic = new Dictionary>(); + + // Option aliases and values are parsed in pairs so we need to keep track of last alias + var lastOptionAlias = ""; + + bool TryParseDirective(string argument) + { + if (!string.IsNullOrWhiteSpace(lastOptionAlias)) + return false; + + if (!argument.StartsWith("[", StringComparison.OrdinalIgnoreCase) || + !argument.EndsWith("]", StringComparison.OrdinalIgnoreCase)) + return false; + + var directive = argument.Substring(1, argument.Length - 2); + directives.Add(directive); + + return true; + } + + bool TryParseArgument(string argument) + { + if (!string.IsNullOrWhiteSpace(lastOptionAlias)) + return false; + + arguments.Add(argument); + + return true; + } + + bool TryParseOptionName(string argument) + { + if (!argument.StartsWith("--", StringComparison.OrdinalIgnoreCase)) + return false; + + lastOptionAlias = argument.Substring(2); + + if (!optionsDic.ContainsKey(lastOptionAlias)) + optionsDic[lastOptionAlias] = new List(); + + return true; + } + + bool TryParseOptionShortName(string argument) + { + if (!argument.StartsWith("-", StringComparison.OrdinalIgnoreCase)) + return false; + + foreach (var c in argument.Substring(1)) + { + lastOptionAlias = c.AsString(); + + if (!optionsDic.ContainsKey(lastOptionAlias)) + optionsDic[lastOptionAlias] = new List(); + } + + return true; + } + + bool TryParseOptionValue(string argument) + { + if (string.IsNullOrWhiteSpace(lastOptionAlias)) + return false; + + optionsDic[lastOptionAlias].Add(argument); + + return true; + } + + foreach (var argument in commandLineArguments) + { + var _ = + TryParseOptionName(argument) || + TryParseOptionShortName(argument) || + TryParseDirective(argument) || + TryParseArgument(argument) || + TryParseOptionValue(argument); + } + + var options = optionsDic.Select(p => new CommandOptionInput(p.Key, p.Value)).ToArray(); + + return new CommandLineInput(directives, arguments, options); + } + } + + internal partial class CommandLineInput + { + public static CommandLineInput Empty { get; } = + new CommandLineInput(new string[0], new string[0], new CommandOptionInput[0]); + } +} \ No newline at end of file diff --git a/CliFx/Models/CommandOptionInput.cs b/CliFx/Domain/CommandOptionInput.cs similarity index 56% rename from CliFx/Models/CommandOptionInput.cs rename to CliFx/Domain/CommandOptionInput.cs index 06feb29..8f4009a 100644 --- a/CliFx/Models/CommandOptionInput.cs +++ b/CliFx/Domain/CommandOptionInput.cs @@ -2,49 +2,30 @@ using System.Text; using CliFx.Internal; -namespace CliFx.Models +namespace CliFx.Domain { - /// - /// Parsed option from command line input. - /// - public partial class CommandOptionInput + internal class CommandOptionInput { - /// - /// Specified option alias. - /// public string Alias { get; } - /// - /// Specified values. - /// public IReadOnlyList Values { get; } - /// - /// Initializes an instance of . - /// public CommandOptionInput(string alias, IReadOnlyList values) { Alias = alias; Values = values; } - /// - /// Initializes an instance of . - /// public CommandOptionInput(string alias, string value) : this(alias, new[] {value}) { } - /// - /// Initializes an instance of . - /// public CommandOptionInput(string alias) - : this(alias, EmptyValues) + : this(alias, new string[0]) { } - /// public override string ToString() { var buffer = new StringBuilder(); @@ -70,9 +51,4 @@ namespace CliFx.Models return buffer.ToString(); } } - - public partial class CommandOptionInput - { - private static readonly IReadOnlyList EmptyValues = new string[0]; - } } \ No newline at end of file diff --git a/CliFx/Domain/CommandOptionSchema.cs b/CliFx/Domain/CommandOptionSchema.cs new file mode 100644 index 0000000..685ce25 --- /dev/null +++ b/CliFx/Domain/CommandOptionSchema.cs @@ -0,0 +1,105 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Text; +using CliFx.Attributes; +using CliFx.Internal; + +namespace CliFx.Domain +{ + internal partial class CommandOptionSchema : CommandArgumentSchema + { + public string? Name { get; } + + public char? ShortName { get; } + + public string DisplayName => !string.IsNullOrWhiteSpace(Name) + ? Name + : ShortName?.AsString()!; + + public string? EnvironmentVariableName { get; } + + public bool IsRequired { get; } + + public CommandOptionSchema( + PropertyInfo property, + string? name, + char? shortName, + string? environmentVariableName, + bool isRequired, + string? description) + : base(property, description) + { + Name = name; + ShortName = shortName; + EnvironmentVariableName = environmentVariableName; + IsRequired = isRequired; + } + + public bool MatchesName(string name) => + !string.IsNullOrWhiteSpace(Name) && + string.Equals(Name, name, StringComparison.OrdinalIgnoreCase); + + public bool MatchesShortName(char shortName) => + ShortName != null && + ShortName == shortName; + + public bool MatchesNameOrShortName(string alias) => + MatchesName(alias) || + alias.Length == 1 && MatchesShortName(alias.Single()); + + public bool MatchesEnvironmentVariableName(string environmentVariableName) => + !string.IsNullOrWhiteSpace(EnvironmentVariableName) && + string.Equals(EnvironmentVariableName, environmentVariableName, StringComparison.OrdinalIgnoreCase); + + public override string ToString() + { + var buffer = new StringBuilder(); + + if (!string.IsNullOrWhiteSpace(Name)) + { + buffer.Append("--"); + buffer.Append(Name); + } + + if (!string.IsNullOrWhiteSpace(Name) && ShortName != null) + buffer.Append('|'); + + if (ShortName != null) + { + buffer.Append('-'); + buffer.Append(ShortName); + } + + return buffer.ToString(); + } + } + + internal partial class CommandOptionSchema + { + public static CommandOptionSchema? TryResolve(PropertyInfo property) + { + var attribute = property.GetCustomAttribute(); + if (attribute == null) + return null; + + return new CommandOptionSchema( + property, + attribute.Name, + attribute.ShortName, + attribute.EnvironmentVariableName, + attribute.IsRequired, + attribute.Description + ); + } + } + + internal partial class CommandOptionSchema + { + public static CommandOptionSchema HelpOption { get; } = + new CommandOptionSchema(null!, "help", 'h', null, false, "Shows help text."); + + public static CommandOptionSchema VersionOption { get; } = + new CommandOptionSchema(null!, "version", null, null, false, "Shows version information."); + } +} \ No newline at end of file diff --git a/CliFx/Domain/CommandParameterSchema.cs b/CliFx/Domain/CommandParameterSchema.cs new file mode 100644 index 0000000..82a83db --- /dev/null +++ b/CliFx/Domain/CommandParameterSchema.cs @@ -0,0 +1,53 @@ +using System.Reflection; +using System.Text; +using CliFx.Attributes; + +namespace CliFx.Domain +{ + internal partial class CommandParameterSchema : CommandArgumentSchema + { + public int Order { get; } + + public string? Name { get; } + + public string DisplayName => !string.IsNullOrWhiteSpace(Name) + ? Name + : Property.Name.ToUpperInvariant(); + + public CommandParameterSchema(PropertyInfo property, int order, string? name, string? description) + : base(property, description) + { + Order = order; + Name = name; + } + + public override string ToString() + { + var buffer = new StringBuilder(); + + buffer + .Append('<') + .Append(DisplayName) + .Append('>'); + + return buffer.ToString(); + } + } + + internal partial class CommandParameterSchema + { + public static CommandParameterSchema? TryResolve(PropertyInfo property) + { + var attribute = property.GetCustomAttribute(); + if (attribute == null) + return null; + + return new CommandParameterSchema( + property, + attribute.Order, + attribute.Name, + attribute.Description + ); + } + } +} \ No newline at end of file diff --git a/CliFx/Domain/CommandSchema.cs b/CliFx/Domain/CommandSchema.cs new file mode 100644 index 0000000..d55abaf --- /dev/null +++ b/CliFx/Domain/CommandSchema.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using CliFx.Attributes; +using CliFx.Exceptions; +using CliFx.Internal; + +namespace CliFx.Domain +{ + internal partial class CommandSchema + { + public Type Type { get; } + + public string? Name { get; } + + public bool IsDefault => string.IsNullOrWhiteSpace(Name); + + public string? Description { get; } + + public IReadOnlyList Parameters { get; } + + public IReadOnlyList Options { get; } + + public CommandSchema( + Type type, + string? name, + string? description, + IReadOnlyList parameters, + IReadOnlyList options) + { + Type = type; + Name = name; + Description = description; + Options = options; + Parameters = parameters; + } + + public bool MatchesName(string name) => string.Equals(name, Name, StringComparison.OrdinalIgnoreCase); + + private void InjectParameters(ICommand command, IReadOnlyList parameterInputs) + { + // Scalar parameters + var scalarParameters = Parameters + .OrderBy(p => p.Order) + .TakeWhile(p => p.IsScalar) + .ToArray(); + + for (var i = 0; i < scalarParameters.Length; i++) + { + var scalarParameter = scalarParameters[i]; + + var scalarParameterInput = i < parameterInputs.Count + ? parameterInputs[i] + : throw new CliFxException($"Missing value for parameter <{scalarParameter.DisplayName}>."); + + scalarParameter.Inject(command, scalarParameterInput); + } + + // Non-scalar parameter (only one is allowed) + var nonScalarParameter = Parameters + .OrderBy(p => p.Order) + .FirstOrDefault(p => !p.IsScalar); + + if (nonScalarParameter != null) + { + var nonScalarParameterInputs = parameterInputs.Skip(scalarParameters.Length).ToArray(); + nonScalarParameter.Inject(command, nonScalarParameterInputs); + } + } + + private void InjectOptions( + ICommand command, + IReadOnlyList optionInputs, + IReadOnlyDictionary environmentVariables) + { + // Keep track of required options so that we can raise an error if any of them are not set + var unsetRequiredOptions = Options.Where(o => o.IsRequired).ToList(); + + // Environment variables + foreach (var environmentVariable in environmentVariables) + { + var option = Options.FirstOrDefault(o => o.MatchesEnvironmentVariableName(environmentVariable.Key)); + + if (option != null) + { + var values = option.IsScalar + ? new[] {environmentVariable.Value} + : environmentVariable.Value.Split(Path.PathSeparator); + + option.Inject(command, values); + unsetRequiredOptions.Remove(option); + } + } + + // Direct input + foreach (var optionInput in optionInputs) + { + var option = Options.FirstOrDefault(o => o.MatchesNameOrShortName(optionInput.Alias)); + + if (option != null) + { + option.Inject(command, optionInput.Values); + unsetRequiredOptions.Remove(option); + } + } + + if (unsetRequiredOptions.Any()) + { + throw new CliFxException(new StringBuilder() + .AppendLine("Missing values for some of the required options:") + .AppendBulletList(unsetRequiredOptions.Select(o => o.DisplayName)) + .ToString()); + } + } + + public ICommand CreateInstance( + IReadOnlyList parameterInputs, + IReadOnlyList optionInputs, + IReadOnlyDictionary environmentVariables, + ITypeActivator activator) + { + var command = (ICommand) activator.CreateInstance(Type); + + InjectParameters(command, parameterInputs); + InjectOptions(command, optionInputs, environmentVariables); + + return command; + } + + public override string ToString() + { + var buffer = new StringBuilder(); + + if (!string.IsNullOrWhiteSpace(Name)) + buffer.Append(Name); + + foreach (var parameter in Parameters) + { + buffer.AppendIfNotEmpty(' '); + buffer.Append(parameter); + } + + foreach (var option in Options) + { + buffer.AppendIfNotEmpty(' '); + buffer.Append(option); + } + + return buffer.ToString(); + } + } + + internal partial class CommandSchema + { + public static bool IsCommandType(Type type) => + type.Implements(typeof(ICommand)) && + type.IsDefined(typeof(CommandAttribute)) && + !type.IsAbstract && + !type.IsInterface; + + public static CommandSchema? TryResolve(Type type) + { + if (!IsCommandType(type)) + return null; + + var attribute = type.GetCustomAttribute(); + + var parameters = type.GetProperties() + .Select(CommandParameterSchema.TryResolve) + .Where(p => p != null) + .ToArray(); + + var options = type.GetProperties() + .Select(CommandOptionSchema.TryResolve) + .Where(o => o != null) + .ToArray(); + + return new CommandSchema( + type, + attribute?.Name, + attribute?.Description, + parameters, + options + ); + } + } + + internal partial class CommandSchema + { + public static CommandSchema StubDefaultCommand { get; } = + new CommandSchema(null!, null, null, new CommandParameterSchema[0], new CommandOptionSchema[0]); + } +} \ No newline at end of file diff --git a/CliFx/Exceptions/CommandException.cs b/CliFx/Exceptions/CommandException.cs index af3d254..f43b4e9 100644 --- a/CliFx/Exceptions/CommandException.cs +++ b/CliFx/Exceptions/CommandException.cs @@ -22,7 +22,9 @@ namespace CliFx.Exceptions public CommandException(string? message, Exception? innerException, int exitCode = DefaultExitCode) : base(message, innerException) { - ExitCode = exitCode != 0 ? exitCode : throw new ArgumentException("Exit code cannot be zero because that signifies success."); + ExitCode = exitCode != 0 + ? exitCode + : throw new ArgumentException("Exit code must not be zero in order to signify failure."); } /// diff --git a/CliFx/Extensions.cs b/CliFx/Extensions.cs index 9b93860..2789af3 100644 --- a/CliFx/Extensions.cs +++ b/CliFx/Extensions.cs @@ -1,48 +1,42 @@ using System; -using System.Collections.Generic; -using System.Reflection; -using CliFx.Models; -using CliFx.Services; namespace CliFx { /// - /// Extensions for . + /// Extensions for /// public static class Extensions { /// - /// Adds multiple commands to the application. + /// Sets console foreground color, executes specified action, and sets the color back to the original value. /// - public static ICliApplicationBuilder AddCommands(this ICliApplicationBuilder builder, IReadOnlyList commandTypes) + public static void WithForegroundColor(this IConsole console, ConsoleColor foregroundColor, Action action) { - foreach (var commandType in commandTypes) - builder.AddCommand(commandType); + var lastColor = console.ForegroundColor; + console.ForegroundColor = foregroundColor; - return builder; + action(); + + console.ForegroundColor = lastColor; } /// - /// Adds commands from specified assemblies to the application. + /// Sets console background color, executes specified action, and sets the color back to the original value. /// - public static ICliApplicationBuilder AddCommandsFrom(this ICliApplicationBuilder builder, IReadOnlyList commandAssemblies) + public static void WithBackgroundColor(this IConsole console, ConsoleColor backgroundColor, Action action) { - foreach (var commandAssembly in commandAssemblies) - builder.AddCommandsFrom(commandAssembly); + var lastColor = console.BackgroundColor; + console.BackgroundColor = backgroundColor; - return builder; + action(); + + console.BackgroundColor = lastColor; } /// - /// Adds commands from calling assembly to the application. + /// Sets console foreground and background colors, executes specified action, and sets the colors back to the original values. /// - public static ICliApplicationBuilder AddCommandsFromThisAssembly(this ICliApplicationBuilder builder) => - builder.AddCommandsFrom(Assembly.GetCallingAssembly()); - - /// - /// Configures application to use specified factory method for creating new instances of . - /// - public static ICliApplicationBuilder UseCommandFactory(this ICliApplicationBuilder builder, Func factoryMethod) => - builder.UseCommandFactory(new DelegateCommandFactory(factoryMethod)); + public static void WithColors(this IConsole console, ConsoleColor foregroundColor, ConsoleColor backgroundColor, Action action) => + console.WithForegroundColor(foregroundColor, () => console.WithBackgroundColor(backgroundColor, action)); } } \ No newline at end of file diff --git a/CliFx/ICliApplication.cs b/CliFx/ICliApplication.cs deleted file mode 100644 index 019c21b..0000000 --- a/CliFx/ICliApplication.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace CliFx -{ - /// - /// Entry point for a command line application. - /// - public interface ICliApplication - { - /// - /// Runs application with specified command line arguments and returns an exit code. - /// - ValueTask RunAsync(IReadOnlyList commandLineArguments); - } -} \ No newline at end of file diff --git a/CliFx/ICliApplicationBuilder.cs b/CliFx/ICliApplicationBuilder.cs deleted file mode 100644 index e7aa2e2..0000000 --- a/CliFx/ICliApplicationBuilder.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Reflection; -using CliFx.Services; - -namespace CliFx -{ - /// - /// Builds an instance of . - /// - public interface ICliApplicationBuilder - { - /// - /// Adds a command of specified type to the application. - /// - ICliApplicationBuilder AddCommand(Type commandType); - - /// - /// Adds commands from specified assembly to the application. - /// - ICliApplicationBuilder AddCommandsFrom(Assembly commandAssembly); - - /// - /// Specifies whether debug mode (enabled with [debug] directive) is allowed in the application. - /// - ICliApplicationBuilder AllowDebugMode(bool isAllowed = true); - - /// - /// Specifies whether preview mode (enabled with [preview] directive) is allowed in the application. - /// - ICliApplicationBuilder AllowPreviewMode(bool isAllowed = true); - - /// - /// Sets application title, which appears in the help text. - /// - ICliApplicationBuilder UseTitle(string title); - - /// - /// Sets application executable name, which appears in the help text. - /// - ICliApplicationBuilder UseExecutableName(string executableName); - - /// - /// Sets application version text, which appears in the help text and when the user requests version information. - /// - ICliApplicationBuilder UseVersionText(string versionText); - - /// - /// Sets application description, which appears in the help text. - /// - ICliApplicationBuilder UseDescription(string? description); - - /// - /// Configures application to use specified implementation of . - /// - ICliApplicationBuilder UseConsole(IConsole console); - - /// - /// Configures application to use specified implementation of . - /// - ICliApplicationBuilder UseCommandFactory(ICommandFactory factory); - - /// - /// Configures application to use specified implementation of . - /// - ICliApplicationBuilder UseCommandOptionInputConverter(ICommandInputConverter converter); - - /// - /// Configures application to use specified implementation of . - /// - ICliApplicationBuilder UseEnvironmentVariablesProvider(IEnvironmentVariablesProvider environmentVariablesProvider); - - /// - /// Creates an instance of using configured parameters. - /// Default values are used in place of parameters that were not specified. - /// - ICliApplication Build(); - } -} \ No newline at end of file diff --git a/CliFx/ICommand.cs b/CliFx/ICommand.cs index 9c5826f..7eb95d8 100644 --- a/CliFx/ICommand.cs +++ b/CliFx/ICommand.cs @@ -1,17 +1,17 @@ using System.Threading.Tasks; -using CliFx.Services; namespace CliFx { /// - /// Point of interaction between a user and command line interface. + /// Entry point in a command line application. /// public interface ICommand { /// - /// Executes command using specified implementation of . - /// This method is called when the command is invoked by a user through command line interface. + /// Executes the command using the specified implementation of . + /// This is the method that's called when the command is invoked by a user through command line interface. /// + /// If the execution of the command is not asynchronous, simply end the method with return default; ValueTask ExecuteAsync(IConsole console); } } \ No newline at end of file diff --git a/CliFx/Services/IConsole.cs b/CliFx/IConsole.cs similarity index 87% rename from CliFx/Services/IConsole.cs rename to CliFx/IConsole.cs index de85fe6..8c1087e 100644 --- a/CliFx/Services/IConsole.cs +++ b/CliFx/IConsole.cs @@ -2,7 +2,7 @@ using System.IO; using System.Threading; -namespace CliFx.Services +namespace CliFx { /// /// Abstraction for interacting with the console. @@ -55,8 +55,9 @@ namespace CliFx.Services void ResetColor(); /// - /// Provides token that cancels when application cancellation is requested. + /// Provides a token that signals when application cancellation is requested. /// Subsequent calls return the same token. + /// When working with system console, the user can request cancellation by issuing an interrupt signal (Ctrl+C). /// CancellationToken GetCancellationToken(); } diff --git a/CliFx/ITypeActivator.cs b/CliFx/ITypeActivator.cs new file mode 100644 index 0000000..878753e --- /dev/null +++ b/CliFx/ITypeActivator.cs @@ -0,0 +1,15 @@ +using System; + +namespace CliFx +{ + /// + /// Abstraction for a service can initialize objects at runtime. + /// + public interface ITypeActivator + { + /// + /// Creates an instance of specified type. + /// + object CreateInstance(Type type); + } +} \ No newline at end of file diff --git a/CliFx/Internal/Extensions.cs b/CliFx/Internal/Extensions.cs index 370f533..160da8a 100644 --- a/CliFx/Internal/Extensions.cs +++ b/CliFx/Internal/Extensions.cs @@ -3,7 +3,6 @@ using System.Collections; using System.Collections.Generic; using System.Linq; using System.Text; -using CliFx.Models; namespace CliFx.Internal { @@ -13,24 +12,19 @@ namespace CliFx.Internal public static string AsString(this char c) => c.Repeat(1); - public static string JoinToString(this IEnumerable source, string separator) => string.Join(separator, source); - - public static string SubstringUntilLast(this string s, string sub, - StringComparison comparison = StringComparison.Ordinal) - { - var index = s.LastIndexOf(sub, comparison); - return index < 0 ? s : s.Substring(0, index); - } - public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) => builder.Length > 0 ? builder.Append(value) : builder; - public static IEnumerable Concat(this IEnumerable source, T value) + public static StringBuilder AppendBulletList(this StringBuilder builder, IEnumerable items) { - foreach (var i in source) - yield return i; + foreach (var item in items) + { + builder.Append("- "); + builder.Append(item); + builder.AppendLine(); + } - yield return value; + return builder; } public static bool Implements(this Type type, Type interfaceType) => type.GetInterfaces().Contains(interfaceType); @@ -50,7 +44,7 @@ namespace CliFx.Internal return type.GetInterfaces() .Select(GetEnumerableUnderlyingType) - .Where(t => t != default) + .Where(t => t != null) .OrderByDescending(t => t != typeof(object)) // prioritize more specific types .FirstOrDefault(); } @@ -64,14 +58,5 @@ namespace CliFx.Internal return array; } - - public static bool IsCollection(this Type type) => - type != typeof(string) && type.GetEnumerableUnderlyingType() != null; - - public static IOrderedEnumerable Ordered(this IEnumerable source) - { - return source - .OrderBy(a => a.Order); - } } } \ No newline at end of file diff --git a/CliFx/Internal/Polyfills.cs b/CliFx/Internal/Polyfills.cs new file mode 100644 index 0000000..77dfcaa --- /dev/null +++ b/CliFx/Internal/Polyfills.cs @@ -0,0 +1,16 @@ +#if NET45 || NETSTANDARD2_0 +using System.Collections.Generic; +using System.Text; + +namespace CliFx.Internal +{ + internal static class Polyfills + { + public static TValue GetValueOrDefault(this IReadOnlyDictionary self, TKey key) => + self.TryGetValue(key, out var value) ? value : default; + + public static StringBuilder AppendJoin(this StringBuilder self, string separator, IEnumerable items) => + self.Append(string.Join(separator, items)); + } +} +#endif \ No newline at end of file diff --git a/CliFx/Models/CommandArgumentSchema.cs b/CliFx/Models/CommandArgumentSchema.cs deleted file mode 100644 index 43bb674..0000000 --- a/CliFx/Models/CommandArgumentSchema.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System.Globalization; -using System.Reflection; -using System.Text; - -namespace CliFx.Models -{ - /// - /// Schema of a defined command argument. - /// - public class CommandArgumentSchema - { - /// - /// Underlying property. - /// - public PropertyInfo Property { get; } - - /// - /// Argument name used for help text. - /// - public string? Name { get; } - - /// - /// Whether the argument is required. - /// - public bool IsRequired { get; } - - /// - /// Argument description. - /// - public string? Description { get; } - - /// - /// Order of the argument. - /// - public int Order { get; } - - /// - /// The display name of the argument. Returns if specified, otherwise the name of the underlying property. - /// - public string DisplayName => !string.IsNullOrWhiteSpace(Name) ? Name! : Property.Name.ToLower(CultureInfo.InvariantCulture); - - /// - /// Initializes an instance of . - /// - public CommandArgumentSchema(PropertyInfo property, string? name, bool isRequired, string? description, int order) - { - Property = property; - Name = name; - IsRequired = isRequired; - Description = description; - Order = order; - } - - /// - /// Returns the string representation of the argument schema. - /// - /// - public override string ToString() - { - var sb = new StringBuilder(); - if (!IsRequired) - { - sb.Append("["); - } - - sb.Append("<"); - sb.Append($"{DisplayName}"); - sb.Append(">"); - - if (!IsRequired) - { - sb.Append("]"); - } - - return sb.ToString(); - } - } -} \ No newline at end of file diff --git a/CliFx/Models/CommandCandidate.cs b/CliFx/Models/CommandCandidate.cs deleted file mode 100644 index 53915df..0000000 --- a/CliFx/Models/CommandCandidate.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Collections.Generic; - -namespace CliFx.Models -{ - /// - /// Defines the target command and the input required for initializing the command. - /// - public class CommandCandidate - { - /// - /// The command schema of the target command. - /// - public CommandSchema Schema { get; } - - /// - /// The positional arguments input for the command. - /// - public IReadOnlyList PositionalArgumentsInput { get; } - - /// - /// The command input for the command. - /// - public CommandInput CommandInput { get; } - - /// - /// Initializes and instance of - /// - public CommandCandidate(CommandSchema schema, IReadOnlyList positionalArgumentsInput, CommandInput commandInput) - { - Schema = schema; - PositionalArgumentsInput = positionalArgumentsInput; - CommandInput = commandInput; - } - } -} diff --git a/CliFx/Models/CommandInput.cs b/CliFx/Models/CommandInput.cs deleted file mode 100644 index 53a81b5..0000000 --- a/CliFx/Models/CommandInput.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System.Collections.Generic; -using System.Text; -using CliFx.Internal; - -namespace CliFx.Models -{ - /// - /// Parsed command line input. - /// - public partial class CommandInput - { - /// - /// Specified arguments. - /// - public IReadOnlyList Arguments { get; } - - /// - /// Specified directives. - /// - public IReadOnlyList Directives { get; } - - /// - /// Specified options. - /// - public IReadOnlyList Options { get; } - - /// - /// Environment variables available when the command was parsed - /// - public IReadOnlyDictionary EnvironmentVariables { get; } - - /// - /// Initializes an instance of . - /// - public CommandInput(IReadOnlyList arguments, IReadOnlyList directives, IReadOnlyList options, - IReadOnlyDictionary environmentVariables) - { - Arguments = arguments; - Directives = directives; - Options = options; - EnvironmentVariables = environmentVariables; - } - - /// - /// Initializes an instance of . - /// - public CommandInput(IReadOnlyList arguments, IReadOnlyList directives, IReadOnlyList options) - : this(arguments, directives, options, EmptyEnvironmentVariables) - { - } - - /// - /// Initializes an instance of . - /// - public CommandInput(IReadOnlyList arguments, IReadOnlyList options, IReadOnlyDictionary environmentVariables) - : this(arguments, EmptyDirectives, options, environmentVariables) - { - } - - /// - /// Initializes an instance of . - /// - public CommandInput(IReadOnlyList arguments, IReadOnlyList options) - : this(arguments, EmptyDirectives, options) - { - } - - /// - /// Initializes an instance of . - /// - public CommandInput(IReadOnlyList options) - : this(new string[0], options) - { - } - - /// - /// Initializes an instance of . - /// - public CommandInput(IReadOnlyList arguments) - : this(arguments, EmptyOptions) - { - } - - /// - public override string ToString() - { - var buffer = new StringBuilder(); - - foreach (var argument in Arguments) - { - buffer.AppendIfNotEmpty(' '); - buffer.Append(argument); - } - - foreach (var directive in Directives) - { - buffer.AppendIfNotEmpty(' '); - buffer.Append(directive); - } - - foreach (var option in Options) - { - buffer.AppendIfNotEmpty(' '); - buffer.Append(option); - } - - return buffer.ToString(); - } - } - - public partial class CommandInput - { - private static readonly IReadOnlyList EmptyDirectives = new string[0]; - private static readonly IReadOnlyList EmptyOptions = new CommandOptionInput[0]; - private static readonly IReadOnlyDictionary EmptyEnvironmentVariables = new Dictionary(); - - /// - /// Empty input. - /// - public static CommandInput Empty { get; } = new CommandInput(EmptyOptions); - } -} \ No newline at end of file diff --git a/CliFx/Models/CommandOptionSchema.cs b/CliFx/Models/CommandOptionSchema.cs deleted file mode 100644 index 3a66292..0000000 --- a/CliFx/Models/CommandOptionSchema.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System.Reflection; -using System.Text; - -namespace CliFx.Models -{ - /// - /// Schema of a defined command option. - /// - public partial class CommandOptionSchema - { - /// - /// Underlying property. - /// - public PropertyInfo? Property { get; } - - /// - /// Option name. - /// - public string? Name { get; } - - /// - /// Option short name. - /// - public char? ShortName { get; } - - /// - /// Whether an option is required. - /// - public bool IsRequired { get; } - - /// - /// Option description. - /// - public string? Description { get; } - - /// - /// Optional environment variable name that will be used as fallback value if no option value is specified. - /// - public string? EnvironmentVariableName { get; } - - /// - /// Initializes an instance of . - /// - public CommandOptionSchema(PropertyInfo? property, string? name, char? shortName, bool isRequired, string? description, string? environmentVariableName) - { - Property = property; - Name = name; - ShortName = shortName; - IsRequired = isRequired; - Description = description; - EnvironmentVariableName = environmentVariableName; - } - - /// - public override string ToString() - { - var buffer = new StringBuilder(); - - if (IsRequired) - buffer.Append('*'); - - if (!string.IsNullOrWhiteSpace(Name)) - buffer.Append(Name); - - if (!string.IsNullOrWhiteSpace(Name) && ShortName != null) - buffer.Append('|'); - - if (ShortName != null) - buffer.Append(ShortName); - - return buffer.ToString(); - } - } - - public partial class CommandOptionSchema - { - // Here we define some built-in options. - // This is probably a bit hacky but I couldn't come up with a better solution given this architecture. - // We define them here to serve as a single source of truth, because they are used... - // ...in CliApplication (when reading) and HelpTextRenderer (when writing). - - internal static CommandOptionSchema HelpOption { get; } = - new CommandOptionSchema(null, "help", 'h', false, "Shows help text.", null); - - internal static CommandOptionSchema VersionOption { get; } = - new CommandOptionSchema(null, "version", null, false, "Shows version information.", null); - } -} \ No newline at end of file diff --git a/CliFx/Models/CommandSchema.cs b/CliFx/Models/CommandSchema.cs deleted file mode 100644 index 6f44d7b..0000000 --- a/CliFx/Models/CommandSchema.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using CliFx.Internal; - -namespace CliFx.Models -{ - /// - /// Schema of a defined command. - /// - public partial class CommandSchema - { - /// - /// Underlying type. - /// - public Type? Type { get; } - - /// - /// Command name. - /// - public string? Name { get; } - - /// - /// Command description. - /// - public string? Description { get; } - - /// - /// Command options. - /// - public IReadOnlyList Options { get; } - - /// - /// Command arguments. - /// - public IReadOnlyList Arguments { get; } - - /// - /// Initializes an instance of . - /// - public CommandSchema(Type type, string name, string description, IReadOnlyList arguments, IReadOnlyList options) - { - Type = type; - Name = name; - Description = description; - Options = options; - Arguments = arguments; - } - - /// - public override string ToString() - { - var buffer = new StringBuilder(); - - if (!string.IsNullOrWhiteSpace(Name)) - buffer.Append(Name); - - if (Options != null) - { - foreach (var option in Options) - { - buffer.AppendIfNotEmpty(' '); - buffer.Append('['); - buffer.Append(option); - buffer.Append(']'); - } - } - return buffer.ToString(); - } - } - - public partial class CommandSchema - { - internal static CommandSchema StubDefaultCommand { get; } = - new CommandSchema(null, null, null, new CommandArgumentSchema[0], new CommandOptionSchema[0]); - } -} \ No newline at end of file diff --git a/CliFx/Models/Extensions.cs b/CliFx/Models/Extensions.cs deleted file mode 100644 index f192fab..0000000 --- a/CliFx/Models/Extensions.cs +++ /dev/null @@ -1,131 +0,0 @@ -using CliFx.Internal; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.RegularExpressions; - -namespace CliFx.Models -{ - /// - /// Extensions for . - /// - public static class Extensions - { - /// - /// Finds a command that has specified name, or null if not found. - /// - public static CommandSchema? FindByName(this IReadOnlyList commandSchemas, string? commandName) - { - // If looking for default command, don't compare names directly - // ...because null and empty are both valid names for default command - if (string.IsNullOrWhiteSpace(commandName)) - return commandSchemas.FirstOrDefault(c => c.IsDefault()); - - return commandSchemas.FirstOrDefault(c => string.Equals(c.Name, commandName, StringComparison.OrdinalIgnoreCase)); - } - - /// - /// Finds parent command to the command that has specified name, or null if not found. - /// - public static CommandSchema? FindParent(this IReadOnlyList commandSchemas, string? commandName) - { - // If command has no name, it's the default command so it doesn't have a parent - if (string.IsNullOrWhiteSpace(commandName)) - return null; - - // Repeatedly cut off individual words from the name until we find a command with that name - var temp = commandName; - while (temp.Contains(" ")) - { - temp = temp.SubstringUntilLast(" "); - - var parent = commandSchemas.FindByName(temp); - if (parent != null) - return parent; - } - - // If no parent is matched by name, then the parent is the default command - return commandSchemas.FirstOrDefault(c => c.IsDefault()); - } - - /// - /// Determines whether an option schema matches specified alias. - /// - public static bool MatchesAlias(this CommandOptionSchema optionSchema, string alias) - { - // Compare against name. Case is ignored. - var matchesByName = - !string.IsNullOrWhiteSpace(optionSchema.Name) && - string.Equals(optionSchema.Name, alias, StringComparison.OrdinalIgnoreCase); - - // Compare against short name. Case is NOT ignored. - var matchesByShortName = - optionSchema.ShortName != null && - alias.Length == 1 && alias[0] == optionSchema.ShortName; - - return matchesByName || matchesByShortName; - } - - /// - /// Finds an option input that matches the option schema specified, or null if not found. - /// - public static CommandOptionInput? FindByOptionSchema(this IReadOnlyList optionInputs, CommandOptionSchema optionSchema) => - optionInputs.FirstOrDefault(o => optionSchema.MatchesAlias(o.Alias)); - - /// - /// Gets valid aliases for the option. - /// - public static IReadOnlyList GetAliases(this CommandOptionSchema optionSchema) - { - var result = new List(2); - - if (!string.IsNullOrWhiteSpace(optionSchema.Name)) - result.Add(optionSchema.Name!); - - if (optionSchema.ShortName != null) - result.Add(optionSchema.ShortName.Value.AsString()); - - return result; - } - - /// - /// Gets whether a command was specified in the input. - /// - public static bool HasArguments(this CommandInput commandInput) => commandInput.Arguments.Any(); - - /// - /// Gets whether debug directive was specified in the input. - /// - public static bool IsDebugDirectiveSpecified(this CommandInput commandInput) => - commandInput.Directives.Contains("debug", StringComparer.OrdinalIgnoreCase); - - /// - /// Gets whether preview directive was specified in the input. - /// - public static bool IsPreviewDirectiveSpecified(this CommandInput commandInput) => - commandInput.Directives.Contains("preview", StringComparer.OrdinalIgnoreCase); - - /// - /// Gets whether help option was specified in the input. - /// - public static bool IsHelpOptionSpecified(this CommandInput commandInput) - { - var firstOption = commandInput.Options.FirstOrDefault(); - return firstOption != null && CommandOptionSchema.HelpOption.MatchesAlias(firstOption.Alias); - } - - /// - /// Gets whether version option was specified in the input. - /// - public static bool IsVersionOptionSpecified(this CommandInput commandInput) - { - var firstOption = commandInput.Options.FirstOrDefault(); - return firstOption != null && CommandOptionSchema.VersionOption.MatchesAlias(firstOption.Alias); - } - - /// - /// Gets whether this command is the default command, i.e. without a name. - /// - public static bool IsDefault(this CommandSchema commandSchema) => string.IsNullOrWhiteSpace(commandSchema.Name); - } -} \ No newline at end of file diff --git a/CliFx/Models/HelpTextSource.cs b/CliFx/Models/HelpTextSource.cs deleted file mode 100644 index 556f522..0000000 --- a/CliFx/Models/HelpTextSource.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Collections.Generic; - -namespace CliFx.Models -{ - /// - /// Source information used to generate help text. - /// - public class HelpTextSource - { - /// - /// Application metadata. - /// - public ApplicationMetadata ApplicationMetadata { get; } - - /// - /// Schemas of commands available in the application. - /// - public IReadOnlyList AvailableCommandSchemas { get; } - - /// - /// Schema of the command for which help text is to be generated. - /// - public CommandSchema TargetCommandSchema { get; } - - /// - /// Initializes an instance of . - /// - public HelpTextSource(ApplicationMetadata applicationMetadata, - IReadOnlyList availableCommandSchemas, - CommandSchema targetCommandSchema) - { - ApplicationMetadata = applicationMetadata; - AvailableCommandSchemas = availableCommandSchemas; - TargetCommandSchema = targetCommandSchema; - } - } -} \ No newline at end of file diff --git a/CliFx/Services/CommandArgumentSchemasValidator.cs b/CliFx/Services/CommandArgumentSchemasValidator.cs deleted file mode 100644 index b9c7b03..0000000 --- a/CliFx/Services/CommandArgumentSchemasValidator.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using CliFx.Exceptions; -using CliFx.Internal; -using CliFx.Models; - -namespace CliFx.Services -{ - /// - public class CommandArgumentSchemasValidator : ICommandArgumentSchemasValidator - { - private bool IsEnumerableArgument(CommandArgumentSchema schema) - { - return schema.Property.PropertyType != typeof(string) && schema.Property.PropertyType.GetEnumerableUnderlyingType() != null; - } - - /// - public IEnumerable ValidateArgumentSchemas(IReadOnlyCollection commandArgumentSchemas) - { - if (commandArgumentSchemas.Count == 0) - { - // No validation needed - yield break; - } - - // Make sure there are no arguments with the same name - var duplicateNameGroups = commandArgumentSchemas - .Where(x => !string.IsNullOrWhiteSpace(x.Name)) - .GroupBy(x => x.Name) - .Where(x => x.Count() > 1); - foreach (var schema in duplicateNameGroups) - { - yield return new ValidationError($"Multiple arguments with same name: \"{schema.Key}\"."); - } - - // Make sure that the order of all properties are distinct - var duplicateOrderGroups = commandArgumentSchemas - .GroupBy(x => x.Order) - .Where(x => x.Count() > 1); - foreach (var schema in duplicateOrderGroups) - { - yield return new ValidationError($"Multiple arguments with the same order: \"{schema.Key}\"."); - } - - var enumerableArguments = commandArgumentSchemas - .Where(IsEnumerableArgument) - .ToList(); - - // Verify that no more than one enumerable argument exists - if (enumerableArguments.Count > 1) - { - yield return new ValidationError($"Multiple sequence arguments found; only one is supported."); - } - - // If an enumerable argument exists, ensure that it has the highest order - if (enumerableArguments.Count == 1) - { - if (enumerableArguments.Single().Order != commandArgumentSchemas.Max(x => x.Order)) - { - yield return new ValidationError($"A sequence argument was defined with a lower order than another argument; the sequence argument must have the highest order (appear last)."); - } - } - - // Verify that all required arguments appear before optional arguments - if (commandArgumentSchemas.Any(x => x.IsRequired) && commandArgumentSchemas.Any(x => !x.IsRequired) && - commandArgumentSchemas.Where(x => x.IsRequired).Max(x => x.Order) > commandArgumentSchemas.Where(x => !x.IsRequired).Min(x => x.Order)) - { - yield return new ValidationError("One or more required arguments appear after optional arguments. Required arguments must appear before (i.e. have lower order than) optional arguments."); - } - } - } - - /// - /// Represents a failed validation. - /// - public class ValidationError - { - /// - /// Creates an instance of with a message. - /// - public ValidationError(string message) - { - Message = message; - } - - /// - /// The error message for the failed validation. - /// - public string Message { get; } - } -} \ No newline at end of file diff --git a/CliFx/Services/CommandFactory.cs b/CliFx/Services/CommandFactory.cs deleted file mode 100644 index 9ddb184..0000000 --- a/CliFx/Services/CommandFactory.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using CliFx.Models; - -namespace CliFx.Services -{ - /// - /// Default implementation of . - /// - public class CommandFactory : ICommandFactory - { - /// - public ICommand CreateCommand(CommandSchema commandSchema) => (ICommand) Activator.CreateInstance(commandSchema.Type); - } -} \ No newline at end of file diff --git a/CliFx/Services/CommandInitializer.cs b/CliFx/Services/CommandInitializer.cs deleted file mode 100644 index 47d6c23..0000000 --- a/CliFx/Services/CommandInitializer.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using CliFx.Exceptions; -using CliFx.Internal; -using CliFx.Models; - -namespace CliFx.Services -{ - /// - /// Default implementation of . - /// - public class CommandInitializer : ICommandInitializer - { - private readonly ICommandInputConverter _commandInputConverter; - private readonly IEnvironmentVariablesParser _environmentVariablesParser; - - /// - /// Initializes an instance of . - /// - public CommandInitializer(ICommandInputConverter commandInputConverter, IEnvironmentVariablesParser environmentVariablesParser) - { - _commandInputConverter = commandInputConverter; - _environmentVariablesParser = environmentVariablesParser; - } - - /// - /// Initializes an instance of . - /// - public CommandInitializer(IEnvironmentVariablesParser environmentVariablesParser) - : this(new CommandInputConverter(), environmentVariablesParser) - { - } - - /// - /// Initializes an instance of . - /// - public CommandInitializer() - : this(new CommandInputConverter(), new EnvironmentVariablesParser()) - { - } - - private void InitializeCommandOptions(ICommand command, CommandCandidate commandCandidate) - { - if (commandCandidate.Schema is null) - { - throw new ArgumentException("Cannot initialize command without a schema."); - } - - // Keep track of unset required options to report an error at a later stage - var unsetRequiredOptions = commandCandidate.Schema.Options.Where(o => o.IsRequired).ToList(); - - //Set command options - foreach (var optionSchema in commandCandidate.Schema.Options) - { - // Ignore special options that are not backed by a property - if (optionSchema.Property == null) - continue; - - //Find matching option input - var optionInput = commandCandidate.CommandInput.Options.FindByOptionSchema(optionSchema); - - //If no option input is available fall back to environment variable values - if (optionInput == null && !string.IsNullOrWhiteSpace(optionSchema.EnvironmentVariableName)) - { - var fallbackEnvironmentVariableExists = commandCandidate.CommandInput.EnvironmentVariables.ContainsKey(optionSchema.EnvironmentVariableName!); - - //If no environment variable is found or there is no valid value for this option skip it - if (!fallbackEnvironmentVariableExists || string.IsNullOrWhiteSpace(commandCandidate.CommandInput.EnvironmentVariables[optionSchema.EnvironmentVariableName!])) - continue; - - optionInput = _environmentVariablesParser.GetCommandOptionInputFromEnvironmentVariable(commandCandidate.CommandInput.EnvironmentVariables[optionSchema.EnvironmentVariableName!], optionSchema); - } - - //No fallback available and no option input was specified, skip option - if (optionInput == null) - continue; - - var convertedValue = _commandInputConverter.ConvertOptionInput(optionInput, optionSchema.Property.PropertyType); - - // Set value of the underlying property - optionSchema.Property.SetValue(command, convertedValue); - - // Mark this required option as set - if (optionSchema.IsRequired) - unsetRequiredOptions.Remove(optionSchema); - } - - // Throw if any of the required options were not set - if (unsetRequiredOptions.Any()) - { - var unsetRequiredOptionNames = unsetRequiredOptions.Select(o => o.GetAliases().FirstOrDefault()).JoinToString(", "); - throw new CliFxException($"Some of the required options were not provided: {unsetRequiredOptionNames}."); - } - } - - private void InitializeCommandArguments(ICommand command, CommandCandidate commandCandidate) - { - if (commandCandidate.Schema is null) - { - throw new ArgumentException("Cannot initialize command without a schema."); - } - - // Keep track of unset required options to report an error at a later stage - var unsetRequiredArguments = commandCandidate.Schema.Arguments - .Where(o => o.IsRequired) - .ToList(); - var orderedArgumentSchemas = commandCandidate.Schema.Arguments.Ordered(); - var argumentIndex = 0; - - foreach (var argumentSchema in orderedArgumentSchemas) - { - if (argumentIndex >= commandCandidate.PositionalArgumentsInput.Count) - { - // No more positional arguments left - remaining argument properties stay unset - break; - } - - var convertedValue = _commandInputConverter.ConvertArgumentInput(commandCandidate.PositionalArgumentsInput, ref argumentIndex, argumentSchema.Property.PropertyType); - - // Set value of underlying property - argumentSchema.Property.SetValue(command, convertedValue); - - // Mark this required argument as set - if (argumentSchema.IsRequired) - unsetRequiredArguments.Remove(argumentSchema); - } - - // Throw if there are remaining input arguments - if (argumentIndex < commandCandidate.PositionalArgumentsInput.Count) - { - throw new CliFxException($"Could not map the following arguments to command name or positional arguments: {commandCandidate.PositionalArgumentsInput.Skip(argumentIndex).JoinToString(", ")}"); - } - - // Throw if any of the required arguments were not set - if (unsetRequiredArguments.Any()) - { - throw new CliFxException($"One or more required arguments were not set: {unsetRequiredArguments.JoinToString(", ")}."); - } - } - - /// - public void InitializeCommand(ICommand command, CommandCandidate commandCandidate) - { - InitializeCommandOptions(command, commandCandidate); - InitializeCommandArguments(command, commandCandidate); - } - } -} \ No newline at end of file diff --git a/CliFx/Services/CommandInputConverter.cs b/CliFx/Services/CommandInputConverter.cs deleted file mode 100644 index d423e78..0000000 --- a/CliFx/Services/CommandInputConverter.cs +++ /dev/null @@ -1,232 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Reflection; -using CliFx.Exceptions; -using CliFx.Internal; -using CliFx.Models; - -namespace CliFx.Services -{ - /// - /// Default implementation of . - /// - public partial class CommandInputConverter : ICommandInputConverter - { - private readonly IFormatProvider _formatProvider; - - /// - /// Initializes an instance of . - /// - public CommandInputConverter(IFormatProvider formatProvider) - { - _formatProvider = formatProvider; - } - - /// - /// Initializes an instance of . - /// - public CommandInputConverter() - : this(CultureInfo.InvariantCulture) - { - } - - private object? ConvertEnumerableValue(IReadOnlyList values, Type enumerableUnderlyingType, Type targetType) - { - // Convert values to the underlying enumerable type and cast it to dynamic array - var convertedValues = values - .Select(v => ConvertValue(v, enumerableUnderlyingType)) - .ToNonGenericArray(enumerableUnderlyingType); - - // Get the type of produced array - var convertedValuesType = convertedValues.GetType(); - - // Try to assign the array (works for T[], IReadOnlyList, IEnumerable, etc) - if (targetType.IsAssignableFrom(convertedValuesType)) - return convertedValues; - - // Try to inject the array into the constructor (works for HashSet, List, etc) - var arrayConstructor = targetType.GetConstructor(new[] { convertedValuesType }); - if (arrayConstructor != null) - return arrayConstructor.Invoke(new object[] { convertedValues }); - - // Throw if we can't find a way to convert the values - throw new CliFxException( - $"Can't convert a sequence of values [{values.JoinToString(", ")}] " + - $"to type [{targetType}]."); - } - - /// - /// Converts a single string value to specified target type. - /// - protected virtual object? ConvertValue(string value, Type targetType) - { - try - { - // String or object - if (targetType == typeof(string) || targetType == typeof(object)) - return value; - - // Bool - if (targetType == typeof(bool)) - return string.IsNullOrWhiteSpace(value) || bool.Parse(value); - - // Char - if (targetType == typeof(char)) - return value.Single(); - - // Sbyte - if (targetType == typeof(sbyte)) - return sbyte.Parse(value, _formatProvider); - - // Byte - if (targetType == typeof(byte)) - return byte.Parse(value, _formatProvider); - - // Short - if (targetType == typeof(short)) - return short.Parse(value, _formatProvider); - - // Ushort - if (targetType == typeof(ushort)) - return ushort.Parse(value, _formatProvider); - - // Int - if (targetType == typeof(int)) - return int.Parse(value, _formatProvider); - - // Uint - if (targetType == typeof(uint)) - return uint.Parse(value, _formatProvider); - - // Long - if (targetType == typeof(long)) - return long.Parse(value, _formatProvider); - - // Ulong - if (targetType == typeof(ulong)) - return ulong.Parse(value, _formatProvider); - - // Float - if (targetType == typeof(float)) - return float.Parse(value, _formatProvider); - - // Double - if (targetType == typeof(double)) - return double.Parse(value, _formatProvider); - - // Decimal - if (targetType == typeof(decimal)) - return decimal.Parse(value, _formatProvider); - - // 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 = targetType.GetNullableUnderlyingType(); - if (nullableUnderlyingType != null) - return !string.IsNullOrWhiteSpace(value) ? ConvertValue(value, nullableUnderlyingType) : null; - - // Has a constructor that accepts a single string - var stringConstructor = GetStringConstructor(targetType); - if (stringConstructor != null) - return stringConstructor.Invoke(new object[] { value }); - - // Has a static parse method that accepts a single string and a format provider - var parseMethodWithFormatProvider = GetStaticParseMethodWithFormatProvider(targetType); - if (parseMethodWithFormatProvider != null) - return parseMethodWithFormatProvider.Invoke(null, new object[] { value, _formatProvider }); - - // Has a static parse method that accepts a single string - var parseMethod = GetStaticParseMethod(targetType); - if (parseMethod != null) - return parseMethod.Invoke(null, new object[] { value }); - } - catch (Exception ex) - { - // Wrap and rethrow exceptions that occur when trying to convert the value - throw new CliFxException( - $"Can't convert value [{value}] to type [{targetType}]. " + - "Provided value probably doesn't match the expected format. " + - $"Underlying exception message: {ex.Message}", ex); - } - - // Throw if we can't find a way to convert the value - throw new CliFxException( - $"Can't find a way to convert user input to type [{targetType}]. " + - "This type is not among the list of types supported by this library."); - } - - /// - public virtual object? ConvertArgumentInput(IReadOnlyList arguments, ref int currentIndex, Type targetType) - { - var enumerableUnderlyingType = targetType != typeof(string) ? targetType.GetEnumerableUnderlyingType() : null; - if (enumerableUnderlyingType is null) - { - var argument = arguments[currentIndex]; - currentIndex += 1; - return ConvertValue(argument, targetType); - } - - // - var argumentSequence = arguments.Skip(currentIndex).ToList(); - currentIndex = arguments.Count; - - return ConvertEnumerableValue(argumentSequence, enumerableUnderlyingType, targetType); - } - - /// - public virtual object? ConvertOptionInput(CommandOptionInput optionInput, Type targetType) - { - // Get the underlying type of IEnumerable if it's implemented by the target type. - // Ignore string type because it's IEnumerable but we don't treat it as such. - var enumerableUnderlyingType = targetType != typeof(string) ? targetType.GetEnumerableUnderlyingType() : null; - - // Convert to a non-enumerable type - if (enumerableUnderlyingType == null) - { - // Throw if provided with more than 1 value - if (optionInput.Values.Count > 1) - { - throw new CliFxException( - $"Can't convert a sequence of values [{optionInput.Values.JoinToString(", ")}] " + - $"to non-enumerable type [{targetType}]."); - } - - // Retrieve a single value and convert - var value = optionInput.Values.SingleOrDefault(); - return ConvertValue(value, targetType); - } - // Convert to an enumerable type - else - { - return ConvertEnumerableValue(optionInput.Values, enumerableUnderlyingType, targetType); - } - } - } - - public partial class CommandInputConverter - { - private static ConstructorInfo? GetStringConstructor(Type type) => type.GetConstructor(new[] {typeof(string)}); - - private static MethodInfo? GetStaticParseMethod(Type type) => - type.GetMethod("Parse", BindingFlags.Public | BindingFlags.Static, null, new[] {typeof(string)}, null); - - private static MethodInfo? GetStaticParseMethodWithFormatProvider(Type type) => - type.GetMethod("Parse", BindingFlags.Public | BindingFlags.Static, null, new[] {typeof(string), typeof(IFormatProvider)}, null); - } -} \ No newline at end of file diff --git a/CliFx/Services/CommandInputParser.cs b/CliFx/Services/CommandInputParser.cs deleted file mode 100644 index 541b89f..0000000 --- a/CliFx/Services/CommandInputParser.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using CliFx.Internal; -using CliFx.Models; - -namespace CliFx.Services -{ - /// - /// Default implementation of . - /// - public class CommandInputParser : ICommandInputParser - { - private readonly IEnvironmentVariablesProvider _environmentVariablesProvider; - - /// - /// Initializes an instance of - /// - public CommandInputParser(IEnvironmentVariablesProvider environmentVariablesProvider) - { - _environmentVariablesProvider = environmentVariablesProvider; - } - - /// - /// Initializes an instance of - /// - public CommandInputParser() - : this(new EnvironmentVariablesProvider()) - { - } - - /// - public CommandInput ParseCommandInput(IReadOnlyList commandLineArguments) - { - var arguments = new List(); - var directives = new List(); - var optionsDic = new Dictionary>(); - - // Option aliases and values are parsed in pairs so we need to keep track of last alias - var lastOptionAlias = ""; - - foreach (var commandLineArgument in commandLineArguments) - { - // Encountered option name - if (commandLineArgument.StartsWith("--", StringComparison.OrdinalIgnoreCase)) - { - // Extract option alias - lastOptionAlias = commandLineArgument.Substring(2); - - if (!optionsDic.ContainsKey(lastOptionAlias)) - optionsDic[lastOptionAlias] = new List(); - } - - // Encountered short option name or multiple short option names - else if (commandLineArgument.StartsWith("-", StringComparison.OrdinalIgnoreCase)) - { - // Handle stacked options - foreach (var c in commandLineArgument.Substring(1)) - { - // Extract option alias - lastOptionAlias = c.AsString(); - - if (!optionsDic.ContainsKey(lastOptionAlias)) - optionsDic[lastOptionAlias] = new List(); - } - } - - // Encountered directive or (part of) command name - else if (string.IsNullOrWhiteSpace(lastOptionAlias)) - { - if (commandLineArgument.StartsWith("[", StringComparison.OrdinalIgnoreCase) && - commandLineArgument.EndsWith("]", StringComparison.OrdinalIgnoreCase)) - { - // Extract directive - var directive = commandLineArgument.Substring(1, commandLineArgument.Length - 2); - - directives.Add(directive); - } - else - { - arguments.Add(commandLineArgument); - } - } - - // Encountered option value - else if (!string.IsNullOrWhiteSpace(lastOptionAlias)) - { - optionsDic[lastOptionAlias].Add(commandLineArgument); - } - } - - var options = optionsDic.Select(p => new CommandOptionInput(p.Key, p.Value)).ToArray(); - - var environmentVariables = _environmentVariablesProvider.GetEnvironmentVariables(); - - return new CommandInput(arguments, directives, options, environmentVariables); - } - } -} \ No newline at end of file diff --git a/CliFx/Services/CommandSchemaResolver.cs b/CliFx/Services/CommandSchemaResolver.cs deleted file mode 100644 index cbcbe8f..0000000 --- a/CliFx/Services/CommandSchemaResolver.cs +++ /dev/null @@ -1,193 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Text.RegularExpressions; -using CliFx.Attributes; -using CliFx.Exceptions; -using CliFx.Internal; -using CliFx.Models; - -namespace CliFx.Services -{ - /// - /// Default implementation of . - /// - public class CommandSchemaResolver : ICommandSchemaResolver - { - private readonly ICommandArgumentSchemasValidator _commandArgumentSchemasValidator; - - /// - /// Creates an instance of . - /// - public CommandSchemaResolver(ICommandArgumentSchemasValidator commandArgumentSchemasValidator) - { - _commandArgumentSchemasValidator = commandArgumentSchemasValidator; - } - - private IReadOnlyList GetCommandOptionSchemas(Type commandType) - { - var result = new List(); - - foreach (var property in commandType.GetProperties()) - { - var attribute = property.GetCustomAttribute(); - - // If an attribute is not set, then it's not an option so we just skip it - if (attribute == null) - continue; - - // Build option schema - var optionSchema = new CommandOptionSchema(property, - attribute.Name, - attribute.ShortName, - attribute.IsRequired, - attribute.Description, - attribute.EnvironmentVariableName); - - // Make sure there are no other options with the same name - var existingOptionWithSameName = result - .Where(o => !string.IsNullOrWhiteSpace(o.Name)) - .FirstOrDefault(o => string.Equals(o.Name, optionSchema.Name, StringComparison.OrdinalIgnoreCase)); - - if (existingOptionWithSameName != null) - { - throw new CliFxException( - $"Command type [{commandType}] has two options that have the same name ({optionSchema.Name}): " + - $"[{existingOptionWithSameName.Property}] and [{optionSchema.Property}]. " + - "All options in a command need to have unique names (case-insensitive)."); - } - - // Make sure there are no other options with the same short name - var existingOptionWithSameShortName = result - .Where(o => o.ShortName != null) - .FirstOrDefault(o => o.ShortName == optionSchema.ShortName); - - if (existingOptionWithSameShortName != null) - { - throw new CliFxException( - $"Command type [{commandType}] has two options that have the same short name ({optionSchema.ShortName}): " + - $"[{existingOptionWithSameShortName.Property}] and [{optionSchema.Property}]. " + - "All options in a command need to have unique short names (case-sensitive)."); - } - - // Add schema to list - result.Add(optionSchema); - } - - return result; - } - - private IReadOnlyList GetCommandArgumentSchemas(Type commandType) - { - var argumentSchemas = commandType.GetProperties() - .Select(p => new { Property = p, Attribute = p.GetCustomAttribute() }) - .Where(a => a.Attribute != null) - .Select(a => new CommandArgumentSchema(a.Property, a.Attribute.Name, a.Attribute.IsRequired, a.Attribute.Description, a.Attribute.Order)) - .ToList(); - - var validationErrors = _commandArgumentSchemasValidator.ValidateArgumentSchemas(argumentSchemas).ToList(); - if (validationErrors.Any()) - { - throw new CliFxException($"Command type [{commandType}] has invalid argument configuration:\n" + - $"{string.Join("\n", validationErrors.Select(v => v.Message))}"); - } - - return argumentSchemas; - } - - /// - public IReadOnlyList GetCommandSchemas(IReadOnlyList commandTypes) - { - // Make sure there's at least one command defined - if (!commandTypes.Any()) - { - throw new CliFxException( - "There are no commands defined. " + - "An application needs to have at least one command to work."); - } - - var result = new List(); - - foreach (var commandType in commandTypes) - { - // Make sure command type implements ICommand. - if (!commandType.Implements(typeof(ICommand))) - { - throw new CliFxException( - $"Command type [{commandType}] needs to implement [{typeof(ICommand)}]." - + Environment.NewLine + Environment.NewLine + - $"public class {commandType.Name} : ICommand" + Environment.NewLine + - "// ^-- implement interface"); - } - - // Get attribute - var attribute = commandType.GetCustomAttribute(); - - // Make sure attribute is set - if (attribute == null) - { - throw new CliFxException( - $"Command type [{commandType}] needs to be annotated with [{typeof(CommandAttribute)}]." - + Environment.NewLine + Environment.NewLine + - "[Command] // <-- add attribute" + Environment.NewLine + - $"public class {commandType.Name} : ICommand"); - } - - // Get option schemas - var optionSchemas = GetCommandOptionSchemas(commandType); - - // Get argument schemas - var argumentSchemas = GetCommandArgumentSchemas(commandType); - - // Build command schema - var commandSchema = new CommandSchema(commandType, - attribute.Name, - attribute.Description, - argumentSchemas, optionSchemas); - - // Make sure there are no other commands with the same name - var existingCommandWithSameName = result - .FirstOrDefault(c => string.Equals(c.Name, commandSchema.Name, StringComparison.OrdinalIgnoreCase)); - - if (existingCommandWithSameName != null) - { - throw new CliFxException( - $"Command type [{existingCommandWithSameName.Type}] has the same name as another command type [{commandType}]. " + - "All commands need to have unique names (case-insensitive)."); - } - - // Add schema to list - result.Add(commandSchema); - } - - return result; - } - - /// - public CommandCandidate? GetTargetCommandSchema(IReadOnlyList availableCommandSchemas, CommandInput commandInput) - { - // If no arguments are given, use the default command - CommandSchema targetSchema; - if (!commandInput.Arguments.Any()) - { - targetSchema = availableCommandSchemas.FirstOrDefault(c => c.IsDefault()); - return targetSchema is null ? null : new CommandCandidate(targetSchema, new string[0], commandInput); - } - - // Arguments can be part of the a command name as long as they are single words, i.e. no whitespace characters - var longestPossibleCommandName = string.Join(" ", commandInput.Arguments.TakeWhile(arg => !Regex.IsMatch(arg, @"\s"))); - - // Find the longest matching schema - var orderedSchemas = availableCommandSchemas.OrderByDescending(x => x.Name?.Length); - targetSchema = orderedSchemas.FirstOrDefault(c => longestPossibleCommandName.StartsWith(c.Name ?? string.Empty, StringComparison.Ordinal)) - ?? availableCommandSchemas.FirstOrDefault(c => c.IsDefault()); - - // Get remaining positional arguments - var commandArgumentsCount = targetSchema?.Name?.Split(new []{ ' ' }, StringSplitOptions.RemoveEmptyEntries).Length ?? 0; - var positionalArguments = commandInput.Arguments.Skip(commandArgumentsCount).ToList(); - - return targetSchema is null ? null : new CommandCandidate(targetSchema, positionalArguments, commandInput); - } - } -} \ No newline at end of file diff --git a/CliFx/Services/DelegateCommandFactory.cs b/CliFx/Services/DelegateCommandFactory.cs deleted file mode 100644 index 5dcdb92..0000000 --- a/CliFx/Services/DelegateCommandFactory.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using CliFx.Models; - -namespace CliFx.Services -{ - /// - /// Implementation of that uses a factory method to create commands. - /// - public class DelegateCommandFactory : ICommandFactory - { - private readonly Func _factoryMethod; - - /// - /// Initializes an instance of . - /// - public DelegateCommandFactory(Func factoryMethod) - { - _factoryMethod = factoryMethod; - } - - /// - public ICommand CreateCommand(CommandSchema commandSchema) => _factoryMethod(commandSchema); - } -} \ No newline at end of file diff --git a/CliFx/Services/EnvironmentVariablesParser.cs b/CliFx/Services/EnvironmentVariablesParser.cs deleted file mode 100644 index c678d7a..0000000 --- a/CliFx/Services/EnvironmentVariablesParser.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.IO; -using System.Linq; -using CliFx.Internal; -using CliFx.Models; - -namespace CliFx.Services -{ - /// - public class EnvironmentVariablesParser : IEnvironmentVariablesParser - { - /// - public CommandOptionInput GetCommandOptionInputFromEnvironmentVariable(string environmentVariableValue, CommandOptionSchema targetOptionSchema) - { - //If the option is not a collection do not split environment variable values - var optionIsCollection = targetOptionSchema.Property != null && targetOptionSchema.Property.PropertyType.IsCollection(); - - if (!optionIsCollection) return new CommandOptionInput(targetOptionSchema.EnvironmentVariableName, environmentVariableValue); - - //If the option is a collection split the values using System separator, empty values are discarded - var environmentVariableValues = environmentVariableValue.Split(Path.PathSeparator) - .Where(v => !string.IsNullOrWhiteSpace(v)) - .ToList(); - - return new CommandOptionInput(targetOptionSchema.EnvironmentVariableName, environmentVariableValues); - } - } -} diff --git a/CliFx/Services/EnvironmentVariablesProvider.cs b/CliFx/Services/EnvironmentVariablesProvider.cs deleted file mode 100644 index 95481fc..0000000 --- a/CliFx/Services/EnvironmentVariablesProvider.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Security; - -namespace CliFx.Services -{ - /// - public class EnvironmentVariablesProvider : IEnvironmentVariablesProvider - { - /// - public IReadOnlyDictionary GetEnvironmentVariables() - { - try - { - var environmentVariables = Environment.GetEnvironmentVariables(); - - //Constructing the dictionary manually allows to specify a key comparer that ignores case - //This allows to ignore casing when looking for a fallback environment variable of an option - var environmentVariablesAsDictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); - - //Type DictionaryEntry must be explicitly used otherwise it will enumerate as a collection of objects - foreach (DictionaryEntry environmentVariable in environmentVariables) - { - environmentVariablesAsDictionary.Add(environmentVariable.Key.ToString(), environmentVariable.Value.ToString()); - } - - return environmentVariablesAsDictionary; - } - catch (SecurityException) - { - return new Dictionary(); - } - } - } -} diff --git a/CliFx/Services/Extensions.cs b/CliFx/Services/Extensions.cs deleted file mode 100644 index 7c39bd7..0000000 --- a/CliFx/Services/Extensions.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System; - -namespace CliFx.Services -{ - /// - /// Extensions for - /// - public static class Extensions - { - /// - /// Sets console foreground color, executes specified action, and sets the color back to the original value. - /// - public static void WithForegroundColor(this IConsole console, ConsoleColor foregroundColor, Action action) - { - var lastColor = console.ForegroundColor; - console.ForegroundColor = foregroundColor; - - action(); - - console.ForegroundColor = lastColor; - } - - /// - /// Sets console background color, executes specified action, and sets the color back to the original value. - /// - public static void WithBackgroundColor(this IConsole console, ConsoleColor backgroundColor, Action action) - { - var lastColor = console.BackgroundColor; - console.BackgroundColor = backgroundColor; - - action(); - - console.BackgroundColor = lastColor; - } - - /// - /// Sets console foreground and background colors, executes specified action, and sets the colors back to the original values. - /// - public static void WithColors(this IConsole console, ConsoleColor foregroundColor, ConsoleColor backgroundColor, Action action) => - console.WithForegroundColor(foregroundColor, () => console.WithBackgroundColor(backgroundColor, action)); - } -} \ No newline at end of file diff --git a/CliFx/Services/HelpTextRenderer.cs b/CliFx/Services/HelpTextRenderer.cs deleted file mode 100644 index bc588bc..0000000 --- a/CliFx/Services/HelpTextRenderer.cs +++ /dev/null @@ -1,368 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using CliFx.Internal; -using CliFx.Models; - -namespace CliFx.Services -{ - /// - /// Default implementation of . - /// - public partial class HelpTextRenderer : IHelpTextRenderer - { - /// - public void RenderHelpText(IConsole console, HelpTextSource source) - { - // Track position - var column = 0; - var row = 0; - - // Get built-in option schemas (help and version) - var builtInOptionSchemas = new List { CommandOptionSchema.HelpOption }; - if (source.TargetCommandSchema.IsDefault()) - builtInOptionSchemas.Add(CommandOptionSchema.VersionOption); - - // Get child command schemas - var childCommandSchemas = source.AvailableCommandSchemas - .Where(c => source.AvailableCommandSchemas.FindParent(c.Name) == source.TargetCommandSchema) - .ToArray(); - - // Define helper functions - - bool IsEmpty() => column == 0 && row == 0; - - void Render(string text) - { - console.Output.Write(text); - - column += text.Length; - } - - void RenderNewLine() - { - console.Output.WriteLine(); - - column = 0; - row++; - } - - void RenderMargin(int lines = 1) - { - if (!IsEmpty()) - { - for (var i = 0; i < lines; i++) - RenderNewLine(); - } - } - - void RenderIndent(int spaces = 2) - { - Render(' '.Repeat(spaces)); - } - - void RenderColumnIndent(int spaces = 20, int margin = 2) - { - if (column + margin >= spaces) - { - RenderNewLine(); - RenderIndent(spaces); - } - else - { - RenderIndent(spaces - column); - } - } - - void RenderWithColor(string text, ConsoleColor foregroundColor) - { - console.WithForegroundColor(foregroundColor, () => Render(text)); - } - - void RenderWithColors(string text, ConsoleColor foregroundColor, ConsoleColor backgroundColor) - { - console.WithColors(foregroundColor, backgroundColor, () => Render(text)); - } - - void RenderHeader(string text) - { - RenderWithColors(text, ConsoleColor.Black, ConsoleColor.DarkGray); - RenderNewLine(); - } - - void RenderApplicationInfo() - { - if (!source.TargetCommandSchema.IsDefault()) - return; - - // Title and version - RenderWithColor(source.ApplicationMetadata.Title, ConsoleColor.Yellow); - Render(" "); - RenderWithColor(source.ApplicationMetadata.VersionText, ConsoleColor.Yellow); - RenderNewLine(); - - // Description - if (!string.IsNullOrWhiteSpace(source.ApplicationMetadata.Description)) - { - Render(source.ApplicationMetadata.Description!); - RenderNewLine(); - } - } - - void RenderDescription() - { - if (string.IsNullOrWhiteSpace(source.TargetCommandSchema.Description)) - return; - - // Margin - RenderMargin(); - - // Header - RenderHeader("Description"); - - // Description - RenderIndent(); - Render(source.TargetCommandSchema.Description!); - RenderNewLine(); - } - - void RenderUsage() - { - // Margin - RenderMargin(); - - // Header - RenderHeader("Usage"); - - // Exe name - RenderIndent(); - Render(source.ApplicationMetadata.ExecutableName); - - // Command name - if (!string.IsNullOrWhiteSpace(source.TargetCommandSchema.Name)) - { - Render(" "); - RenderWithColor(source.TargetCommandSchema.Name!, ConsoleColor.Cyan); - } - - // Child command - if (childCommandSchemas.Any()) - { - Render(" "); - RenderWithColor("[command]", ConsoleColor.Cyan); - } - - // Arguments - foreach (var argumentSchema in source.TargetCommandSchema.Arguments) - { - Render(" "); - if (!argumentSchema.IsRequired) - Render("["); - - Render($"<{argumentSchema.DisplayName}>"); - - if (!argumentSchema.IsRequired) - Render("]"); - } - - // Required options - var requiredOptionSchemas = source.TargetCommandSchema.Options - .Where(o => o.IsRequired) - .ToArray(); - - foreach (var requiredOption in requiredOptionSchemas) - { - Render($" --{requiredOption.Name} "); - } - - // Options placeholder - var notRequiredOrDefaultOptionCount = source.TargetCommandSchema.Options - .Count( - o => !o.IsRequired && - o.Name != CommandOptionSchema.HelpOption.Name && - o.Name != CommandOptionSchema.VersionOption.Name); - - if (notRequiredOrDefaultOptionCount > 0) - { - Render(" "); - RenderWithColor("[options]", ConsoleColor.White); - } - - RenderNewLine(); - } - - void RenderArguments() - { - // Do not render anything if the command has no arguments - if (source.TargetCommandSchema.Arguments.Count == 0) - return; - - // Margin - RenderMargin(); - - // Header - RenderHeader("Arguments"); - - // Order arguments - var orderedArgumentSchemas = source.TargetCommandSchema.Arguments - .Ordered() - .ToArray(); - - // Arguments - foreach (var argumentSchema in orderedArgumentSchemas) - { - // Is required - if (argumentSchema.IsRequired) - { - RenderWithColor("* ", ConsoleColor.Red); - } - else - { - RenderIndent(); - } - - // Short name - RenderWithColor($"{argumentSchema.DisplayName}", ConsoleColor.White); - - // Description - if (!string.IsNullOrWhiteSpace(argumentSchema.Description)) - { - RenderColumnIndent(); - Render(argumentSchema.Description!); - } - - RenderNewLine(); - } - } - - void RenderOptions() - { - // Margin - RenderMargin(); - - // Header - RenderHeader("Options"); - - // Order options and append built-in options - var allOptionSchemas = source.TargetCommandSchema.Options - .OrderByDescending(o => o.IsRequired) - .Concat(builtInOptionSchemas) - .ToArray(); - - // Options - foreach (var optionSchema in allOptionSchemas) - { - // Is required - if (optionSchema.IsRequired) - { - RenderWithColor("* ", ConsoleColor.Red); - } - else - { - RenderIndent(); - } - - // Short name - if (optionSchema.ShortName != null) - { - RenderWithColor($"-{optionSchema.ShortName}", ConsoleColor.White); - } - - // Delimiter - if (!string.IsNullOrWhiteSpace(optionSchema.Name) && optionSchema.ShortName != null) - { - Render("|"); - } - - // Name - if (!string.IsNullOrWhiteSpace(optionSchema.Name)) - { - RenderWithColor($"--{optionSchema.Name}", ConsoleColor.White); - } - - // Description - if (!string.IsNullOrWhiteSpace(optionSchema.Description)) - { - RenderColumnIndent(); - Render(optionSchema.Description!); - } - - RenderNewLine(); - } - } - - void RenderChildCommands() - { - if (!childCommandSchemas.Any()) - return; - - // Margin - RenderMargin(); - - // Header - RenderHeader("Commands"); - - // Child commands - foreach (var childCommandSchema in childCommandSchemas) - { - var relativeCommandName = GetRelativeCommandName(childCommandSchema, source.TargetCommandSchema)!; - - // Name - RenderIndent(); - RenderWithColor(relativeCommandName, ConsoleColor.Cyan); - - // Description - if (!string.IsNullOrWhiteSpace(childCommandSchema.Description)) - { - RenderColumnIndent(); - Render(childCommandSchema.Description!); - } - - RenderNewLine(); - } - - // Margin - RenderMargin(); - - // Child command help tip - Render("You can run `"); - Render(source.ApplicationMetadata.ExecutableName); - - if (!string.IsNullOrWhiteSpace(source.TargetCommandSchema.Name)) - { - Render(" "); - RenderWithColor(source.TargetCommandSchema.Name, ConsoleColor.Cyan); - } - - Render(" "); - RenderWithColor("[command]", ConsoleColor.Cyan); - - Render(" "); - RenderWithColor("--help", ConsoleColor.White); - - Render("` to show help on a specific command."); - - RenderNewLine(); - } - - // Reset color just in case - console.ResetColor(); - - // Render everything - RenderApplicationInfo(); - RenderDescription(); - RenderUsage(); - RenderArguments(); - RenderOptions(); - RenderChildCommands(); - } - } - - public partial class HelpTextRenderer - { - private static string? GetRelativeCommandName(CommandSchema commandSchema, CommandSchema parentCommandSchema) => - string.IsNullOrWhiteSpace(parentCommandSchema.Name) || string.IsNullOrWhiteSpace(commandSchema.Name) - ? commandSchema.Name - : commandSchema.Name!.Substring(parentCommandSchema.Name!.Length + 1); - } -} \ No newline at end of file diff --git a/CliFx/Services/ICommandArgumentSchemasValidator.cs b/CliFx/Services/ICommandArgumentSchemasValidator.cs deleted file mode 100644 index 2194c5a..0000000 --- a/CliFx/Services/ICommandArgumentSchemasValidator.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.Generic; -using CliFx.Models; - -namespace CliFx.Services -{ - /// - /// Validates command arguments. - /// - public interface ICommandArgumentSchemasValidator - { - /// - /// Validate the given command arguments. - /// - IEnumerable ValidateArgumentSchemas(IReadOnlyCollection commandArgumentSchemas); - } -} \ No newline at end of file diff --git a/CliFx/Services/ICommandFactory.cs b/CliFx/Services/ICommandFactory.cs deleted file mode 100644 index e6d1084..0000000 --- a/CliFx/Services/ICommandFactory.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CliFx.Models; - -namespace CliFx.Services -{ - /// - /// Initializes new instances of . - /// - public interface ICommandFactory - { - /// - /// Initializes an instance of with specified schema. - /// - ICommand CreateCommand(CommandSchema commandSchema); - } -} \ No newline at end of file diff --git a/CliFx/Services/ICommandInitializer.cs b/CliFx/Services/ICommandInitializer.cs deleted file mode 100644 index 1710f29..0000000 --- a/CliFx/Services/ICommandInitializer.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CliFx.Models; - -namespace CliFx.Services -{ - /// - /// Populates instances with input according to its schema. - /// - public interface ICommandInitializer - { - /// - /// Populates an instance of with specified input according to specified schema. - /// - void InitializeCommand(ICommand command, CommandCandidate commandCandidate); - } -} \ No newline at end of file diff --git a/CliFx/Services/ICommandInputConverter.cs b/CliFx/Services/ICommandInputConverter.cs deleted file mode 100644 index ad6d01b..0000000 --- a/CliFx/Services/ICommandInputConverter.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using System.Collections.Generic; -using CliFx.Models; - -namespace CliFx.Services -{ - /// - /// Converts input command options. - /// - public interface ICommandInputConverter - { - /// - /// Converts an option to specified target type. - /// - object? ConvertOptionInput(CommandOptionInput optionInput, Type targetType); - - /// - /// Converts an argument to specified target type, using up arguments from the given enumerator. - /// - object? ConvertArgumentInput(IReadOnlyList arguments, ref int currentIndex, Type targetType); - } -} \ No newline at end of file diff --git a/CliFx/Services/ICommandInputParser.cs b/CliFx/Services/ICommandInputParser.cs deleted file mode 100644 index 608ce7c..0000000 --- a/CliFx/Services/ICommandInputParser.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.Generic; -using CliFx.Models; - -namespace CliFx.Services -{ - /// - /// Parses command line arguments. - /// - public interface ICommandInputParser - { - /// - /// Parses specified command line arguments. - /// - CommandInput ParseCommandInput(IReadOnlyList commandLineArguments); - } -} \ No newline at end of file diff --git a/CliFx/Services/ICommandSchemaResolver.cs b/CliFx/Services/ICommandSchemaResolver.cs deleted file mode 100644 index 629850a..0000000 --- a/CliFx/Services/ICommandSchemaResolver.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using System.Collections.Generic; -using CliFx.Models; - -namespace CliFx.Services -{ - /// - /// Resolves command schemas. - /// - public interface ICommandSchemaResolver - { - /// - /// Resolves schemas of specified command types. - /// - IReadOnlyList GetCommandSchemas(IReadOnlyList commandTypes); - - /// - /// Get the target command schema. The target command is the most specific command that matches the unbound input arguments. - /// - CommandCandidate? GetTargetCommandSchema(IReadOnlyList availableCommandSchemas, CommandInput commandInput); - } -} \ No newline at end of file diff --git a/CliFx/Services/IEnvironmentVariablesParser.cs b/CliFx/Services/IEnvironmentVariablesParser.cs deleted file mode 100644 index cf13b06..0000000 --- a/CliFx/Services/IEnvironmentVariablesParser.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CliFx.Models; - -namespace CliFx.Services -{ - /// - /// Parses environment variable values - /// - public interface IEnvironmentVariablesParser - { - /// - /// Parse an environment variable value and converts it to a - /// - CommandOptionInput GetCommandOptionInputFromEnvironmentVariable(string environmentVariableValue, CommandOptionSchema targetOptionSchema); - } -} diff --git a/CliFx/Services/IEnvironmentVariablesProvider.cs b/CliFx/Services/IEnvironmentVariablesProvider.cs deleted file mode 100644 index 0eddd05..0000000 --- a/CliFx/Services/IEnvironmentVariablesProvider.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.Generic; - -namespace CliFx.Services -{ - /// - /// Provides environment variable values - /// - public interface IEnvironmentVariablesProvider - { - /// - /// Returns all the environment variables available. - /// - /// If the User is not allowed to read environment variables it will return an empty dictionary. - IReadOnlyDictionary GetEnvironmentVariables(); - } -} diff --git a/CliFx/Services/IHelpTextRenderer.cs b/CliFx/Services/IHelpTextRenderer.cs deleted file mode 100644 index d7a9e54..0000000 --- a/CliFx/Services/IHelpTextRenderer.cs +++ /dev/null @@ -1,15 +0,0 @@ -using CliFx.Models; - -namespace CliFx.Services -{ - /// - /// Renders help text to the console. - /// - public interface IHelpTextRenderer - { - /// - /// Renders help text using specified console and source information. - /// - void RenderHelpText(IConsole console, HelpTextSource source); - } -} \ No newline at end of file diff --git a/CliFx/Services/SystemConsole.cs b/CliFx/SystemConsole.cs similarity index 66% rename from CliFx/Services/SystemConsole.cs rename to CliFx/SystemConsole.cs index 865edad..6addf77 100644 --- a/CliFx/Services/SystemConsole.cs +++ b/CliFx/SystemConsole.cs @@ -2,15 +2,15 @@ using System.IO; using System.Threading; -namespace CliFx.Services +namespace CliFx { /// - /// Implementation of that wraps around . + /// Implementation of that wraps the default system console. /// public class SystemConsole : IConsole { private CancellationTokenSource? _cancellationTokenSource; - + /// public TextReader Input => Console.In; @@ -49,22 +49,22 @@ namespace CliFx.Services /// public CancellationToken GetCancellationToken() { - if (_cancellationTokenSource is null) + if (_cancellationTokenSource != null) + return _cancellationTokenSource.Token; + + var cts = new CancellationTokenSource(); + + Console.CancelKeyPress += (_, args) => { - _cancellationTokenSource = new CancellationTokenSource(); - - // Subscribe to CancelKeyPress event with cancellation token source - // Kills app on second cancellation (hard cancellation) - Console.CancelKeyPress += (_, args) => + // If cancellation hasn't been requested yet - cancel shutdown and fire the token + if (!cts.IsCancellationRequested) { - if (_cancellationTokenSource.IsCancellationRequested) - return; args.Cancel = true; - _cancellationTokenSource.Cancel(); - }; - } + cts.Cancel(); + } + }; - return _cancellationTokenSource.Token; + return (_cancellationTokenSource = cts).Token; } } } \ No newline at end of file diff --git a/CliFx/Utilities/Extensions.cs b/CliFx/Utilities/Extensions.cs index abd8c38..69e21d5 100644 --- a/CliFx/Utilities/Extensions.cs +++ b/CliFx/Utilities/Extensions.cs @@ -1,6 +1,4 @@ -using CliFx.Services; - -namespace CliFx.Utilities +namespace CliFx.Utilities { /// /// Extensions for . diff --git a/CliFx/Utilities/ProgressTicker.cs b/CliFx/Utilities/ProgressTicker.cs index d0e1651..9e6755c 100644 --- a/CliFx/Utilities/ProgressTicker.cs +++ b/CliFx/Utilities/ProgressTicker.cs @@ -1,5 +1,4 @@ using System; -using CliFx.Services; namespace CliFx.Utilities { @@ -34,12 +33,12 @@ namespace CliFx.Utilities /// /// Erases previous output and renders new progress to the console. - /// If console's stdout is redirected, this method returns without doing anything. + /// If stdout is redirected, this method returns without doing anything. /// public void Report(double progress) { // We don't do anything if stdout is redirected to avoid polluting output - //...when there's no active console window. + // when there's no active console window. if (!_console.IsOutputRedirected) { EraseLastOutput(); diff --git a/CliFx/Services/VirtualConsole.cs b/CliFx/VirtualConsole.cs similarity index 93% rename from CliFx/Services/VirtualConsole.cs rename to CliFx/VirtualConsole.cs index 05382fb..ba82919 100644 --- a/CliFx/Services/VirtualConsole.cs +++ b/CliFx/VirtualConsole.cs @@ -2,12 +2,12 @@ using System.IO; using System.Threading; -namespace CliFx.Services +namespace CliFx { /// /// Implementation of that routes data to specified streams. - /// Does not leak to in any way. - /// Provides an isolated instance of which is useful for testing purposes. + /// Does not leak to system console in any way. + /// Use this class as a substitute for system console when running tests. /// public class VirtualConsole : IConsole { @@ -57,7 +57,7 @@ namespace CliFx.Services /// /// Initializes an instance of . /// - public VirtualConsole(TextReader input, TextWriter output, TextWriter error, + public VirtualConsole(TextReader input, TextWriter output, TextWriter error, CancellationToken cancellationToken = default) : this(input, true, output, true, error, true, cancellationToken) {