113 Commits
0.0.1 ... 0.0.4

Author SHA1 Message Date
Alexey Golub
857257ca73 Update version 2019-08-25 23:19:10 +03:00
Alexey Golub
3587155c7e Update readme 2019-08-25 23:17:58 +03:00
Alexey Golub
ae05e0db96 Refactor 2019-08-25 22:08:34 +03:00
Alexey Golub
41c0493e66 Refactor tests again 2019-08-25 18:26:40 +03:00
Alexey Golub
43a304bb26 Refactor tests 2019-08-25 17:28:54 +03:00
Alexey Golub
cd3892bf83 Refactor CliApplication.RunAsync using chain of responsibility 2019-08-25 14:54:29 +03:00
Alexey Golub
3f7c02342d Add smoke tests for VirtualConsole 2019-08-25 11:30:06 +03:00
Alexey Golub
c65cdf465e Remove dummy tests 2019-08-24 23:25:41 +03:00
Alexey Golub
b5d67ecf24 Fix not printing version when requested if used with stub default command 2019-08-24 22:46:10 +03:00
Alexey Golub
a94b2296e1 Add tests for CommandInitializer that verify that short name comparison is case sensitive 2019-08-24 22:44:11 +03:00
Alexey Golub
fa05e4df3f Rework schema validation in CommandSchemaResolver 2019-08-24 22:23:12 +03:00
Alexey Golub
b70b25076e Add smoke tests for CliApplicationBuilder 2019-08-24 18:31:17 +03:00
Alexey Golub
0662f341e6 Rename some methods 2019-08-24 18:25:56 +03:00
Alexey Golub
80bf477f3b Add support for directives (debug and preview)
Closes #7
Closes #8
2019-08-24 18:22:54 +03:00
Alexey Golub
e4a502d9d6 Rename ProgressReporter to ProgressTicker 2019-08-24 13:00:13 +03:00
Alexey Golub
13b15b98ed Add ProgressReporter
Closes #14
2019-08-23 22:50:43 +03:00
Alexey Golub
80465e0e51 Move tests into corresponding namespaces 2019-08-23 17:01:49 +03:00
Alexey Golub
9a1ce7e7e5 Add 1 more negative test for CommandSchemaResolver 2019-08-22 12:08:08 +03:00
Alexey Golub
b45da64664 Make CommandAttribute non-optional on command types 2019-08-21 21:04:42 +03:00
Alexey Golub
df01dc055e Prepend 'v' to default version text 2019-08-21 15:55:05 +03:00
Alexey Golub
31dd24d189 Sort options when rendering help 2019-08-21 14:37:53 +03:00
Alexey Golub
2a76dfe1c8 Update version 2019-08-20 18:12:33 +03:00
Alexey Golub
59ee2e34d8 Don't add abstract and interface types that implement ICommand 2019-08-20 18:12:22 +03:00
Alexey Golub
9e04f79469 Update version 2019-08-20 17:25:32 +03:00
Alexey Golub
cd55898011 Refactor CliApplication 2019-08-20 17:24:06 +03:00
Alexey Golub
272c079767 Refactor tests and make them more consistent 2019-08-20 17:15:53 +03:00
Alexey Golub
256b693466 Add negative tests for CommandOptionInputConverter 2019-08-20 12:27:11 +03:00
Alexey Golub
89cc3c8785 Add even more tests for CommandSchemaResolver 2019-08-19 23:23:40 +03:00
Alexey Golub
43e3042bac Improve tests for CommandSchemaResolver 2019-08-19 23:19:47 +03:00
Alexey Golub
c906833ac7 Lower target framework to net45 2019-08-19 22:58:42 +03:00
Alexey Golub
dd882a6372 Refactor tests and add best-effort tests for HelpTextRenderer 2019-08-19 22:49:21 +03:00
Alexey Golub
3017c3d6c3 Fix incorrect default executable name for .NET Core apps 2019-08-19 22:02:19 +03:00
Alexey Golub
4b98dbf51f Refactor CommandInputParser 2019-08-19 21:51:06 +03:00
Alexey Golub
e652f9bda4 Set proper default executable name for apps launched with dotnet SDK 2019-08-19 18:54:23 +03:00
Alexey Golub
21c550d99c Update readme 2019-08-19 17:19:49 +03:00
Alexey Golub
23d29a8309 Update readme 2019-08-19 15:22:51 +03:00
Alexey Golub
70796c1254 Add etymology section to readme 2019-08-19 14:44:06 +03:00
Alexey Golub
1b62b2ded2 Add philosophy section to the readme 2019-08-19 14:42:12 +03:00
Alexey Golub
a9f4958c92 Refactor CommandFactory 2019-08-19 01:20:01 +03:00
Alexey Golub
66f9b1a256 Rework CommandSchemaResolver and move validation there 2019-08-19 01:15:10 +03:00
Alexey Golub
de8513c6fa Rename things to make them slightly more consistent 2019-08-18 18:59:52 +03:00
Alexey Golub
105dc88ccd Try to standardize built-in command options
Also remove '-?' as a valid alias for help
2019-08-18 18:06:03 +03:00
Alexey Golub
b736eeaf7d Rename CommandHelpTextRenderer to HelpTextRenderer 2019-08-18 17:30:54 +03:00
Alexey Golub
04415cbfc1 Rename WithCommand* to AddCommand* on CliApplicationBuilder 2019-08-18 17:21:25 +03:00
Alexey Golub
45c2b9c4e0 Update readme and add benchmark results 2019-08-18 17:13:45 +03:00
Alexey Golub
78ffaeb4b2 Add some comments 2019-08-18 15:03:53 +03:00
Alexey Golub
08e2874eb4 Reset color before rendering help text 2019-08-18 14:16:35 +03:00
Alexey Golub
6648ae22eb Mark required commands in help text 2019-08-18 14:14:13 +03:00
Alexey Golub
bd6b1a1134 Refactor CommandHelpTextRenderer slightly 2019-08-18 14:08:27 +03:00
Alexey Golub
d5b95bf1f1 Fix incorrect ToString() implementation on some models 2019-08-18 13:57:10 +03:00
Alexey Golub
f5c34ca454 Use invariant culture in CliFx.Tests.Dummy 2019-08-18 13:23:15 +03:00
Alexey Golub
63f583b02a Small refactor 2019-08-18 01:43:18 +03:00
Alexey Golub
fa82f892e4 Improve coverage for CommandOptionInputConverter 2019-08-18 01:35:48 +03:00
Alexey Golub
5a696c181b Refactor ToString() on some models 2019-08-18 01:11:15 +03:00
Alexey Golub
7d7edaf30f Refactor command type list into ApplicationConfiguration 2019-08-17 23:46:55 +03:00
Alexey Golub
172ec1f15e Refactor CommandOptionInputConverter and add support for array-initializable types 2019-08-17 21:34:31 +03:00
Alexey Golub
e5bbda5892 Remove option groups 2019-08-17 19:31:09 +03:00
Alexey Golub
fc1568ce20 Proper validation errors for default commands 2019-08-17 16:50:39 +03:00
Alexey Golub
efd8bbe89f Validate that all command types implement ICommand 2019-08-17 16:46:07 +03:00
Alexey Golub
2d8b0b4c88 Rename CommandErrorException to CommandException 2019-08-17 16:31:28 +03:00
Alexey Golub
87688ec29e Rename TestConsole to VirtualConsole 2019-08-16 17:51:48 +03:00
Alexey Golub
ddc1ae8537 Add application description to metadata 2019-08-16 17:43:50 +03:00
Alexey Golub
5104a2ebf9 Only print error message if it's set, otherwise fallback to stack trace 2019-08-16 17:35:44 +03:00
Alexey Golub
b6ea1c3df0 Update readme 2019-08-16 14:40:27 +03:00
Alexey Golub
cf521a9fb3 Simpler usage of Microsoft.Extensions.DependencyInjection service provider 2019-08-16 13:57:56 +03:00
Alexey Golub
b5fa60a26b Update readme 2019-08-15 21:27:23 +03:00
Alexey Golub
500378070d Update readme 2019-08-15 11:48:47 +03:00
Alexey Golub
24c892b1ab Update readme 2019-08-14 21:43:15 +03:00
Alexey Golub
f1554fd08a Add demo project 2019-08-14 17:47:05 +03:00
Alexey Golub
5a08b8c19b Add guards 2019-08-14 13:49:14 +03:00
Alexey Golub
7dfbb40860 Refactor CommandHelpTextRenderer using local functions 2019-08-14 12:34:59 +03:00
Alexey Golub
743241cb3b Add xml documentation 2019-08-13 21:59:57 +03:00
Alexey Golub
384482a47c Don't ignore case in short names 2019-08-13 18:38:24 +03:00
Alexey Golub
86fdf72d9c Validate available command schemas at the start of the application 2019-08-13 18:34:23 +03:00
Alexey Golub
dc067ba224 Make CliApplicationBuilder set defaults through itself to increase reuse 2019-08-13 18:03:52 +03:00
Alexey Golub
a322632e46 Change ICommandHelpTextRenderer 2019-08-13 18:00:26 +03:00
Alexey Golub
f09caa876f Refactor 2019-08-13 17:27:26 +03:00
Alexey Golub
018320582b Use parameterless action in IConsole.WithColor extension method 2019-08-12 22:29:34 +03:00
Alexey Golub
18429827df Render help text properly in two columns 2019-08-12 22:24:44 +03:00
Alexey Golub
b050ca4d67 Add usage to readme 2019-08-11 23:32:58 +03:00
Alexey Golub
f8cd2a56b2 Don't print stacktrace on exceptions specific to CliFx domain 2019-08-11 21:03:08 +03:00
Alexey Golub
6a06cdc422 Fix benchmarks 2019-08-11 18:44:35 +03:00
Alexey Golub
b0d9626e74 Add CliApplicationBuilder 2019-08-11 00:32:52 +03:00
Alexey Golub
f47cd3774e Update nuget packages 2019-08-10 14:10:26 +03:00
Alexey Golub
ed72571ddc Refactor 2019-07-30 23:08:08 +03:00
Alexey Golub
e7e47b1c9d Quick and dirty but working support for subcommands in help 2019-07-30 20:11:59 +03:00
Alexey Golub
50df046754 Handle cases where matchingCommandSchema == null more cleanly 2019-07-30 18:13:59 +03:00
Alexey Golub
041a995c62 Add console abstraction, remove CommandContext 2019-07-30 17:35:06 +03:00
Alexey Golub
5174d5354b Add Parse(string, IFormatProvider) handling to option converter 2019-07-28 23:07:42 +03:00
Alexey Golub
9856e784f5 Add benchmarks 2019-07-28 22:08:02 +03:00
Alexey Golub
16676cff8c Add ToString overloads for some models for easier debugging 2019-07-28 19:34:47 +03:00
Alexey Golub
d9c27dc82a Create FUNDING.yml 2019-07-27 02:01:51 +03:00
Alexey Golub
5bb175fd4b Use FluentAssertions 2019-07-26 17:39:28 +03:00
Alexey Golub
d72391df1f Move custom equality comparers to tests to increase coverage 2019-07-26 16:34:02 +03:00
Alexey Golub
c1ee1a968a Remove some public methods to avoid testing them 2019-07-26 15:56:17 +03:00
Alexey Golub
4e9effe481 Encapsulate application title, executable name, and version to ApplicationMetadata 2019-07-26 00:17:31 +03:00
Alexey Golub
5ac9b33056 Add support for space-separated command names in input parser
This enables multi-level subcommands
Closes #2
2019-07-26 00:00:26 +03:00
Alexey Golub
a64a8fc651 Show available options in help text even if there are none defined
Because --help and --version are automatically added
2019-07-25 23:27:48 +03:00
Alexey Golub
24eef8957d Inform user that they can use help on a specific command 2019-07-25 23:12:58 +03:00
Alexey Golub
dd2789790e Fail when there are no commands defined 2019-07-25 23:06:35 +03:00
Alexey Golub
d2599af90b Rework architecture again 2019-07-25 19:49:43 +03:00
Alexey Golub
2bdb2bddc8 Rework architecture and implement auto help 2019-07-23 00:49:28 +03:00
Alexey Golub
77c7faa759 Introduce ICommand 2019-07-17 23:07:20 +03:00
Alexey Golub
4ba9413012 Refactor 2019-07-17 22:54:50 +03:00
Alexey Golub
3611aa51e6 Add code coverage 2019-07-10 21:40:26 +03:00
Alexey Golub
74ee927498 Refactor 2019-06-29 22:02:41 +03:00
Alexey Golub
79cf994386 Refactor dummy tests 2019-06-16 17:56:24 +03:00
Alexey Golub
7a5a32d27b Add command description 2019-06-15 21:26:56 +03:00
Alexey Golub
1543076bf4 Throw exception when an option has multiple values but the target type is not an array 2019-06-09 22:14:01 +03:00
Alexey Golub
63d798977d Enhance option converter and add support for array options 2019-06-09 21:57:30 +03:00
Alexey Golub
e0211fc141 Improve option converter and add support for dynamic types constructable or parseable from string 2019-06-09 01:51:46 +03:00
Alexey Golub
fd6ed3ca72 Add support for stacked options followed by a value 2019-06-08 23:50:56 +03:00
Alexey Golub
3a9ac3d36c Cleanup tests 2019-06-02 19:53:21 +03:00
119 changed files with 5186 additions and 1109 deletions

4
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,4 @@
github: Tyrrrz
patreon: Tyrrrz
open_collective: Tyrrrz
custom: ['buymeacoffee.com/Tyrrrz']

View File

@@ -0,0 +1,34 @@
using System.Threading.Tasks;
using BenchmarkDotNet.Attributes;
using CliFx.Benchmarks.Commands;
namespace CliFx.Benchmarks
{
[CoreJob]
[RankColumn]
public class Benchmark
{
private static readonly string[] Arguments = { "--str", "hello world", "-i", "13", "-b" };
[Benchmark(Description = "CliFx", Baseline = true)]
public Task<int> ExecuteWithCliFx() => new CliApplicationBuilder().AddCommand(typeof(CliFxCommand)).Build().RunAsync(Arguments);
[Benchmark(Description = "System.CommandLine")]
public Task<int> ExecuteWithSystemCommandLine() => new SystemCommandLineCommand().ExecuteAsync(Arguments);
[Benchmark(Description = "McMaster.Extensions.CommandLineUtils")]
public int ExecuteWithMcMaster() => McMaster.Extensions.CommandLineUtils.CommandLineApplication.Execute<McMasterCommand>(Arguments);
// Skipped because this benchmark freezes after a couple of iterations
// Probably wasn't designed to run multiple times in single process execution
//[Benchmark(Description = "CommandLineParser")]
public void ExecuteWithCommandLineParser()
{
var parsed = CommandLine.Parser.Default.ParseArguments(Arguments, typeof(CommandLineParserCommand));
CommandLine.ParserResultExtensions.WithParsed<CommandLineParserCommand>(parsed, c => c.Execute());
}
[Benchmark(Description = "PowerArgs")]
public void ExecuteWithPowerArgs() => PowerArgs.Args.InvokeMain<PowerArgsCommand>(Arguments);
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp2.2</TargetFramework>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.11.5" />
<PackageReference Include="CommandLineParser" Version="2.6.0" />
<PackageReference Include="McMaster.Extensions.CommandLineUtils" Version="2.3.4" />
<PackageReference Include="PowerArgs" Version="3.6.0" />
<PackageReference Include="System.CommandLine.Experimental" Version="0.3.0-alpha.19317.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CliFx\CliFx.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,21 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
namespace CliFx.Benchmarks.Commands
{
[Command]
public class CliFxCommand : ICommand
{
[CommandOption("str", 's')]
public string StrOption { get; set; }
[CommandOption("int", 'i')]
public int IntOption { get; set; }
[CommandOption("bool", 'b')]
public bool BoolOption { get; set; }
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
}
}

View File

@@ -0,0 +1,20 @@
using CommandLine;
namespace CliFx.Benchmarks.Commands
{
public class CommandLineParserCommand
{
[Option('s', "str")]
public string StrOption { get; set; }
[Option('i', "int")]
public int IntOption { get; set; }
[Option('b', "bool")]
public bool BoolOption { get; set; }
public void Execute()
{
}
}
}

View File

@@ -0,0 +1,18 @@
using McMaster.Extensions.CommandLineUtils;
namespace CliFx.Benchmarks.Commands
{
public class McMasterCommand
{
[Option("--str|-s")]
public string StrOption { get; set; }
[Option("--int|-i")]
public int IntOption { get; set; }
[Option("--bool|-b")]
public bool BoolOption { get; set; }
public int OnExecute() => 0;
}
}

View File

@@ -0,0 +1,20 @@
using PowerArgs;
namespace CliFx.Benchmarks.Commands
{
public class PowerArgsCommand
{
[ArgShortcut("--str"), ArgShortcut("-s")]
public string StrOption { get; set; }
[ArgShortcut("--int"), ArgShortcut("-i")]
public int IntOption { get; set; }
[ArgShortcut("--bool"), ArgShortcut("-b")]
public bool BoolOption { get; set; }
public void Main()
{
}
}
}

View File

@@ -0,0 +1,34 @@
using System.CommandLine;
using System.CommandLine.Invocation;
using System.Threading.Tasks;
namespace CliFx.Benchmarks.Commands
{
public class SystemCommandLineCommand
{
public static int ExecuteHandler(string s, int i, bool b) => 0;
public Task<int> ExecuteAsync(string[] args)
{
var command = new RootCommand
{
new Option(new[] {"--str", "-s"})
{
Argument = new Argument<string>()
},
new Option(new[] {"--int", "-i"})
{
Argument = new Argument<int>()
},
new Option(new[] {"--bool", "-b"})
{
Argument = new Argument<bool>()
}
};
command.Handler = CommandHandler.Create(typeof(SystemCommandLineCommand).GetMethod(nameof(ExecuteHandler)));
return command.InvokeAsync(args);
}
}
}

View File

@@ -0,0 +1,12 @@
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Running;
namespace CliFx.Benchmarks
{
public static class Program
{
public static void Main() =>
BenchmarkRunner.Run(typeof(Program).Assembly, DefaultConfig.Instance
.With(ConfigOptions.DisableOptimizationsValidator));
}
}

View File

@@ -2,10 +2,15 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net45</TargetFramework> <TargetFramework>netcoreapp2.2</TargetFramework>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="2.2.0" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.2" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\CliFx\CliFx.csproj" /> <ProjectReference Include="..\CliFx\CliFx.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -0,0 +1,75 @@
using System;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Demo.Internal;
using CliFx.Demo.Models;
using CliFx.Demo.Services;
using CliFx.Exceptions;
using CliFx.Services;
namespace CliFx.Demo.Commands
{
[Command("book add", Description = "Add a book to the library.")]
public partial class BookAddCommand : ICommand
{
private readonly LibraryService _libraryService;
[CommandOption("title", 't', IsRequired = true, Description = "Book title.")]
public string Title { get; set; }
[CommandOption("author", 'a', IsRequired = true, Description = "Book author.")]
public string Author { get; set; }
[CommandOption("published", 'p', Description = "Book publish date.")]
public DateTimeOffset Published { get; set; }
[CommandOption("isbn", 'n', Description = "Book ISBN.")]
public Isbn Isbn { get; set; }
public BookAddCommand(LibraryService libraryService)
{
_libraryService = libraryService;
}
public Task 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);
var book = new Book(Title, Author, Published, Isbn);
_libraryService.AddBook(book);
console.Output.WriteLine("Book added.");
console.RenderBook(book);
return Task.CompletedTask;
}
}
public partial class BookAddCommand
{
private static readonly Random Random = new Random();
private static DateTimeOffset CreateRandomDate() => new DateTimeOffset(
Random.Next(1800, 2020),
Random.Next(1, 12),
Random.Next(1, 28),
Random.Next(1, 23),
Random.Next(1, 59),
Random.Next(1, 59),
TimeSpan.Zero);
public static Isbn CreateRandomIsbn() => new Isbn(
Random.Next(0, 999),
Random.Next(0, 99),
Random.Next(0, 99999),
Random.Next(0, 99),
Random.Next(0, 9));
}
}

View File

@@ -0,0 +1,35 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Demo.Internal;
using CliFx.Demo.Services;
using CliFx.Exceptions;
using CliFx.Services;
namespace CliFx.Demo.Commands
{
[Command("book", Description = "View, list, add or remove books.")]
public class BookCommand : ICommand
{
private readonly LibraryService _libraryService;
[CommandOption("title", 't', IsRequired = true, Description = "Book title.")]
public string Title { get; set; }
public BookCommand(LibraryService libraryService)
{
_libraryService = libraryService;
}
public Task ExecuteAsync(IConsole console)
{
var book = _libraryService.GetBook(Title);
if (book == null)
throw new CommandException("Book not found.", 1);
console.RenderBook(book);
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,38 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Demo.Internal;
using CliFx.Demo.Services;
using CliFx.Services;
namespace CliFx.Demo.Commands
{
[Command("book list", Description = "List all books in the library.")]
public class BookListCommand : ICommand
{
private readonly LibraryService _libraryService;
public BookListCommand(LibraryService libraryService)
{
_libraryService = libraryService;
}
public Task ExecuteAsync(IConsole console)
{
var library = _libraryService.GetLibrary();
var isFirst = true;
foreach (var book in library.Books)
{
// Margin
if (!isFirst)
console.Output.WriteLine();
isFirst = false;
// Render book
console.RenderBook(book);
}
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,36 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Demo.Services;
using CliFx.Exceptions;
using CliFx.Services;
namespace CliFx.Demo.Commands
{
[Command("book remove", Description = "Remove a book from the library.")]
public class BookRemoveCommand : ICommand
{
private readonly LibraryService _libraryService;
[CommandOption("title", 't', IsRequired = true, Description = "Book title.")]
public string Title { get; set; }
public BookRemoveCommand(LibraryService libraryService)
{
_libraryService = libraryService;
}
public Task ExecuteAsync(IConsole console)
{
var book = _libraryService.GetBook(Title);
if (book == null)
throw new CommandException("Book not found.", 1);
_libraryService.RemoveBook(book);
console.Output.WriteLine($"Book {Title} removed.");
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,30 @@
using System;
using CliFx.Demo.Models;
using CliFx.Services;
namespace CliFx.Demo.Internal
{
internal static class Extensions
{
public static void RenderBook(this IConsole console, Book book)
{
// Title
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine(book.Title));
// Author
console.Output.Write(" ");
console.Output.Write("Author: ");
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine(book.Author));
// Published
console.Output.Write(" ");
console.Output.Write("Published: ");
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine($"{book.Published:d}"));
// ISBN
console.Output.Write(" ");
console.Output.Write("ISBN: ");
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine(book.Isbn));
}
}
}

23
CliFx.Demo/Models/Book.cs Normal file
View File

@@ -0,0 +1,23 @@
using System;
namespace CliFx.Demo.Models
{
public class Book
{
public string Title { get; }
public string Author { get; }
public DateTimeOffset Published { get; }
public Isbn Isbn { get; }
public Book(string title, string author, DateTimeOffset published, Isbn isbn)
{
Title = title;
Author = author;
Published = published;
Isbn = isbn;
}
}
}

View File

@@ -0,0 +1,22 @@
using System.Linq;
namespace CliFx.Demo.Models
{
public static class Extensions
{
public static Library WithBook(this Library library, Book book)
{
var books = library.Books.ToList();
books.Add(book);
return new Library(books);
}
public static Library WithoutBook(this Library library, Book book)
{
var books = library.Books.Where(b => b != book).ToArray();
return new Library(books);
}
}
}

44
CliFx.Demo/Models/Isbn.cs Normal file
View File

@@ -0,0 +1,44 @@
using System;
using System.Globalization;
namespace CliFx.Demo.Models
{
public partial class Isbn
{
public int EanPrefix { get; }
public int RegistrationGroup { get; }
public int Registrant { get; }
public int Publication { get; }
public int CheckDigit { get; }
public Isbn(int eanPrefix, int registrationGroup, int registrant, int publication, int checkDigit)
{
EanPrefix = eanPrefix;
RegistrationGroup = registrationGroup;
Registrant = registrant;
Publication = publication;
CheckDigit = checkDigit;
}
public override string ToString() => $"{EanPrefix:000}-{RegistrationGroup:00}-{Registrant:00000}-{Publication:00}-{CheckDigit:0}";
}
public partial class Isbn
{
public static Isbn Parse(string value)
{
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));
}
}
}

View File

@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
namespace CliFx.Demo.Models
{
public partial class Library
{
public IReadOnlyList<Book> Books { get; }
public Library(IReadOnlyList<Book> books)
{
Books = books;
}
}
public partial class Library
{
public static Library Empty { get; } = new Library(Array.Empty<Book>());
}
}

33
CliFx.Demo/Program.cs Normal file
View File

@@ -0,0 +1,33 @@
using System.Threading.Tasks;
using CliFx.Demo.Commands;
using CliFx.Demo.Services;
using Microsoft.Extensions.DependencyInjection;
namespace CliFx.Demo
{
public static class Program
{
public static Task<int> Main(string[] args)
{
// We use Microsoft.Extensions.DependencyInjection for injecting dependencies in commands
var services = new ServiceCollection();
// Register services
services.AddSingleton<LibraryService>();
// Register commands
services.AddTransient<BookCommand>();
services.AddTransient<BookAddCommand>();
services.AddTransient<BookRemoveCommand>();
services.AddTransient<BookListCommand>();
var serviceProvider = services.BuildServiceProvider();
return new CliApplicationBuilder()
.AddCommandsFromThisAssembly()
.UseCommandFactory(schema => (ICommand) serviceProvider.GetRequiredService(schema.Type))
.Build()
.RunAsync(args);
}
}
}

7
CliFx.Demo/Readme.md Normal file
View File

@@ -0,0 +1,7 @@
# CliFx Demo Project
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.
You can get a list of available commands by running `CliFx.Demo --help`.

View File

@@ -0,0 +1,42 @@
using System.IO;
using System.Linq;
using CliFx.Demo.Models;
using Newtonsoft.Json;
namespace CliFx.Demo.Services
{
public class LibraryService
{
private string StorageFilePath => Path.Combine(Directory.GetCurrentDirectory(), "Data.json");
private void StoreLibrary(Library library)
{
var data = JsonConvert.SerializeObject(library);
File.WriteAllText(StorageFilePath, data);
}
public Library GetLibrary()
{
if (!File.Exists(StorageFilePath))
return Library.Empty;
var data = File.ReadAllText(StorageFilePath);
return JsonConvert.DeserializeObject<Library>(data);
}
public Book GetBook(string title) => GetLibrary().Books.FirstOrDefault(b => b.Title == title);
public void AddBook(Book book)
{
var updatedLibrary = GetLibrary().WithBook(book);
StoreLibrary(updatedLibrary);
}
public void RemoveBook(Book book)
{
var updatedLibrary = GetLibrary().WithoutBook(book);
StoreLibrary(updatedLibrary);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,48 @@
using System;
using System.IO;
using CliFx.Services;
using CliFx.Tests.TestCommands;
using NUnit.Framework;
namespace CliFx.Tests
{
[TestFixture]
public class CliApplicationBuilderTests
{
// Make sure all builder methods work
[Test]
public void All_Smoke_Test()
{
// Arrange
var builder = new CliApplicationBuilder();
// Act
builder
.AddCommand(typeof(HelloWorldDefaultCommand))
.AddCommandsFrom(typeof(HelloWorldDefaultCommand).Assembly)
.AddCommands(new[] {typeof(HelloWorldDefaultCommand)})
.AddCommandsFrom(new[] {typeof(HelloWorldDefaultCommand).Assembly})
.AddCommandsFromThisAssembly()
.AllowDebugMode()
.AllowPreviewMode()
.UseTitle("test")
.UseExecutableName("test")
.UseVersionText("test")
.UseDescription("test")
.UseConsole(new VirtualConsole(TextWriter.Null))
.UseCommandFactory(schema => (ICommand) Activator.CreateInstance(schema.Type))
.Build();
}
// Make sure builder can produce an application with no parameters specified
[Test]
public void Build_Test()
{
// Arrange
var builder = new CliApplicationBuilder();
// Act
builder.Build();
}
}
}

View File

@@ -1,8 +1,10 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Services; using CliFx.Services;
using CliFx.Tests.TestObjects; using CliFx.Tests.TestCommands;
using Moq; using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
namespace CliFx.Tests namespace CliFx.Tests
@@ -10,24 +12,219 @@ namespace CliFx.Tests
[TestFixture] [TestFixture]
public class CliApplicationTests public class CliApplicationTests
{ {
private const string TestVersionText = "v1.0";
private static IEnumerable<TestCaseData> GetTestCases_RunAsync()
{
yield return new TestCaseData(
new[] {typeof(HelloWorldDefaultCommand)},
new string[0],
"Hello world."
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"concat", "-i", "foo", "-i", "bar", "-s", " "},
"foo bar"
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"concat", "-i", "one", "two", "three", "-s", ", "},
"one, two, three"
);
yield return new TestCaseData(
new[] {typeof(DivideCommand)},
new[] {"div", "-D", "24", "-d", "8"},
"3"
);
yield return new TestCaseData(
new[] {typeof(HelloWorldDefaultCommand)},
new[] {"--version"},
TestVersionText
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"--version"},
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],
null
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"-h"},
null
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"--help"},
null
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"concat", "-h"},
null
);
yield return new TestCaseData(
new[] {typeof(ExceptionCommand)},
new[] {"exc", "-h"},
null
);
yield return new TestCaseData(
new[] {typeof(CommandExceptionCommand)},
new[] {"exc", "-h"},
null
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"[preview]"},
null
);
yield return new TestCaseData(
new[] {typeof(ExceptionCommand)},
new[] {"exc", "[preview]"},
null
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"concat", "[preview]", "-o", "value"},
null
);
}
private static IEnumerable<TestCaseData> GetTestCases_RunAsync_Negative()
{
yield return new TestCaseData(
new Type[0],
new string[0],
null, null
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"non-existing"},
null, null
);
yield return new TestCaseData(
new[] {typeof(ExceptionCommand)},
new[] {"exc"},
null, null
);
yield return new TestCaseData(
new[] {typeof(CommandExceptionCommand)},
new[] {"exc"},
null, null
);
yield return new TestCaseData(
new[] {typeof(CommandExceptionCommand)},
new[] {"exc"},
null, null
);
yield return new TestCaseData(
new[] {typeof(CommandExceptionCommand)},
new[] {"exc", "-m", "foo bar"},
"foo bar", null
);
yield return new TestCaseData(
new[] {typeof(CommandExceptionCommand)},
new[] {"exc", "-m", "foo bar", "-c", "666"},
"foo bar", 666
);
}
[Test] [Test]
public async Task RunAsync_Test() [TestCaseSource(nameof(GetTestCases_RunAsync))]
public async Task RunAsync_Test(IReadOnlyList<Type> commandTypes, IReadOnlyList<string> commandLineArguments,
string expectedStdOut = null)
{ {
// Arrange // Arrange
var command = new TestCommand(); using (var stdoutStream = new StringWriter())
var expectedExitCode = await command.ExecuteAsync(); {
var console = new VirtualConsole(stdoutStream);
var commandResolverMock = new Mock<ICommandResolver>(); var application = new CliApplicationBuilder()
commandResolverMock.Setup(m => m.ResolveCommand(It.IsAny<IReadOnlyList<string>>())).Returns(command); .AddCommands(commandTypes)
var commandResolver = commandResolverMock.Object; .UseVersionText(TestVersionText)
.UseConsole(console)
.Build();
var application = new CliApplication(commandResolver); // Act
var exitCode = await application.RunAsync(commandLineArguments);
var stdOut = stdoutStream.ToString().Trim();
// Act // Assert
var exitCodeValue = await application.RunAsync(); exitCode.Should().Be(0);
// Assert if (expectedStdOut != null)
Assert.That(exitCodeValue, Is.EqualTo(expectedExitCode.Value)); stdOut.Should().Be(expectedStdOut);
else
stdOut.Should().NotBeNullOrWhiteSpace();
}
}
[Test]
[TestCaseSource(nameof(GetTestCases_RunAsync_Negative))]
public async Task RunAsync_Negative_Test(IReadOnlyList<Type> commandTypes, IReadOnlyList<string> commandLineArguments,
string expectedStdErr = null, int? expectedExitCode = null)
{
// Arrange
using (var stderrStream = new StringWriter())
{
var console = new VirtualConsole(TextWriter.Null, stderrStream);
var application = new CliApplicationBuilder()
.AddCommands(commandTypes)
.UseVersionText(TestVersionText)
.UseConsole(console)
.Build();
// Act
var exitCode = await application.RunAsync(commandLineArguments);
var stderr = stderrStream.ToString().Trim();
// Assert
if (expectedExitCode != null)
exitCode.Should().Be(expectedExitCode);
else
exitCode.Should().NotBe(0);
if (expectedStdErr != null)
stderr.Should().Be(expectedStdErr);
else
stderr.Should().NotBeNullOrWhiteSpace();
}
} }
} }
} }

View File

@@ -1,22 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net45</TargetFramework> <TargetFramework>net46</TargetFramework>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject> <IsTestProject>true</IsTestProject>
<CollectCoverage>true</CollectCoverage>
<CoverletOutputFormat>opencover</CoverletOutputFormat>
<CoverletOutput>bin/$(Configuration)/Coverage.xml</CoverletOutput>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.0.1" /> <PackageReference Include="FluentAssertions" Version="5.8.0" />
<PackageReference Include="NUnit" Version="3.11.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.2.0" />
<PackageReference Include="NUnit3TestAdapter" Version="3.11.0" /> <PackageReference Include="NUnit" Version="3.12.0" />
<PackageReference Include="Moq" Version="4.11.0" /> <PackageReference Include="NUnit3TestAdapter" Version="3.14.0" />
<PackageReference Include="CliWrap" Version="2.3.0" /> <PackageReference Include="coverlet.msbuild" Version="2.6.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\CliFx.Tests.Dummy\CliFx.Tests.Dummy.csproj" />
<ProjectReference Include="..\CliFx\CliFx.csproj" /> <ProjectReference Include="..\CliFx\CliFx.csproj" />
</ItemGroup> </ItemGroup>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,37 @@
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().GetCommandSchemas(new[] {commandType}).Single();
private static IEnumerable<TestCaseData> 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);
}
}
}

View File

@@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CliFx.Exceptions;
using CliFx.Models;
using CliFx.Services;
using CliFx.Tests.TestCommands;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests.Services
{
[TestFixture]
public class CommandInitializerTests
{
private static CommandSchema GetCommandSchema(Type commandType) =>
new CommandSchemaResolver().GetCommandSchemas(new[] {commandType}).Single();
private static IEnumerable<TestCaseData> GetTestCases_InitializeCommand()
{
yield return new TestCaseData(
new DivideCommand(),
GetCommandSchema(typeof(DivideCommand)),
new CommandInput("div", new[]
{
new CommandOptionInput("dividend", "13"),
new CommandOptionInput("divisor", "8")
}),
new DivideCommand {Dividend = 13, Divisor = 8}
);
yield return new TestCaseData(
new DivideCommand(),
GetCommandSchema(typeof(DivideCommand)),
new CommandInput("div", new[]
{
new CommandOptionInput("dividend", "13"),
new CommandOptionInput("d", "8")
}),
new DivideCommand {Dividend = 13, Divisor = 8}
);
yield return new TestCaseData(
new DivideCommand(),
GetCommandSchema(typeof(DivideCommand)),
new CommandInput("div", new[]
{
new CommandOptionInput("D", "13"),
new CommandOptionInput("d", "8")
}),
new DivideCommand {Dividend = 13, Divisor = 8}
);
yield return new TestCaseData(
new ConcatCommand(),
GetCommandSchema(typeof(ConcatCommand)),
new CommandInput("concat", new[]
{
new CommandOptionInput("i", new[] {"foo", " ", "bar"})
}),
new ConcatCommand {Inputs = new[] {"foo", " ", "bar"}}
);
yield return new TestCaseData(
new ConcatCommand(),
GetCommandSchema(typeof(ConcatCommand)),
new CommandInput("concat", new[]
{
new CommandOptionInput("i", new[] {"foo", "bar"}),
new CommandOptionInput("s", " ")
}),
new ConcatCommand {Inputs = new[] {"foo", "bar"}, Separator = " "}
);
}
private static IEnumerable<TestCaseData> GetTestCases_InitializeCommand_Negative()
{
yield return new TestCaseData(
new DivideCommand(),
GetCommandSchema(typeof(DivideCommand)),
new CommandInput("div")
);
yield return new TestCaseData(
new DivideCommand(),
GetCommandSchema(typeof(DivideCommand)),
new CommandInput("div", new[]
{
new CommandOptionInput("D", "13")
})
);
yield return new TestCaseData(
new ConcatCommand(),
GetCommandSchema(typeof(ConcatCommand)),
new CommandInput("concat")
);
yield return new TestCaseData(
new ConcatCommand(),
GetCommandSchema(typeof(ConcatCommand)),
new CommandInput("concat", new[]
{
new CommandOptionInput("s", "_")
})
);
}
[Test]
[TestCaseSource(nameof(GetTestCases_InitializeCommand))]
public void InitializeCommand_Test(ICommand command, CommandSchema commandSchema, CommandInput commandInput,
ICommand expectedCommand)
{
// Arrange
var initializer = new CommandInitializer();
// Act
initializer.InitializeCommand(command, commandSchema, commandInput);
// Assert
command.Should().BeEquivalentTo(expectedCommand, o => o.RespectingRuntimeTypes());
}
[Test]
[TestCaseSource(nameof(GetTestCases_InitializeCommand_Negative))]
public void InitializeCommand_Negative_Test(ICommand command, CommandSchema commandSchema, CommandInput commandInput)
{
// Arrange
var initializer = new CommandInitializer();
// Act & Assert
initializer.Invoking(i => i.InitializeCommand(command, commandSchema, commandInput))
.Should().ThrowExactly<CliFxException>();
}
}
}

View File

@@ -0,0 +1,219 @@
using System.Collections.Generic;
using CliFx.Models;
using CliFx.Services;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests.Services
{
[TestFixture]
public class CommandInputParserTests
{
private static IEnumerable<TestCaseData> GetTestCases_ParseCommandInput()
{
yield return new TestCaseData(new string[0], CommandInput.Empty);
yield return new TestCaseData(
new[] {"--option", "value"},
new CommandInput(new[]
{
new CommandOptionInput("option", "value")
})
);
yield return new TestCaseData(
new[] {"--option1", "value1", "--option2", "value2"},
new CommandInput(new[]
{
new CommandOptionInput("option1", "value1"),
new CommandOptionInput("option2", "value2")
})
);
yield return new TestCaseData(
new[] {"--option", "value1", "value2"},
new CommandInput(new[]
{
new CommandOptionInput("option", new[] {"value1", "value2"})
})
);
yield return new TestCaseData(
new[] {"--option", "value1", "--option", "value2"},
new CommandInput(new[]
{
new CommandOptionInput("option", new[] {"value1", "value2"})
})
);
yield return new TestCaseData(
new[] {"-a", "value"},
new CommandInput(new[]
{
new CommandOptionInput("a", "value")
})
);
yield return new TestCaseData(
new[] {"-a", "value1", "-b", "value2"},
new CommandInput(new[]
{
new CommandOptionInput("a", "value1"),
new CommandOptionInput("b", "value2")
})
);
yield return new TestCaseData(
new[] {"-a", "value1", "value2"},
new CommandInput(new[]
{
new CommandOptionInput("a", new[] {"value1", "value2"})
})
);
yield return new TestCaseData(
new[] {"-a", "value1", "-a", "value2"},
new CommandInput(new[]
{
new CommandOptionInput("a", new[] {"value1", "value2"})
})
);
yield return new TestCaseData(
new[] {"--option1", "value1", "-b", "value2"},
new CommandInput(new[]
{
new CommandOptionInput("option1", "value1"),
new CommandOptionInput("b", "value2")
})
);
yield return new TestCaseData(
new[] {"--switch"},
new CommandInput(new[]
{
new CommandOptionInput("switch")
})
);
yield return new TestCaseData(
new[] {"--switch1", "--switch2"},
new CommandInput(new[]
{
new CommandOptionInput("switch1"),
new CommandOptionInput("switch2")
})
);
yield return new TestCaseData(
new[] {"-s"},
new CommandInput(new[]
{
new CommandOptionInput("s")
})
);
yield return new TestCaseData(
new[] {"-a", "-b"},
new CommandInput(new[]
{
new CommandOptionInput("a"),
new CommandOptionInput("b")
})
);
yield return new TestCaseData(
new[] {"-ab"},
new CommandInput(new[]
{
new CommandOptionInput("a"),
new CommandOptionInput("b")
})
);
yield return new TestCaseData(
new[] {"-ab", "value"},
new CommandInput(new[]
{
new CommandOptionInput("a"),
new CommandOptionInput("b", "value")
})
);
yield return new TestCaseData(
new[] {"command"},
new CommandInput("command")
);
yield return new TestCaseData(
new[] {"command", "--option", "value"},
new CommandInput("command", new[]
{
new CommandOptionInput("option", "value")
})
);
yield return new TestCaseData(
new[] {"long", "command", "name"},
new CommandInput("long command name")
);
yield return new TestCaseData(
new[] {"long", "command", "name", "--option", "value"},
new CommandInput("long command name", new[]
{
new CommandOptionInput("option", "value")
})
);
yield return new TestCaseData(
new[] {"[debug]"},
new CommandInput(null,
new[] {"debug"},
new CommandOptionInput[0])
);
yield return new TestCaseData(
new[] {"[debug]", "[preview]"},
new CommandInput(null,
new[] {"debug", "preview"},
new CommandOptionInput[0])
);
yield return new TestCaseData(
new[] {"[debug]", "[preview]", "-o", "value"},
new CommandInput(null,
new[] {"debug", "preview"},
new[]
{
new CommandOptionInput("o", "value")
})
);
yield return new TestCaseData(
new[] {"command", "[debug]", "[preview]", "-o", "value"},
new CommandInput("command",
new[] {"debug", "preview"},
new[]
{
new CommandOptionInput("o", "value")
})
);
}
[Test]
[TestCaseSource(nameof(GetTestCases_ParseCommandInput))]
public void ParseCommandInput_Test(IReadOnlyList<string> commandLineArguments,
CommandInput expectedCommandInput)
{
// Arrange
var parser = new CommandInputParser();
// Act
var commandInput = parser.ParseCommandInput(commandLineArguments);
// Assert
commandInput.Should().BeEquivalentTo(expectedCommandInput);
}
}
}

View File

@@ -0,0 +1,307 @@
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 CommandOptionInputConverterTests
{
private static IEnumerable<TestCaseData> 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[] {"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<string>),
new[] {"value1", "value2"}
);
yield return new TestCaseData(
new CommandOptionInput("option", new[] {"value1", "value2"}),
typeof(IReadOnlyList<string>),
new[] {"value1", "value2"}
);
yield return new TestCaseData(
new CommandOptionInput("option", new[] {"value1", "value2"}),
typeof(List<string>),
new List<string> {"value1", "value2"}
);
yield return new TestCaseData(
new CommandOptionInput("option", new[] {"value1", "value2"}),
typeof(HashSet<string>),
new HashSet<string> {"value1", "value2"}
);
}
private static IEnumerable<TestCaseData> GetTestCases_ConvertOptionInput_Negative()
{
yield return new TestCaseData(
new CommandOptionInput("option", "1234.5"),
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 CommandOptionInputConverter();
// 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 CommandOptionInputConverter();
// Act & Assert
converter.Invoking(c => c.ConvertOptionInput(optionInput, targetType))
.Should().ThrowExactly<CliFxException>();
}
}
}

View File

@@ -0,0 +1,109 @@
using System;
using System.Collections.Generic;
using CliFx.Exceptions;
using CliFx.Models;
using CliFx.Services;
using CliFx.Tests.TestCommands;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests.Services
{
[TestFixture]
public class CommandSchemaResolverTests
{
private static IEnumerable<TestCaseData> GetTestCases_GetCommandSchemas()
{
yield return new TestCaseData(
new[] {typeof(DivideCommand), typeof(ConcatCommand)},
new[]
{
new CommandSchema(typeof(DivideCommand), "div", "Divide one number by another.",
new[]
{
new CommandOptionSchema(typeof(DivideCommand).GetProperty(nameof(DivideCommand.Dividend)),
"dividend", 'D', true, "The number to divide."),
new CommandOptionSchema(typeof(DivideCommand).GetProperty(nameof(DivideCommand.Divisor)),
"divisor", 'd', true, "The number to divide by.")
}),
new CommandSchema(typeof(ConcatCommand), "concat", "Concatenate strings.",
new[]
{
new CommandOptionSchema(typeof(ConcatCommand).GetProperty(nameof(ConcatCommand.Inputs)),
null, 'i', true, "Input strings."),
new CommandOptionSchema(typeof(ConcatCommand).GetProperty(nameof(ConcatCommand.Separator)),
null, 's', false, "String separator.")
})
}
);
yield return new TestCaseData(
new[] {typeof(HelloWorldDefaultCommand)},
new[]
{
new CommandSchema(typeof(HelloWorldDefaultCommand), null, null, new CommandOptionSchema[0])
}
);
}
private static IEnumerable<TestCaseData> 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)}
});
}
[Test]
[TestCaseSource(nameof(GetTestCases_GetCommandSchemas))]
public void GetCommandSchemas_Test(IReadOnlyList<Type> commandTypes,
IReadOnlyList<CommandSchema> expectedCommandSchemas)
{
// Arrange
var commandSchemaResolver = new CommandSchemaResolver();
// Act
var commandSchemas = commandSchemaResolver.GetCommandSchemas(commandTypes);
// Assert
commandSchemas.Should().BeEquivalentTo(expectedCommandSchemas);
}
[Test]
[TestCaseSource(nameof(GetTestCases_GetCommandSchemas_Negative))]
public void GetCommandSchemas_Negative_Test(IReadOnlyList<Type> commandTypes)
{
// Arrange
var resolver = new CommandSchemaResolver();
// Act & Assert
resolver.Invoking(r => r.GetCommandSchemas(commandTypes))
.Should().ThrowExactly<CliFxException>();
}
}
}

View File

@@ -0,0 +1,40 @@
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().GetCommandSchemas(new[] {commandType}).Single();
private static IEnumerable<TestCaseData> GetTestCases_CreateCommand()
{
yield return new TestCaseData(
new Func<CommandSchema, ICommand>(schema => (ICommand) Activator.CreateInstance(schema.Type)),
GetCommandSchema(typeof(HelloWorldDefaultCommand))
);
}
[Test]
[TestCaseSource(nameof(GetTestCases_CreateCommand))]
public void CreateCommand_Test(Func<CommandSchema, ICommand> factoryMethod, CommandSchema commandSchema)
{
// Arrange
var factory = new DelegateCommandFactory(factoryMethod);
// Act
var command = factory.CreateCommand(commandSchema);
// Assert
command.Should().BeOfType(commandSchema.Type);
}
}
}

View File

@@ -0,0 +1,109 @@
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<Type> availableCommandTypes, Type targetCommandType)
{
var commandSchemaResolver = new CommandSchemaResolver();
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<TestCaseData> 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."
}
);
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."
}
);
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."
}
);
}
[Test]
[TestCaseSource(nameof(GetTestCases_RenderHelpText))]
public void RenderHelpText_Test(HelpTextSource source,
IReadOnlyList<string> expectedSubstrings)
{
// 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);
}
}
}
}

View File

@@ -0,0 +1,41 @@
using System;
using CliFx.Services;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests.Services
{
[TestFixture]
public class SystemConsoleTests
{
[TearDown]
public void TearDown()
{
// Reset console color so it doesn't carry on into next tests
Console.ResetColor();
}
// Make sure console correctly wraps around System.Console
[Test]
public void All_Smoke_Test()
{
// Arrange
var console = new SystemConsole();
// Act
console.ResetColor();
console.ForegroundColor = ConsoleColor.DarkMagenta;
console.BackgroundColor = ConsoleColor.DarkMagenta;
// Assert
console.Input.Should().BeSameAs(Console.In);
console.IsInputRedirected.Should().Be(Console.IsInputRedirected);
console.Output.Should().BeSameAs(Console.Out);
console.IsOutputRedirected.Should().Be(Console.IsOutputRedirected);
console.Error.Should().BeSameAs(Console.Error);
console.IsErrorRedirected.Should().Be(Console.IsErrorRedirected);
console.ForegroundColor.Should().Be(Console.ForegroundColor);
console.BackgroundColor.Should().Be(Console.BackgroundColor);
}
}
}

View File

@@ -0,0 +1,43 @@
using System;
using System.IO;
using CliFx.Services;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests.Services
{
[TestFixture]
public class VirtualConsoleTests
{
// Make sure console uses specified streams and doesn't leak to System.Console
[Test]
public void All_Smoke_Test()
{
// Arrange
using (var stdin = new StringReader("hello world"))
using (var stdout = new StringWriter())
using (var stderr = new StringWriter())
{
var console = new VirtualConsole(stdin, stdout, stderr);
// Act
console.ResetColor();
console.ForegroundColor = ConsoleColor.DarkMagenta;
console.BackgroundColor = ConsoleColor.DarkMagenta;
// Assert
console.Input.Should().BeSameAs(stdin);
console.Input.Should().NotBeSameAs(Console.In);
console.IsInputRedirected.Should().BeTrue();
console.Output.Should().BeSameAs(stdout);
console.Output.Should().NotBeSameAs(Console.Out);
console.IsOutputRedirected.Should().BeTrue();
console.Error.Should().BeSameAs(stderr);
console.Error.Should().NotBeSameAs(Console.Error);
console.IsErrorRedirected.Should().BeTrue();
console.ForegroundColor.Should().NotBe(Console.ForegroundColor);
console.BackgroundColor.Should().NotBe(Console.BackgroundColor);
}
}
}
}

View File

@@ -0,0 +1,19 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Services;
namespace CliFx.Tests.TestCommands
{
[Command("exc")]
public class CommandExceptionCommand : ICommand
{
[CommandOption("code", 'c')]
public int ExitCode { get; set; } = 1337;
[CommandOption("msg", 'm')]
public string Message { get; set; }
public Task ExecuteAsync(IConsole console) => throw new CommandException(Message, ExitCode);
}
}

View File

@@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
namespace CliFx.Tests.TestCommands
{
[Command("concat", Description = "Concatenate strings.")]
public class ConcatCommand : ICommand
{
[CommandOption('i', IsRequired = true, Description = "Input strings.")]
public IReadOnlyList<string> Inputs { get; set; }
[CommandOption('s', Description = "String separator.")]
public string Separator { get; set; } = "";
public Task ExecuteAsync(IConsole console)
{
console.Output.WriteLine(string.Join(Separator, Inputs));
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,25 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
namespace CliFx.Tests.TestCommands
{
[Command("div", Description = "Divide one number by another.")]
public class DivideCommand : ICommand
{
[CommandOption("dividend", 'D', IsRequired = true, Description = "The number to divide.")]
public double Dividend { get; set; }
[CommandOption("divisor", 'd', IsRequired = true, Description = "The number to divide by.")]
public double Divisor { get; set; }
// This property should be ignored by resolver
public bool NotAnOption { get; set; }
public Task ExecuteAsync(IConsole console)
{
console.Output.WriteLine(Dividend / Divisor);
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,18 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
namespace CliFx.Tests.TestCommands
{
[Command]
public class DuplicateOptionNamesCommand : ICommand
{
[CommandOption("fruits")]
public string Apples { get; set; }
[CommandOption("fruits")]
public string Oranges { get; set; }
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
}
}

View File

@@ -0,0 +1,18 @@
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; }
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
}
}

View File

@@ -0,0 +1,16 @@
using System;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
namespace CliFx.Tests.TestCommands
{
[Command("exc")]
public class ExceptionCommand : ICommand
{
[CommandOption("msg", 'm')]
public string Message { get; set; }
public Task ExecuteAsync(IConsole console) => throw new Exception(Message);
}
}

View File

@@ -0,0 +1,16 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
namespace CliFx.Tests.TestCommands
{
[Command]
public class HelloWorldDefaultCommand : ICommand
{
public Task ExecuteAsync(IConsole console)
{
console.Output.WriteLine("Hello world.");
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,18 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
namespace CliFx.Tests.TestCommands
{
[Command(Description = "HelpDefaultCommand description.")]
public class HelpDefaultCommand : ICommand
{
[CommandOption("option-a", 'a', Description = "OptionA description.")]
public string OptionA { get; set; }
[CommandOption("option-b", 'b', Description = "OptionB description.")]
public string OptionB { get; set; }
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
}
}

View File

@@ -0,0 +1,18 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
namespace CliFx.Tests.TestCommands
{
[Command("cmd", Description = "HelpNamedCommand description.")]
public class HelpNamedCommand : ICommand
{
[CommandOption("option-c", 'c', Description = "OptionC description.")]
public string OptionC { get; set; }
[CommandOption("option-d", 'd', Description = "OptionD description.")]
public string OptionD { get; set; }
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
}
}

View File

@@ -0,0 +1,15 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
namespace CliFx.Tests.TestCommands
{
[Command("cmd sub", Description = "HelpSubCommand description.")]
public class HelpSubCommand : ICommand
{
[CommandOption("option-e", 'e', Description = "OptionE description.")]
public string OptionE { get; set; }
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
}
}

View File

@@ -0,0 +1,10 @@
using System.Threading.Tasks;
using CliFx.Services;
namespace CliFx.Tests.TestCommands
{
public class NonAnnotatedCommand : ICommand
{
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
}
}

View File

@@ -0,0 +1,9 @@
using CliFx.Attributes;
namespace CliFx.Tests.TestCommands
{
[Command]
public class NonImplementedCommand
{
}
}

View File

@@ -1,4 +1,4 @@
namespace CliFx.Tests.TestObjects namespace CliFx.Tests.TestCustomTypes
{ {
public enum TestEnum public enum TestEnum
{ {

View File

@@ -0,0 +1,12 @@
namespace CliFx.Tests.TestCustomTypes
{
public class TestNonStringParseable
{
public int Value { get; }
public TestNonStringParseable(int value)
{
Value = value;
}
}
}

View File

@@ -0,0 +1,12 @@
namespace CliFx.Tests.TestCustomTypes
{
public class TestStringConstructable
{
public string Value { get; }
public TestStringConstructable(string value)
{
Value = value;
}
}
}

View File

@@ -0,0 +1,14 @@
namespace CliFx.Tests.TestCustomTypes
{
public class TestStringParseable
{
public string Value { get; }
private TestStringParseable(string value)
{
Value = value;
}
public static TestStringParseable Parse(string value) => new TestStringParseable(value);
}
}

View File

@@ -0,0 +1,17 @@
using System;
namespace CliFx.Tests.TestCustomTypes
{
public class TestStringParseableWithFormatProvider
{
public string Value { get; }
private TestStringParseableWithFormatProvider(string value)
{
Value = value;
}
public static TestStringParseableWithFormatProvider Parse(string value, IFormatProvider formatProvider) =>
new TestStringParseableWithFormatProvider(value + " " + formatProvider);
}
}

View File

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

View File

@@ -0,0 +1,57 @@
using System.Globalization;
using System.IO;
using System.Linq;
using CliFx.Services;
using CliFx.Utilities;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests.Utilities
{
[TestFixture]
public class ProgressTickerTests
{
[Test]
public void Report_Test()
{
// Arrange
var formatProvider = CultureInfo.InvariantCulture;
using (var stdout = new StringWriter(formatProvider))
{
var console = new VirtualConsole(TextReader.Null, false, stdout, false, TextWriter.Null, false);
var ticker = console.CreateProgressTicker();
var progressValues = Enumerable.Range(0, 100).Select(p => p / 100.0).ToArray();
var progressStringValues = progressValues.Select(p => p.ToString("P2", formatProvider)).ToArray();
// Act
foreach (var progress in progressValues)
ticker.Report(progress);
// Assert
stdout.ToString().Should().ContainAll(progressStringValues);
}
}
[Test]
public void Report_Redirected_Test()
{
// Arrange
using (var stdout = new StringWriter())
{
var console = new VirtualConsole(stdout);
var ticker = console.CreateProgressTicker();
var progressValues = Enumerable.Range(0, 100).Select(p => p / 100.0).ToArray();
// Act
foreach (var progress in progressValues)
ticker.Report(progress);
// Assert
stdout.ToString().Should().BeEmpty();
}
}
}
}

View File

@@ -7,8 +7,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx", "CliFx\CliFx.csproj
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx.Tests", "CliFx.Tests\CliFx.Tests.csproj", "{268CF863-65A5-49BB-93CF-08972B7756DC}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx.Tests", "CliFx.Tests\CliFx.Tests.csproj", "{268CF863-65A5-49BB-93CF-08972B7756DC}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx.Tests.Dummy", "CliFx.Tests.Dummy\CliFx.Tests.Dummy.csproj", "{4904B3EB-3286-4F1B-8B74-6FF051C8E787}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{3AAE8166-BB8E-49DA-844C-3A0EE6BD40A0}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{3AAE8166-BB8E-49DA-844C-3A0EE6BD40A0}"
ProjectSection(SolutionItems) = preProject ProjectSection(SolutionItems) = preProject
Changelog.md = Changelog.md Changelog.md = Changelog.md
@@ -16,6 +14,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
Readme.md = Readme.md Readme.md = Readme.md
EndProjectSection EndProjectSection
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx.Benchmarks", "CliFx.Benchmarks\CliFx.Benchmarks.csproj", "{8ACD6DC2-D768-4850-9223-5B7C83A78513}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx.Demo", "CliFx.Demo\CliFx.Demo.csproj", "{AAB6844C-BF71-448F-A11B-89AEE459AB15}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -50,18 +52,30 @@ Global
{268CF863-65A5-49BB-93CF-08972B7756DC}.Release|x64.Build.0 = Release|Any CPU {268CF863-65A5-49BB-93CF-08972B7756DC}.Release|x64.Build.0 = Release|Any CPU
{268CF863-65A5-49BB-93CF-08972B7756DC}.Release|x86.ActiveCfg = Release|Any CPU {268CF863-65A5-49BB-93CF-08972B7756DC}.Release|x86.ActiveCfg = Release|Any CPU
{268CF863-65A5-49BB-93CF-08972B7756DC}.Release|x86.Build.0 = Release|Any CPU {268CF863-65A5-49BB-93CF-08972B7756DC}.Release|x86.Build.0 = Release|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8ACD6DC2-D768-4850-9223-5B7C83A78513}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Debug|Any CPU.Build.0 = Debug|Any CPU {8ACD6DC2-D768-4850-9223-5B7C83A78513}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Debug|x64.ActiveCfg = Debug|Any CPU {8ACD6DC2-D768-4850-9223-5B7C83A78513}.Debug|x64.ActiveCfg = Debug|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Debug|x64.Build.0 = Debug|Any CPU {8ACD6DC2-D768-4850-9223-5B7C83A78513}.Debug|x64.Build.0 = Debug|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Debug|x86.ActiveCfg = Debug|Any CPU {8ACD6DC2-D768-4850-9223-5B7C83A78513}.Debug|x86.ActiveCfg = Debug|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Debug|x86.Build.0 = Debug|Any CPU {8ACD6DC2-D768-4850-9223-5B7C83A78513}.Debug|x86.Build.0 = Debug|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Release|Any CPU.ActiveCfg = Release|Any CPU {8ACD6DC2-D768-4850-9223-5B7C83A78513}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Release|Any CPU.Build.0 = Release|Any CPU {8ACD6DC2-D768-4850-9223-5B7C83A78513}.Release|Any CPU.Build.0 = Release|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Release|x64.ActiveCfg = Release|Any CPU {8ACD6DC2-D768-4850-9223-5B7C83A78513}.Release|x64.ActiveCfg = Release|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Release|x64.Build.0 = Release|Any CPU {8ACD6DC2-D768-4850-9223-5B7C83A78513}.Release|x64.Build.0 = Release|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Release|x86.ActiveCfg = Release|Any CPU {8ACD6DC2-D768-4850-9223-5B7C83A78513}.Release|x86.ActiveCfg = Release|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Release|x86.Build.0 = Release|Any CPU {8ACD6DC2-D768-4850-9223-5B7C83A78513}.Release|x86.Build.0 = Release|Any CPU
{AAB6844C-BF71-448F-A11B-89AEE459AB15}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AAB6844C-BF71-448F-A11B-89AEE459AB15}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AAB6844C-BF71-448F-A11B-89AEE459AB15}.Debug|x64.ActiveCfg = Debug|Any CPU
{AAB6844C-BF71-448F-A11B-89AEE459AB15}.Debug|x64.Build.0 = Debug|Any CPU
{AAB6844C-BF71-448F-A11B-89AEE459AB15}.Debug|x86.ActiveCfg = Debug|Any CPU
{AAB6844C-BF71-448F-A11B-89AEE459AB15}.Debug|x86.Build.0 = Debug|Any CPU
{AAB6844C-BF71-448F-A11B-89AEE459AB15}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AAB6844C-BF71-448F-A11B-89AEE459AB15}.Release|Any CPU.Build.0 = Release|Any CPU
{AAB6844C-BF71-448F-A11B-89AEE459AB15}.Release|x64.ActiveCfg = Release|Any CPU
{AAB6844C-BF71-448F-A11B-89AEE459AB15}.Release|x64.Build.0 = Release|Any CPU
{AAB6844C-BF71-448F-A11B-89AEE459AB15}.Release|x86.ActiveCfg = Release|Any CPU
{AAB6844C-BF71-448F-A11B-89AEE459AB15}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE

View File

@@ -2,14 +2,36 @@
namespace CliFx.Attributes namespace CliFx.Attributes
{ {
/// <summary>
/// Annotates a type that defines a command.
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)] [AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class CommandAttribute : Attribute public class CommandAttribute : Attribute
{ {
/// <summary>
/// Command name.
/// </summary>
public string Name { get; } public string Name { get; }
/// <summary>
/// Command description, which is used in help text.
/// </summary>
public string Description { get; set; }
/// <summary>
/// Initializes an instance of <see cref="CommandAttribute"/>.
/// </summary>
public CommandAttribute(string name) public CommandAttribute(string name)
{ {
Name = name; Name = name; // can be null
}
/// <summary>
/// Initializes an instance of <see cref="CommandAttribute"/>.
/// </summary>
public CommandAttribute()
: this(null)
{
} }
} }
} }

View File

@@ -2,20 +2,63 @@
namespace CliFx.Attributes namespace CliFx.Attributes
{ {
/// <summary>
/// Annotates a property that defines a command option.
/// </summary>
[AttributeUsage(AttributeTargets.Property)] [AttributeUsage(AttributeTargets.Property)]
public class CommandOptionAttribute : Attribute public class CommandOptionAttribute : Attribute
{ {
/// <summary>
/// Option name.
/// </summary>
public string Name { get; } public string Name { get; }
public char ShortName { get; set; } /// <summary>
/// Option short name.
/// </summary>
public char? ShortName { get; }
/// <summary>
/// Whether an option is required.
/// </summary>
public bool IsRequired { get; set; } public bool IsRequired { get; set; }
/// <summary>
/// Option description, which is used in help text.
/// </summary>
public string Description { get; set; } public string Description { get; set; }
public CommandOptionAttribute(string name) /// <summary>
/// Initializes an instance of <see cref="CommandOptionAttribute"/>.
/// </summary>
public CommandOptionAttribute(string name, char? shortName)
{
Name = name; // can be null
ShortName = shortName; // can be null
}
/// <summary>
/// Initializes an instance of <see cref="CommandOptionAttribute"/>.
/// </summary>
public CommandOptionAttribute(string name, char shortName)
: this(name, (char?) shortName)
{
}
/// <summary>
/// Initializes an instance of <see cref="CommandOptionAttribute"/>.
/// </summary>
public CommandOptionAttribute(string name)
: this(name, null)
{
}
/// <summary>
/// Initializes an instance of <see cref="CommandOptionAttribute"/>.
/// </summary>
public CommandOptionAttribute(char shortName)
: this(null, shortName)
{ {
Name = name;
} }
} }
} }

View File

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

View File

@@ -1,45 +1,231 @@
using System.Collections.Generic; using System;
using System.Reflection; using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Exceptions;
using CliFx.Internal;
using CliFx.Models;
using CliFx.Services; using CliFx.Services;
namespace CliFx namespace CliFx
{ {
public partial class CliApplication : ICliApplication /// <summary>
/// Default implementation of <see cref="ICliApplication"/>.
/// </summary>
public class CliApplication : ICliApplication
{ {
private readonly ICommandResolver _commandResolver; private readonly ApplicationMetadata _metadata;
private readonly ApplicationConfiguration _configuration;
public CliApplication(ICommandResolver commandResolver) private readonly IConsole _console;
private readonly ICommandInputParser _commandInputParser;
private readonly ICommandSchemaResolver _commandSchemaResolver;
private readonly ICommandFactory _commandFactory;
private readonly ICommandInitializer _commandInitializer;
private readonly IHelpTextRenderer _helpTextRenderer;
/// <summary>
/// Initializes an instance of <see cref="CliApplication"/>.
/// </summary>
public CliApplication(ApplicationMetadata metadata, ApplicationConfiguration configuration,
IConsole console, ICommandInputParser commandInputParser, ICommandSchemaResolver commandSchemaResolver,
ICommandFactory commandFactory, ICommandInitializer commandInitializer, IHelpTextRenderer helpTextRenderer)
{ {
_commandResolver = commandResolver; _metadata = metadata.GuardNotNull(nameof(metadata));
_configuration = configuration.GuardNotNull(nameof(configuration));
_console = console.GuardNotNull(nameof(console));
_commandInputParser = commandInputParser.GuardNotNull(nameof(commandInputParser));
_commandSchemaResolver = commandSchemaResolver.GuardNotNull(nameof(commandSchemaResolver));
_commandFactory = commandFactory.GuardNotNull(nameof(commandFactory));
_commandInitializer = commandInitializer.GuardNotNull(nameof(commandInitializer));
_helpTextRenderer = helpTextRenderer.GuardNotNull(nameof(helpTextRenderer));
} }
public CliApplication() private async Task<int?> HandleDebugDirectiveAsync(CommandInput commandInput)
: this(GetDefaultCommandResolver(Assembly.GetCallingAssembly()))
{ {
// 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
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."));
// Wait until debugger is attached
while (!Debugger.IsAttached)
await Task.Delay(100);
// Debug directive never short-circuits
return null;
} }
private int? HandlePreviewDirective(CommandInput commandInput)
{
// 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
if (!isPreviewMode)
return null;
// Render command name
_console.Output.WriteLine($"Command name: {commandInput.CommandName}");
_console.Output.WriteLine();
// Render directives
_console.Output.WriteLine("Directives:");
foreach (var directive in commandInput.Directives)
{
_console.Output.Write(" ");
_console.Output.WriteLine(directive);
}
// Margin
_console.Output.WriteLine();
// Render options
_console.Output.WriteLine("Options:");
foreach (var option in commandInput.Options)
{
_console.Output.Write(" ");
_console.Output.WriteLine(option);
}
// Short-circuit with exit code 0
return 0;
}
private int? HandleVersionOption(CommandInput commandInput)
{
// Version should be rendered if it was requested on a default command
var shouldRenderVersion = !commandInput.IsCommandSpecified() && commandInput.IsVersionOptionSpecified();
// If shouldn't render version, pass execution to the next handler
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<CommandSchema> availableCommandSchemas, CommandSchema targetCommandSchema)
{
// Help should be rendered if it was requested, or when executing a command which isn't defined
var shouldRenderHelp = commandInput.IsHelpOptionSpecified() || targetCommandSchema == null;
// 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;
// If target command isn't defined, find its contextual replacement
if (targetCommandSchema == null)
{
// If command was specified, inform the user that it's not defined
if (commandInput.IsCommandSpecified())
{
_console.WithForegroundColor(ConsoleColor.Red,
() => _console.Error.WriteLine($"Specified command [{commandInput.CommandName}] is not defined."));
isError = true;
}
// Replace target command with closest parent of specified command
targetCommandSchema = availableCommandSchemas.FindParent(commandInput.CommandName);
// If there's no parent, replace with stub default command
if (targetCommandSchema == null)
{
targetCommandSchema = CommandSchema.StubDefaultCommand;
availableCommandSchemas = availableCommandSchemas.Concat(CommandSchema.StubDefaultCommand).ToArray();
}
}
// Build help text source
var helpTextSource = new HelpTextSource(_metadata, availableCommandSchemas, targetCommandSchema);
// Render help text
_helpTextRenderer.RenderHelpText(_console, helpTextSource);
// Short-circuit with appropriate exit code
return isError ? -1 : 0;
}
private async Task<int> HandleCommandExecutionAsync(CommandInput commandInput, CommandSchema targetCommandSchema)
{
// Create an instance of the command
var command = _commandFactory.CreateCommand(targetCommandSchema);
// Populate command with options according to its schema
_commandInitializer.InitializeCommand(command, targetCommandSchema, commandInput);
// Execute command
await command.ExecuteAsync(_console);
// Finish the chain with exit code 0
return 0;
}
/// <inheritdoc />
public async Task<int> RunAsync(IReadOnlyList<string> commandLineArguments) public async Task<int> RunAsync(IReadOnlyList<string> commandLineArguments)
{ {
// Resolve and execute command commandLineArguments.GuardNotNull(nameof(commandLineArguments));
var command = _commandResolver.ResolveCommand(commandLineArguments);
var exitCode = await command.ExecuteAsync();
// TODO: print message if error? try
{
// Parse command input from arguments
var commandInput = _commandInputParser.ParseCommandInput(commandLineArguments);
return exitCode.Value; // Get schemas for all available command types
} var availableCommandSchemas = _commandSchemaResolver.GetCommandSchemas(_configuration.CommandTypes);
}
public partial class CliApplication // Find command schema matching the name specified in the input
{ var targetCommandSchema = availableCommandSchemas.FindByName(commandInput.CommandName);
private static ICommandResolver GetDefaultCommandResolver(Assembly assembly)
{
var typeProvider = TypeProvider.FromAssembly(assembly);
var commandOptionParser = new CommandOptionParser();
var commandOptionConverter = new CommandOptionConverter();
return new CommandResolver(typeProvider, commandOptionParser, commandOptionConverter); // Chain handlers until the first one that produces an exit code
return
await HandleDebugDirectiveAsync(commandInput) ??
HandlePreviewDirective(commandInput) ??
HandleVersionOption(commandInput) ??
HandleHelpOption(commandInput, availableCommandSchemas, targetCommandSchema) ??
await HandleCommandExecutionAsync(commandInput, targetCommandSchema);
}
catch (Exception ex)
{
// We want to catch exceptions in order to print errors and return correct exit codes.
// 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 (!ex.Message.IsNullOrWhiteSpace() && (ex is CliFxException || ex is CommandException))
{
_console.WithForegroundColor(ConsoleColor.Red, () => _console.Error.WriteLine(ex.Message));
}
else
{
_console.WithForegroundColor(ConsoleColor.Red, () => _console.Error.WriteLine(ex));
}
// Return exit code if it was specified via CommandException
if (ex is CommandException commandException)
{
return commandException.ExitCode;
}
else
{
return ex.HResult;
}
}
} }
} }
} }

View File

@@ -0,0 +1,156 @@
using System;
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;
namespace CliFx
{
/// <summary>
/// Default implementation of <see cref="ICliApplicationBuilder"/>.
/// </summary>
public partial class CliApplicationBuilder : ICliApplicationBuilder
{
private readonly HashSet<Type> _commandTypes = new HashSet<Type>();
private bool _isDebugModeAllowed = true;
private bool _isPreviewModeAllowed = true;
private string _title;
private string _executableName;
private string _versionText;
private string _description;
private IConsole _console;
private ICommandFactory _commandFactory;
/// <inheritdoc />
public ICliApplicationBuilder AddCommand(Type commandType)
{
commandType.GuardNotNull(nameof(commandType));
_commandTypes.Add(commandType);
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder AddCommandsFrom(Assembly commandAssembly)
{
commandAssembly.GuardNotNull(nameof(commandAssembly));
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;
}
/// <inheritdoc />
public ICliApplicationBuilder AllowDebugMode(bool isAllowed = true)
{
_isDebugModeAllowed = isAllowed;
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder AllowPreviewMode(bool isAllowed = true)
{
_isPreviewModeAllowed = isAllowed;
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder UseTitle(string title)
{
_title = title.GuardNotNull(nameof(title));
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder UseExecutableName(string executableName)
{
_executableName = executableName.GuardNotNull(nameof(executableName));
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder UseVersionText(string versionText)
{
_versionText = versionText.GuardNotNull(nameof(versionText));
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder UseDescription(string description)
{
_description = description; // can be null
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder UseConsole(IConsole console)
{
_console = console.GuardNotNull(nameof(console));
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder UseCommandFactory(ICommandFactory factory)
{
_commandFactory = factory.GuardNotNull(nameof(factory));
return this;
}
/// <inheritdoc />
public ICliApplication Build()
{
// Use defaults for required parameters that were not configured
_title = _title ?? GetDefaultTitle() ?? "App";
_executableName = _executableName ?? GetDefaultExecutableName() ?? "app";
_versionText = _versionText ?? GetDefaultVersionText() ?? "v1.0";
_console = _console ?? new SystemConsole();
_commandFactory = _commandFactory ?? new CommandFactory();
// 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(), new CommandSchemaResolver(),
_commandFactory, new CommandInitializer(), new HelpTextRenderer());
}
}
public partial class CliApplicationBuilder
{
private static readonly Lazy<Assembly> LazyEntryAssembly = new Lazy<Assembly>(Assembly.GetEntryAssembly);
// Entry assembly is null in tests
private static Assembly EntryAssembly => LazyEntryAssembly.Value;
private static string GetDefaultTitle() => EntryAssembly?.GetName().Name;
private static string GetDefaultExecutableName()
{
var entryAssemblyLocation = EntryAssembly?.Location;
// If it's a .dll assembly, prepend 'dotnet' and keep the file extension
if (string.Equals(Path.GetExtension(entryAssemblyLocation), ".dll", StringComparison.OrdinalIgnoreCase))
{
return "dotnet " + Path.GetFileName(entryAssemblyLocation);
}
// Otherwise just use assembly file name without extension
return Path.GetFileNameWithoutExtension(entryAssemblyLocation);
}
private static string GetDefaultVersionText() => EntryAssembly != null ? $"v{EntryAssembly.GetName().Version}" : null;
}
}

View File

@@ -3,7 +3,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFrameworks>net45;netstandard2.0</TargetFrameworks> <TargetFrameworks>net45;netstandard2.0</TargetFrameworks>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<Version>0.0.1</Version> <Version>0.0.4</Version>
<Company>Tyrrrz</Company> <Company>Tyrrrz</Company>
<Authors>$(Company)</Authors> <Authors>$(Company)</Authors>
<Copyright>Copyright (C) Alexey Golub</Copyright> <Copyright>Copyright (C) Alexey Golub</Copyright>
@@ -17,7 +17,7 @@
<RepositoryType>git</RepositoryType> <RepositoryType>git</RepositoryType>
<PackageRequireLicenseAcceptance>True</PackageRequireLicenseAcceptance> <PackageRequireLicenseAcceptance>True</PackageRequireLicenseAcceptance>
<GeneratePackageOnBuild>True</GeneratePackageOnBuild> <GeneratePackageOnBuild>True</GeneratePackageOnBuild>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile> <DocumentationFile>bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).xml</DocumentationFile>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

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

View File

@@ -0,0 +1,26 @@
using System;
namespace CliFx.Exceptions
{
/// <summary>
/// Domain exception thrown within CliFx.
/// </summary>
public class CliFxException : Exception
{
/// <summary>
/// Initializes an instance of <see cref="CliFxException"/>.
/// </summary>
public CliFxException(string message)
: base(message)
{
}
/// <summary>
/// Initializes an instance of <see cref="CliFxException"/>.
/// </summary>
public CliFxException(string message, Exception innerException)
: base(message, innerException)
{
}
}
}

View File

@@ -0,0 +1,45 @@
using System;
using CliFx.Internal;
namespace CliFx.Exceptions
{
/// <summary>
/// Thrown when a command cannot proceed with normal execution due to an error.
/// Use this exception if you want to report an error that occured during execution of a command.
/// This exception also allows specifying exit code which will be returned to the calling process.
/// </summary>
public class CommandException : Exception
{
private const int DefaultExitCode = -100;
/// <summary>
/// Process exit code.
/// </summary>
public int ExitCode { get; }
/// <summary>
/// Initializes an instance of <see cref="CommandException"/>.
/// </summary>
public CommandException(string message, Exception innerException, int exitCode = DefaultExitCode)
: base(message, innerException)
{
ExitCode = exitCode.GuardNotZero(nameof(exitCode));
}
/// <summary>
/// Initializes an instance of <see cref="CommandException"/>.
/// </summary>
public CommandException(string message, int exitCode = DefaultExitCode)
: this(message, null, exitCode)
{
}
/// <summary>
/// Initializes an instance of <see cref="CommandException"/>.
/// </summary>
public CommandException(int exitCode = DefaultExitCode)
: this(null, exitCode)
{
}
}
}

View File

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

View File

@@ -1,12 +1,63 @@
using System; using System;
using System.Linq; using System.Collections.Generic;
using System.Threading.Tasks; using System.Reflection;
using CliFx.Internal;
using CliFx.Models;
using CliFx.Services;
namespace CliFx namespace CliFx
{ {
/// <summary>
/// Extensions for <see cref="CliFx"/>.
/// </summary>
public static class Extensions public static class Extensions
{ {
public static Task<int> RunAsync(this ICliApplication application) => /// <summary>
application.RunAsync(Environment.GetCommandLineArgs().Skip(1).ToArray()); /// Adds multiple commands to the application.
/// </summary>
public static ICliApplicationBuilder AddCommands(this ICliApplicationBuilder builder, IReadOnlyList<Type> commandTypes)
{
builder.GuardNotNull(nameof(builder));
commandTypes.GuardNotNull(nameof(commandTypes));
foreach (var commandType in commandTypes)
builder.AddCommand(commandType);
return builder;
}
/// <summary>
/// Adds commands from specified assemblies to the application.
/// </summary>
public static ICliApplicationBuilder AddCommandsFrom(this ICliApplicationBuilder builder, IReadOnlyList<Assembly> commandAssemblies)
{
builder.GuardNotNull(nameof(builder));
commandAssemblies.GuardNotNull(nameof(commandAssemblies));
foreach (var commandAssembly in commandAssemblies)
builder.AddCommandsFrom(commandAssembly);
return builder;
}
/// <summary>
/// Adds commands from calling assembly to the application.
/// </summary>
public static ICliApplicationBuilder AddCommandsFromThisAssembly(this ICliApplicationBuilder builder)
{
builder.GuardNotNull(nameof(builder));
return builder.AddCommandsFrom(Assembly.GetCallingAssembly());
}
/// <summary>
/// Configures application to use specified factory method for creating new instances of <see cref="ICommand"/>.
/// </summary>
public static ICliApplicationBuilder UseCommandFactory(this ICliApplicationBuilder builder, Func<CommandSchema, ICommand> factoryMethod)
{
builder.GuardNotNull(nameof(builder));
factoryMethod.GuardNotNull(nameof(factoryMethod));
return builder.UseCommandFactory(new DelegateCommandFactory(factoryMethod));
}
} }
} }

View File

@@ -3,8 +3,14 @@ using System.Threading.Tasks;
namespace CliFx namespace CliFx
{ {
/// <summary>
/// Entry point for a command line application.
/// </summary>
public interface ICliApplication public interface ICliApplication
{ {
/// <summary>
/// Runs application with specified command line arguments and returns an exit code.
/// </summary>
Task<int> RunAsync(IReadOnlyList<string> commandLineArguments); Task<int> RunAsync(IReadOnlyList<string> commandLineArguments);
} }
} }

View File

@@ -0,0 +1,68 @@
using System;
using System.Reflection;
using CliFx.Services;
namespace CliFx
{
/// <summary>
/// Builds an instance of <see cref="ICliApplication"/>.
/// </summary>
public interface ICliApplicationBuilder
{
/// <summary>
/// Adds a command of specified type to the application.
/// </summary>
ICliApplicationBuilder AddCommand(Type commandType);
/// <summary>
/// Adds commands from specified assembly to the application.
/// </summary>
ICliApplicationBuilder AddCommandsFrom(Assembly commandAssembly);
/// <summary>
/// Specifies whether debug mode (enabled with [debug] directive) is allowed in the application.
/// </summary>
ICliApplicationBuilder AllowDebugMode(bool isAllowed = true);
/// <summary>
/// Specifies whether preview mode (enabled with [preview] directive) is allowed in the application.
/// </summary>
ICliApplicationBuilder AllowPreviewMode(bool isAllowed = true);
/// <summary>
/// Sets application title, which appears in the help text.
/// </summary>
ICliApplicationBuilder UseTitle(string title);
/// <summary>
/// Sets application executable name, which appears in the help text.
/// </summary>
ICliApplicationBuilder UseExecutableName(string executableName);
/// <summary>
/// Sets application version text, which appears in the help text and when the user requests version information.
/// </summary>
ICliApplicationBuilder UseVersionText(string versionText);
/// <summary>
/// Sets application description, which appears in the help text.
/// </summary>
ICliApplicationBuilder UseDescription(string description);
/// <summary>
/// Configures application to use specified implementation of <see cref="IConsole"/>.
/// </summary>
ICliApplicationBuilder UseConsole(IConsole console);
/// <summary>
/// Configures application to use specified implementation of <see cref="ICommandFactory"/>.
/// </summary>
ICliApplicationBuilder UseCommandFactory(ICommandFactory factory);
/// <summary>
/// Creates an instance of <see cref="ICliApplication"/> using configured parameters.
/// Default values are used in place of parameters that were not specified.
/// </summary>
ICliApplication Build();
}
}

17
CliFx/ICommand.cs Normal file
View File

@@ -0,0 +1,17 @@
using System.Threading.Tasks;
using CliFx.Services;
namespace CliFx
{
/// <summary>
/// Point of interaction between a user and command line interface.
/// </summary>
public interface ICommand
{
/// <summary>
/// Executes command using specified implementation of <see cref="IConsole"/>.
/// This method is called when the command is invoked by a user through command line interface.
/// </summary>
Task ExecuteAsync(IConsole console);
}
}

View File

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

View File

@@ -1,52 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using CliFx.Attributes;
namespace CliFx.Internal
{
internal partial class CommandType
{
private readonly Type _type;
public string Name { get; }
public bool IsDefault { get; }
public CommandType(Type type, string name, bool isDefault)
{
_type = type;
Name = name;
IsDefault = isDefault;
}
public IEnumerable<CommandOptionProperty> GetOptionProperties() => _type.GetProperties()
.Where(CommandOptionProperty.IsValid)
.Select(CommandOptionProperty.Initialize);
public Command Activate() => (Command) Activator.CreateInstance(_type);
}
internal partial class CommandType
{
public static bool IsValid(Type type) =>
// Derives from Command
type.IsDerivedFrom(typeof(Command)) &&
// Marked with DefaultCommandAttribute or CommandAttribute
(type.IsDefined(typeof(DefaultCommandAttribute)) || type.IsDefined(typeof(CommandAttribute)));
public static CommandType Initialize(Type type)
{
if (!IsValid(type))
throw new InvalidOperationException($"[{type.Name}] is not a valid command type.");
var name = type.GetCustomAttribute<CommandAttribute>()?.Name;
var isDefault = type.IsDefined(typeof(DefaultCommandAttribute));
return new CommandType(type, name, isDefault);
}
public static IEnumerable<CommandType> GetCommandTypes(IEnumerable<Type> types) => types.Where(IsValid).Select(Initialize);
}
}

View File

@@ -1,5 +1,8 @@
using System; using System;
using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace CliFx.Internal namespace CliFx.Internal
{ {
@@ -7,51 +10,55 @@ namespace CliFx.Internal
{ {
public static bool IsNullOrWhiteSpace(this string s) => string.IsNullOrWhiteSpace(s); public static bool IsNullOrWhiteSpace(this string s) => string.IsNullOrWhiteSpace(s);
public static string SubstringUntil(this string s, string sub, StringComparison comparison = StringComparison.Ordinal) public static string Repeat(this char c, int count) => new string(c, count);
{
var index = s.IndexOf(sub, comparison);
return index < 0 ? s : s.Substring(0, index);
}
public static string SubstringAfter(this string s, string sub, StringComparison comparison = StringComparison.Ordinal) public static string AsString(this char c) => c.Repeat(1);
{
var index = s.IndexOf(sub, comparison);
return index < 0 ? string.Empty : s.Substring(index + sub.Length, s.Length - index - sub.Length);
}
public static TValue GetValueOrDefault<TKey, TValue>(this IReadOnlyDictionary<TKey, TValue> dic, TKey key) =>
dic.TryGetValue(key, out var result) ? result : default;
public static string TrimStart(this string s, string sub, StringComparison comparison = StringComparison.Ordinal)
{
while (s.StartsWith(sub, comparison))
s = s.Substring(sub.Length);
return s;
}
public static string TrimEnd(this string s, string sub, StringComparison comparison = StringComparison.Ordinal)
{
while (s.EndsWith(sub, comparison))
s = s.Substring(0, s.Length - sub.Length);
return s;
}
public static string JoinToString<T>(this IEnumerable<T> source, string separator) => string.Join(separator, source); public static string JoinToString<T>(this IEnumerable<T> source, string separator) => string.Join(separator, source);
public static bool IsDerivedFrom(this Type type, Type baseType) public static string SubstringUntilLast(this string s, string sub,
StringComparison comparison = StringComparison.Ordinal)
{ {
var currentType = type; var index = s.LastIndexOf(sub, comparison);
while (currentType != null) return index < 0 ? s : s.Substring(0, index);
{ }
if (currentType == baseType)
return true;
currentType = currentType.BaseType; public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) =>
} builder.Length > 0 ? builder.Append(value) : builder;
return false; public static IEnumerable<T> Concat<T>(this IEnumerable<T> source, T value)
{
foreach (var i in source)
yield return i;
yield return value;
}
public static bool Implements(this Type type, Type interfaceType) => type.GetInterfaces().Contains(interfaceType);
public static Type GetEnumerableUnderlyingType(this Type type)
{
if (type == typeof(IEnumerable))
return typeof(object);
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>))
return type.GetGenericArguments().FirstOrDefault();
return type.GetInterfaces()
.Select(GetEnumerableUnderlyingType)
.Where(t => t != default)
.OrderByDescending(t => t != typeof(object)) // prioritize more specific types
.FirstOrDefault();
}
public static Array ToNonGenericArray<T>(this IEnumerable<T> source, Type elementType)
{
var sourceAsCollection = source as ICollection ?? source.ToArray();
var array = Array.CreateInstance(elementType, sourceAsCollection.Count);
sourceAsCollection.CopyTo(array, 0);
return array;
} }
} }
} }

13
CliFx/Internal/Guards.cs Normal file
View File

@@ -0,0 +1,13 @@
using System;
namespace CliFx.Internal
{
internal static class Guards
{
public static T GuardNotNull<T>(this T o, string argName = null) where T : class =>
o ?? throw new ArgumentNullException(argName);
public static int GuardNotZero(this int i, string argName = null) =>
i != 0 ? i : throw new ArgumentException("Cannot be zero.", argName);
}
}

View File

@@ -0,0 +1,38 @@
using System;
using System.Collections.Generic;
using CliFx.Internal;
namespace CliFx.Models
{
/// <summary>
/// Configuration of an application.
/// </summary>
public class ApplicationConfiguration
{
/// <summary>
/// Command types defined in this application.
/// </summary>
public IReadOnlyList<Type> CommandTypes { get; }
/// <summary>
/// Whether debug mode is allowed in this application.
/// </summary>
public bool IsDebugModeAllowed { get; }
/// <summary>
/// Whether preview mode is allowed in this application.
/// </summary>
public bool IsPreviewModeAllowed { get; }
/// <summary>
/// Initializes an instance of <see cref="ApplicationConfiguration"/>.
/// </summary>
public ApplicationConfiguration(IReadOnlyList<Type> commandTypes,
bool isDebugModeAllowed, bool isPreviewModeAllowed)
{
CommandTypes = commandTypes.GuardNotNull(nameof(commandTypes));
IsDebugModeAllowed = isDebugModeAllowed;
IsPreviewModeAllowed = isPreviewModeAllowed;
}
}
}

View File

@@ -0,0 +1,41 @@
using CliFx.Internal;
namespace CliFx.Models
{
/// <summary>
/// Metadata associated with an application.
/// </summary>
public class ApplicationMetadata
{
/// <summary>
/// Application title.
/// </summary>
public string Title { get; }
/// <summary>
/// Application executable name.
/// </summary>
public string ExecutableName { get; }
/// <summary>
/// Application version text.
/// </summary>
public string VersionText { get; }
/// <summary>
/// Application description.
/// </summary>
public string Description { get; }
/// <summary>
/// Initializes an instance of <see cref="ApplicationMetadata"/>.
/// </summary>
public ApplicationMetadata(string title, string executableName, string versionText, string description)
{
Title = title.GuardNotNull(nameof(title));
ExecutableName = executableName.GuardNotNull(nameof(executableName));
VersionText = versionText.GuardNotNull(nameof(versionText));
Description = description; // can be null
}
}
}

View File

@@ -0,0 +1,96 @@
using System.Collections.Generic;
using System.Text;
using CliFx.Internal;
namespace CliFx.Models
{
/// <summary>
/// Parsed command line input.
/// </summary>
public partial class CommandInput
{
/// <summary>
/// Specified command name.
/// Can be null if command was not specified.
/// </summary>
public string CommandName { get; }
/// <summary>
/// Specified directives.
/// </summary>
public IReadOnlyList<string> Directives { get; }
/// <summary>
/// Specified options.
/// </summary>
public IReadOnlyList<CommandOptionInput> Options { get; }
/// <summary>
/// Initializes an instance of <see cref="CommandInput"/>.
/// </summary>
public CommandInput(string commandName, IReadOnlyList<string> directives, IReadOnlyList<CommandOptionInput> options)
{
CommandName = commandName; // can be null
Directives = directives.GuardNotNull(nameof(directives));
Options = options.GuardNotNull(nameof(options));
}
/// <summary>
/// Initializes an instance of <see cref="CommandInput"/>.
/// </summary>
public CommandInput(string commandName, IReadOnlyList<CommandOptionInput> options)
: this(commandName, EmptyDirectives, options)
{
}
/// <summary>
/// Initializes an instance of <see cref="CommandInput"/>.
/// </summary>
public CommandInput(IReadOnlyList<CommandOptionInput> options)
: this(null, options)
{
}
/// <summary>
/// Initializes an instance of <see cref="CommandInput"/>.
/// </summary>
public CommandInput(string commandName)
: this(commandName, EmptyOptions)
{
}
/// <inheritdoc />
public override string ToString()
{
var buffer = new StringBuilder();
if (!CommandName.IsNullOrWhiteSpace())
buffer.Append(CommandName);
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<string> EmptyDirectives = new string[0];
private static readonly IReadOnlyList<CommandOptionInput> EmptyOptions = new CommandOptionInput[0];
/// <summary>
/// Empty input.
/// </summary>
public static CommandInput Empty { get; } = new CommandInput(EmptyOptions);
}
}

View File

@@ -0,0 +1,78 @@
using System.Collections.Generic;
using System.Text;
using CliFx.Internal;
namespace CliFx.Models
{
/// <summary>
/// Parsed option from command line input.
/// </summary>
public partial class CommandOptionInput
{
/// <summary>
/// Specified option alias.
/// </summary>
public string Alias { get; }
/// <summary>
/// Specified values.
/// </summary>
public IReadOnlyList<string> Values { get; }
/// <summary>
/// Initializes an instance of <see cref="CommandOptionInput"/>.
/// </summary>
public CommandOptionInput(string alias, IReadOnlyList<string> values)
{
Alias = alias.GuardNotNull(nameof(alias));
Values = values.GuardNotNull(nameof(values));
}
/// <summary>
/// Initializes an instance of <see cref="CommandOptionInput"/>.
/// </summary>
public CommandOptionInput(string alias, string value)
: this(alias, new[] {value})
{
}
/// <summary>
/// Initializes an instance of <see cref="CommandOptionInput"/>.
/// </summary>
public CommandOptionInput(string alias)
: this(alias, EmptyValues)
{
}
/// <inheritdoc />
public override string ToString()
{
var buffer = new StringBuilder();
buffer.Append(Alias.Length > 1 ? "--" : "-");
buffer.Append(Alias);
foreach (var value in Values)
{
buffer.AppendIfNotEmpty(' ');
var isEscaped = value.Contains(" ");
if (isEscaped)
buffer.Append('"');
buffer.Append(value);
if (isEscaped)
buffer.Append('"');
}
return buffer.ToString();
}
}
public partial class CommandOptionInput
{
private static readonly IReadOnlyList<string> EmptyValues = new string[0];
}
}

View File

@@ -0,0 +1,83 @@
using System.Reflection;
using System.Text;
using CliFx.Internal;
namespace CliFx.Models
{
/// <summary>
/// Schema of a defined command option.
/// </summary>
public partial class CommandOptionSchema
{
/// <summary>
/// Underlying property.
/// </summary>
public PropertyInfo Property { get; }
/// <summary>
/// Option name.
/// </summary>
public string Name { get; }
/// <summary>
/// Option short name.
/// </summary>
public char? ShortName { get; }
/// <summary>
/// Whether an option is required.
/// </summary>
public bool IsRequired { get; }
/// <summary>
/// Option description.
/// </summary>
public string Description { get; }
/// <summary>
/// Initializes an instance of <see cref="CommandOptionSchema"/>.
/// </summary>
public CommandOptionSchema(PropertyInfo property, string name, char? shortName, bool isRequired, string description)
{
Property = property; // can be null
Name = name; // can be null
ShortName = shortName; // can be null
IsRequired = isRequired;
Description = description; // can be null
}
/// <inheritdoc />
public override string ToString()
{
var buffer = new StringBuilder();
if (IsRequired)
buffer.Append('*');
if (!Name.IsNullOrWhiteSpace())
buffer.Append(Name);
if (!Name.IsNullOrWhiteSpace() && 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.");
internal static CommandOptionSchema VersionOption { get; } =
new CommandOptionSchema(null, "version", null, false, "Shows version information.");
}
}

View File

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

View File

@@ -0,0 +1,69 @@
using System;
using System.Collections.Generic;
using System.Text;
using CliFx.Internal;
namespace CliFx.Models
{
/// <summary>
/// Schema of a defined command.
/// </summary>
public partial class CommandSchema
{
/// <summary>
/// Underlying type.
/// </summary>
public Type Type { get; }
/// <summary>
/// Command name.
/// </summary>
public string Name { get; }
/// <summary>
/// Command description.
/// </summary>
public string Description { get; }
/// <summary>
/// Command options.
/// </summary>
public IReadOnlyList<CommandOptionSchema> Options { get; }
/// <summary>
/// Initializes an instance of <see cref="CommandSchema"/>.
/// </summary>
public CommandSchema(Type type, string name, string description, IReadOnlyList<CommandOptionSchema> options)
{
Type = type; // can be null
Name = name; // can be null
Description = description; // can be null
Options = options.GuardNotNull(nameof(options));
}
/// <inheritdoc />
public override string ToString()
{
var buffer = new StringBuilder();
if (!Name.IsNullOrWhiteSpace())
buffer.Append(Name);
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 CommandOptionSchema[0]);
}
}

View File

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

160
CliFx/Models/Extensions.cs Normal file
View File

@@ -0,0 +1,160 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CliFx.Internal;
namespace CliFx.Models
{
/// <summary>
/// Extensions for <see cref="Models"/>.
/// </summary>
public static class Extensions
{
/// <summary>
/// Finds a command that has specified name, or null if not found.
/// </summary>
public static CommandSchema FindByName(this IReadOnlyList<CommandSchema> commandSchemas, string commandName)
{
commandSchemas.GuardNotNull(nameof(commandSchemas));
// If looking for default command, don't compare names directly
// ...because null and empty are both valid names for default command
if (commandName.IsNullOrWhiteSpace())
return commandSchemas.FirstOrDefault(c => c.IsDefault());
return commandSchemas.FirstOrDefault(c => string.Equals(c.Name, commandName, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Finds parent command to the command that has specified name, or null if not found.
/// </summary>
public static CommandSchema FindParent(this IReadOnlyList<CommandSchema> commandSchemas, string commandName)
{
commandSchemas.GuardNotNull(nameof(commandSchemas));
// If command has no name, it's the default command so it doesn't have a parent
if (commandName.IsNullOrWhiteSpace())
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());
}
/// <summary>
/// Determines whether an option schema matches specified alias.
/// </summary>
public static bool MatchesAlias(this CommandOptionSchema optionSchema, string alias)
{
optionSchema.GuardNotNull(nameof(optionSchema));
alias.GuardNotNull(nameof(alias));
// Compare against name. Case is ignored.
var matchesByName =
!optionSchema.Name.IsNullOrWhiteSpace() &&
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;
}
/// <summary>
/// Finds an option that matches specified alias, or null if not found.
/// </summary>
public static CommandOptionSchema FindByAlias(this IReadOnlyList<CommandOptionSchema> optionSchemas, string alias)
{
optionSchemas.GuardNotNull(nameof(optionSchemas));
alias.GuardNotNull(nameof(alias));
return optionSchemas.FirstOrDefault(o => o.MatchesAlias(alias));
}
/// <summary>
/// Gets valid aliases for the option.
/// </summary>
public static IReadOnlyList<string> GetAliases(this CommandOptionSchema optionSchema)
{
var result = new List<string>(2);
if (!optionSchema.Name.IsNullOrWhiteSpace())
result.Add(optionSchema.Name);
if (optionSchema.ShortName != null)
result.Add(optionSchema.ShortName.Value.AsString());
return result;
}
/// <summary>
/// Gets whether a command was specified in the input.
/// </summary>
public static bool IsCommandSpecified(this CommandInput commandInput)
{
commandInput.GuardNotNull(nameof(commandInput));
return !commandInput.CommandName.IsNullOrWhiteSpace();
}
/// <summary>
/// Gets whether debug directive was specified in the input.
/// </summary>
public static bool IsDebugDirectiveSpecified(this CommandInput commandInput)
{
commandInput.GuardNotNull(nameof(commandInput));
return commandInput.Directives.Contains("debug", StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Gets whether preview directive was specified in the input.
/// </summary>
public static bool IsPreviewDirectiveSpecified(this CommandInput commandInput)
{
commandInput.GuardNotNull(nameof(commandInput));
return commandInput.Directives.Contains("preview", StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Gets whether help option was specified in the input.
/// </summary>
public static bool IsHelpOptionSpecified(this CommandInput commandInput)
{
commandInput.GuardNotNull(nameof(commandInput));
var firstOption = commandInput.Options.FirstOrDefault();
return firstOption != null && CommandOptionSchema.HelpOption.MatchesAlias(firstOption.Alias);
}
/// <summary>
/// Gets whether version option was specified in the input.
/// </summary>
public static bool IsVersionOptionSpecified(this CommandInput commandInput)
{
commandInput.GuardNotNull(nameof(commandInput));
var firstOption = commandInput.Options.FirstOrDefault();
return firstOption != null && CommandOptionSchema.VersionOption.MatchesAlias(firstOption.Alias);
}
/// <summary>
/// Gets whether this command is the default command, i.e. without a name.
/// </summary>
public static bool IsDefault(this CommandSchema commandSchema)
{
commandSchema.GuardNotNull(nameof(commandSchema));
return commandSchema.Name.IsNullOrWhiteSpace();
}
}
}

View File

@@ -0,0 +1,38 @@
using System.Collections.Generic;
using CliFx.Internal;
namespace CliFx.Models
{
/// <summary>
/// Source information used to generate help text.
/// </summary>
public class HelpTextSource
{
/// <summary>
/// Application metadata.
/// </summary>
public ApplicationMetadata ApplicationMetadata { get; }
/// <summary>
/// Schemas of commands available in the application.
/// </summary>
public IReadOnlyList<CommandSchema> AvailableCommandSchemas { get; }
/// <summary>
/// Schema of the command for which help text is to be generated.
/// </summary>
public CommandSchema TargetCommandSchema { get; }
/// <summary>
/// Initializes an instance of <see cref="HelpTextSource"/>.
/// </summary>
public HelpTextSource(ApplicationMetadata applicationMetadata,
IReadOnlyList<CommandSchema> availableCommandSchemas,
CommandSchema targetCommandSchema)
{
ApplicationMetadata = applicationMetadata.GuardNotNull(nameof(applicationMetadata));
AvailableCommandSchemas = availableCommandSchemas.GuardNotNull(nameof(availableCommandSchemas));
TargetCommandSchema = targetCommandSchema.GuardNotNull(nameof(targetCommandSchema));
}
}
}

View File

@@ -0,0 +1,19 @@
using System;
using CliFx.Internal;
using CliFx.Models;
namespace CliFx.Services
{
/// <summary>
/// Default implementation of <see cref="ICommandFactory"/>.
/// </summary>
public class CommandFactory : ICommandFactory
{
/// <inheritdoc />
public ICommand CreateCommand(CommandSchema commandSchema)
{
commandSchema.GuardNotNull(nameof(commandSchema));
return (ICommand) Activator.CreateInstance(commandSchema.Type);
}
}
}

View File

@@ -0,0 +1,68 @@
using System.Linq;
using CliFx.Exceptions;
using CliFx.Internal;
using CliFx.Models;
namespace CliFx.Services
{
/// <summary>
/// Default implementation of <see cref="ICommandInitializer"/>.
/// </summary>
public class CommandInitializer : ICommandInitializer
{
private readonly ICommandOptionInputConverter _commandOptionInputConverter;
/// <summary>
/// Initializes an instance of <see cref="CommandInitializer"/>.
/// </summary>
public CommandInitializer(ICommandOptionInputConverter commandOptionInputConverter)
{
_commandOptionInputConverter = commandOptionInputConverter.GuardNotNull(nameof(commandOptionInputConverter));
}
/// <summary>
/// Initializes an instance of <see cref="CommandInitializer"/>.
/// </summary>
public CommandInitializer()
: this(new CommandOptionInputConverter())
{
}
/// <inheritdoc />
public void InitializeCommand(ICommand command, CommandSchema commandSchema, CommandInput commandInput)
{
command.GuardNotNull(nameof(command));
commandSchema.GuardNotNull(nameof(commandSchema));
commandInput.GuardNotNull(nameof(commandInput));
// Keep track of unset required options to report an error at a later stage
var unsetRequiredOptions = commandSchema.Options.Where(o => o.IsRequired).ToList();
// Set command options
foreach (var optionInput in commandInput.Options)
{
// Find matching option schema for this option input
var optionSchema = commandSchema.Options.FindByAlias(optionInput.Alias);
if (optionSchema == null)
continue;
// Convert option to the type of the underlying property
var convertedValue = _commandOptionInputConverter.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($"One or more required options were not set: {unsetRequiredOptionNames}.");
}
}
}
}

View File

@@ -0,0 +1,84 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using CliFx.Internal;
using CliFx.Models;
namespace CliFx.Services
{
/// <summary>
/// Default implementation of <see cref="ICommandInputParser"/>.
/// </summary>
public class CommandInputParser : ICommandInputParser
{
/// <inheritdoc />
public CommandInput ParseCommandInput(IReadOnlyList<string> commandLineArguments)
{
commandLineArguments.GuardNotNull(nameof(commandLineArguments));
var commandNameBuilder = new StringBuilder();
var directives = new List<string>();
var optionsDic = new Dictionary<string, List<string>>();
// 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<string>();
}
// 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<string>();
}
}
// Encountered directive or (part of) command name
else if (lastOptionAlias.IsNullOrWhiteSpace())
{
if (commandLineArgument.StartsWith("[", StringComparison.OrdinalIgnoreCase) &&
commandLineArgument.EndsWith("]", StringComparison.OrdinalIgnoreCase))
{
// Extract directive
var directive = commandLineArgument.Substring(1, commandLineArgument.Length - 2);
directives.Add(directive);
}
else
{
commandNameBuilder.AppendIfNotEmpty(' ');
commandNameBuilder.Append(commandLineArgument);
}
}
// Encountered option value
else if (!lastOptionAlias.IsNullOrWhiteSpace())
{
optionsDic[lastOptionAlias].Add(commandLineArgument);
}
}
var commandName = commandNameBuilder.Length > 0 ? commandNameBuilder.ToString() : null;
var options = optionsDic.Select(p => new CommandOptionInput(p.Key, p.Value)).ToArray();
return new CommandInput(commandName, directives, options);
}
}
}

View File

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

View File

@@ -0,0 +1,185 @@
using System;
using System.Globalization;
using System.Linq;
using System.Reflection;
using CliFx.Exceptions;
using CliFx.Internal;
using CliFx.Models;
namespace CliFx.Services
{
/// <summary>
/// Default implementation of <see cref="ICommandOptionInputConverter"/>.
/// </summary>
public partial class CommandOptionInputConverter : ICommandOptionInputConverter
{
private readonly IFormatProvider _formatProvider;
/// <summary>
/// Initializes an instance of <see cref="CommandOptionInputConverter"/>.
/// </summary>
public CommandOptionInputConverter(IFormatProvider formatProvider)
{
_formatProvider = formatProvider.GuardNotNull(nameof(formatProvider));
}
/// <summary>
/// Initializes an instance of <see cref="CommandOptionInputConverter"/>.
/// </summary>
public CommandOptionInputConverter()
: this(CultureInfo.InvariantCulture)
{
}
private 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 value.IsNullOrWhiteSpace() || 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 = Nullable.GetUnderlyingType(targetType);
if (nullableUnderlyingType != null)
return !value.IsNullOrWhiteSpace() ? 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});
throw new CliFxException($"Can't convert value [{value}] to type [{targetType}].");
}
catch (Exception ex)
{
throw new CliFxException($"Can't convert value [{value}] to type [{targetType}].", ex);
}
}
/// <inheritdoc />
public object ConvertOptionInput(CommandOptionInput optionInput, Type targetType)
{
optionInput.GuardNotNull(nameof(optionInput));
targetType.GuardNotNull(nameof(targetType));
// Single value
if (optionInput.Values.Count <= 1)
{
var value = optionInput.Values.SingleOrDefault();
return ConvertValue(value, targetType);
}
// Multiple values
else
{
// Determine underlying type of elements inside the target collection type
var underlyingType = targetType.GetEnumerableUnderlyingType() ?? typeof(object);
// Convert values to that type
var convertedValues = optionInput.Values.Select(v => ConvertValue(v, underlyingType)).ToNonGenericArray(underlyingType);
var convertedValuesType = convertedValues.GetType();
// Assignable from array of values (e.g. T[], IReadOnlyList<T>, IEnumerable<T>)
if (targetType.IsAssignableFrom(convertedValuesType))
return convertedValues;
// Has a constructor that accepts an array of values (e.g. HashSet<T>, List<T>)
var arrayConstructor = targetType.GetConstructor(new[] {convertedValuesType});
if (arrayConstructor != null)
return arrayConstructor.Invoke(new object[] {convertedValues});
throw new CliFxException(
$"Can't convert sequence of values [{optionInput.Values.JoinToString(", ")}] to type [{targetType}].");
}
}
}
public partial class CommandOptionInputConverter
{
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);
}
}

View File

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

View File

@@ -1,107 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Internal;
namespace CliFx.Services
{
public class CommandResolver : ICommandResolver
{
private readonly ITypeProvider _typeProvider;
private readonly ICommandOptionParser _commandOptionParser;
private readonly ICommandOptionConverter _commandOptionConverter;
public CommandResolver(ITypeProvider typeProvider,
ICommandOptionParser commandOptionParser, ICommandOptionConverter commandOptionConverter)
{
_typeProvider = typeProvider;
_commandOptionParser = commandOptionParser;
_commandOptionConverter = commandOptionConverter;
}
private IEnumerable<CommandType> GetCommandTypes() => CommandType.GetCommandTypes(_typeProvider.GetTypes());
private CommandType GetDefaultCommandType()
{
// Get command types marked as default
var defaultCommandTypes = GetCommandTypes().Where(t => t.IsDefault).ToArray();
// If there's only one type - return
if (defaultCommandTypes.Length == 1)
return defaultCommandTypes.Single();
// If there are multiple - throw
if (defaultCommandTypes.Length > 1)
{
throw new CommandResolveException(
"Can't resolve default command because there is more than one command marked as default. " +
$"Make sure you apply {nameof(DefaultCommandAttribute)} only to one command.");
}
// If there aren't any - throw
throw new CommandResolveException(
"Can't resolve default command because there are no commands marked as default. " +
$"Apply {nameof(DefaultCommandAttribute)} to the default command.");
}
private CommandType GetCommandType(string name)
{
// Get command types with given name
var matchingCommandTypes =
GetCommandTypes().Where(t => string.Equals(t.Name, name, StringComparison.OrdinalIgnoreCase)).ToArray();
// If there's only one type - return
if (matchingCommandTypes.Length == 1)
return matchingCommandTypes.Single();
// If there are multiple - throw
if (matchingCommandTypes.Length > 1)
{
throw new CommandResolveException(
$"Can't resolve command because there is more than one command named [{name}]. " +
"Make sure all command names are unique and keep in mind that comparison is case-insensitive.");
}
// If there aren't any - throw
throw new CommandResolveException(
$"Can't resolve command because none of the commands is named [{name}]. " +
$"Apply {nameof(CommandAttribute)} to give command a name.");
}
public Command ResolveCommand(IReadOnlyList<string> commandLineArguments)
{
var optionSet = _commandOptionParser.ParseOptions(commandLineArguments);
// Get command type
var commandType = !optionSet.CommandName.IsNullOrWhiteSpace()
? GetCommandType(optionSet.CommandName)
: GetDefaultCommandType();
// Activate command
var command = commandType.Activate();
// Set command options
foreach (var property in commandType.GetOptionProperties())
{
// If option set contains this property - set value
if (optionSet.Options.TryGetValue(property.Name, out var value) ||
optionSet.Options.TryGetValue(property.ShortName.ToString(CultureInfo.InvariantCulture), out value))
{
var convertedValue = _commandOptionConverter.ConvertOption(value, property.Type);
property.SetValue(command, convertedValue);
}
// If the property is missing but it's required - throw
else if (property.IsRequired)
{
throw new CommandResolveException(
$"Can't resolve command [{optionSet.CommandName}] because required property [{property.Name}] is not set.");
}
}
return command;
}
}
}

View File

@@ -0,0 +1,123 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Internal;
using CliFx.Models;
namespace CliFx.Services
{
/// <summary>
/// Default implementation of <see cref="ICommandSchemaResolver"/>.
/// </summary>
public class CommandSchemaResolver : ICommandSchemaResolver
{
private IReadOnlyList<CommandOptionSchema> GetCommandOptionSchemas(Type commandType)
{
var result = new List<CommandOptionSchema>();
foreach (var property in commandType.GetProperties())
{
var attribute = property.GetCustomAttribute<CommandOptionAttribute>();
// 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);
// Make sure there are no other options with the same name
var existingOptionWithSameName = result
.Where(o => !o.Name.IsNullOrWhiteSpace())
.FirstOrDefault(o => string.Equals(o.Name, optionSchema.Name, StringComparison.OrdinalIgnoreCase));
if (existingOptionWithSameName != null)
{
throw new CliFxException(
$"Command type [{commandType}] has options defined with the same name: " +
$"[{existingOptionWithSameName.Property}] and [{optionSchema.Property}].");
}
// 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 options defined with the same short name: " +
$"[{existingOptionWithSameShortName.Property}] and [{optionSchema.Property}].");
}
// Add schema to list
result.Add(optionSchema);
}
return result;
}
/// <inheritdoc />
public IReadOnlyList<CommandSchema> GetCommandSchemas(IReadOnlyList<Type> commandTypes)
{
commandTypes.GuardNotNull(nameof(commandTypes));
// Make sure there's at least one command defined
if (!commandTypes.Any())
{
throw new CliFxException("There are no commands defined.");
}
var result = new List<CommandSchema>();
foreach (var commandType in commandTypes)
{
// Make sure command type implements ICommand.
if (!commandType.Implements(typeof(ICommand)))
{
throw new CliFxException($"Command type [{commandType}] must implement {typeof(ICommand)}.");
}
// Get attribute
var attribute = commandType.GetCustomAttribute<CommandAttribute>();
// Make sure attribute is set
if (attribute == null)
{
throw new CliFxException($"Command type [{commandType}] must be annotated with [{typeof(CommandAttribute)}].");
}
// Get option schemas
var optionSchemas = GetCommandOptionSchemas(commandType);
// Build command schema
var commandSchema = new CommandSchema(commandType,
attribute.Name,
attribute.Description,
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}].");
}
// Add schema to list
result.Add(commandSchema);
}
return result;
}
}
}

View File

@@ -0,0 +1,29 @@
using System;
using CliFx.Internal;
using CliFx.Models;
namespace CliFx.Services
{
/// <summary>
/// Implementation of <see cref="ICommandFactory"/> that uses a factory method to create commands.
/// </summary>
public class DelegateCommandFactory : ICommandFactory
{
private readonly Func<CommandSchema, ICommand> _factoryMethod;
/// <summary>
/// Initializes an instance of <see cref="DelegateCommandFactory"/>.
/// </summary>
public DelegateCommandFactory(Func<CommandSchema, ICommand> factoryMethod)
{
_factoryMethod = factoryMethod.GuardNotNull(nameof(factoryMethod));
}
/// <inheritdoc />
public ICommand CreateCommand(CommandSchema commandSchema)
{
commandSchema.GuardNotNull(nameof(commandSchema));
return _factoryMethod(commandSchema);
}
}
}

View File

@@ -1,7 +1,54 @@
namespace CliFx.Services using System;
using CliFx.Internal;
namespace CliFx.Services
{ {
/// <summary>
/// Extensions for <see cref="Services"/>
/// </summary>
public static class Extensions public static class Extensions
{ {
public static Command ResolveCommand(this ICommandResolver commandResolver) => commandResolver.ResolveCommand(new string[0]); /// <summary>
/// Sets console foreground color, executes specified action, and sets the color back to the original value.
/// </summary>
public static void WithForegroundColor(this IConsole console, ConsoleColor foregroundColor, Action action)
{
console.GuardNotNull(nameof(console));
action.GuardNotNull(nameof(action));
var lastColor = console.ForegroundColor;
console.ForegroundColor = foregroundColor;
action();
console.ForegroundColor = lastColor;
}
/// <summary>
/// Sets console background color, executes specified action, and sets the color back to the original value.
/// </summary>
public static void WithBackgroundColor(this IConsole console, ConsoleColor backgroundColor, Action action)
{
console.GuardNotNull(nameof(console));
action.GuardNotNull(nameof(action));
var lastColor = console.BackgroundColor;
console.BackgroundColor = backgroundColor;
action();
console.BackgroundColor = lastColor;
}
/// <summary>
/// Sets console foreground and background colors, executes specified action, and sets the colors back to the original values.
/// </summary>
public static void WithColors(this IConsole console, ConsoleColor foregroundColor, ConsoleColor backgroundColor, Action action)
{
console.GuardNotNull(nameof(console));
action.GuardNotNull(nameof(action));
console.WithForegroundColor(foregroundColor, () => console.WithBackgroundColor(backgroundColor, action));
}
} }
} }

Some files were not shown because too many files have changed in this diff Show More