90 Commits
0.0.1 ... 0.0.2

Author SHA1 Message Date
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
110 changed files with 4824 additions and 1054 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

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp2.2</TargetFramework>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="2.2.0" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CliFx\CliFx.csproj" />
</ItemGroup>
</Project>

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

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

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

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

View File

@@ -1,25 +1,25 @@
using System; using System;
using System.Globalization; using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Models; using CliFx.Services;
namespace CliFx.Tests.Dummy.Commands namespace CliFx.Tests.Dummy.Commands
{ {
[Command("log")] [Command("log", Description = "Calculate the logarithm of a value.")]
public class LogCommand : Command public class LogCommand : ICommand
{ {
[CommandOption("value", IsRequired = true, Description = "Value whose logarithm is to be found.")] [CommandOption("value", 'v', IsRequired = true, Description = "Value whose logarithm is to be found.")]
public double Value { get; set; } public double Value { get; set; }
[CommandOption("base", Description = "Logarithm base.")] [CommandOption("base", 'b', Description = "Logarithm base.")]
public double Base { get; set; } = 10; public double Base { get; set; } = 10;
public override ExitCode Execute() public Task ExecuteAsync(IConsole console)
{ {
var result = Math.Log(Value, Base); var result = Math.Log(Value, Base);
Console.WriteLine(result.ToString(CultureInfo.InvariantCulture)); console.Output.WriteLine(result);
return ExitCode.Success; return Task.CompletedTask;
} }
} }
} }

View File

@@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
namespace CliFx.Tests.Dummy.Commands
{
[Command("sum", Description = "Calculate the sum of all input values.")]
public class SumCommand : ICommand
{
[CommandOption("values", 'v', IsRequired = true, Description = "Input values.")]
public IReadOnlyList<double> Values { get; set; }
public Task ExecuteAsync(IConsole console)
{
var result = Values.Sum();
console.Output.WriteLine(result);
return Task.CompletedTask;
}
}
}

View File

@@ -1,9 +1,21 @@
using System.Threading.Tasks; using System.Globalization;
using System.Threading.Tasks;
namespace CliFx.Tests.Dummy namespace CliFx.Tests.Dummy
{ {
public static class Program public static class Program
{ {
public static Task<int> Main(string[] args) => new CliApplication().RunAsync(args); public static Task<int> Main(string[] args)
{
// Set culture to invariant to maintain consistent format because we rely on it in tests
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
return new CliApplicationBuilder()
.AddCommandsFromThisAssembly()
.UseDescription("Dummy program used for E2E tests.")
.Build()
.RunAsync(args);
}
} }
} }

View File

@@ -0,0 +1,53 @@
using System;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Services;
namespace CliFx.Tests
{
public partial class CliApplicationTests
{
[Command]
private class DefaultCommand : ICommand
{
public Task ExecuteAsync(IConsole console)
{
console.Output.WriteLine("DefaultCommand executed.");
return Task.CompletedTask;
}
}
[Command("cmd")]
private class NamedCommand : ICommand
{
public Task ExecuteAsync(IConsole console)
{
console.Output.WriteLine("NamedCommand executed.");
return Task.CompletedTask;
}
}
}
// Negative
public partial class CliApplicationTests
{
[Command("faulty1")]
private class FaultyCommand1 : ICommand
{
public Task ExecuteAsync(IConsole console) => throw new CommandException(150);
}
[Command("faulty2")]
private class FaultyCommand2 : ICommand
{
public Task ExecuteAsync(IConsole console) => throw new CommandException("FaultyCommand2 error message.", 150);
}
[Command("faulty3")]
private class FaultyCommand3 : ICommand
{
public Task ExecuteAsync(IConsole console) => throw new Exception("FaultyCommand3 error message.");
}
}
}

View File

@@ -1,33 +1,174 @@
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 FluentAssertions;
using Moq;
using NUnit.Framework; using NUnit.Framework;
namespace CliFx.Tests namespace CliFx.Tests
{ {
[TestFixture] [TestFixture]
public class CliApplicationTests public partial class CliApplicationTests
{ {
private static IEnumerable<TestCaseData> GetTestCases_RunAsync()
{
yield return new TestCaseData(
new[] {typeof(DefaultCommand)},
new string[0],
"DefaultCommand executed."
);
yield return new TestCaseData(
new[] {typeof(NamedCommand)},
new[] {"cmd"},
"NamedCommand executed."
);
}
private static IEnumerable<TestCaseData> GetTestCases_HelpAndVersion_RunAsync()
{
yield return new TestCaseData(
new[] {typeof(DefaultCommand)},
new string[0]
);
yield return new TestCaseData(
new[] {typeof(DefaultCommand)},
new[] {"-h"}
);
yield return new TestCaseData(
new[] {typeof(DefaultCommand)},
new[] {"--help"}
);
yield return new TestCaseData(
new[] {typeof(DefaultCommand)},
new[] {"--version"}
);
yield return new TestCaseData(
new[] {typeof(NamedCommand)},
new string[0]
);
yield return new TestCaseData(
new[] {typeof(NamedCommand)},
new[] {"cmd", "-h"}
);
yield return new TestCaseData(
new[] {typeof(FaultyCommand1)},
new[] {"faulty1", "-h"}
);
yield return new TestCaseData(
new[] {typeof(FaultyCommand2)},
new[] {"faulty2", "-h"}
);
yield return new TestCaseData(
new[] {typeof(FaultyCommand3)},
new[] {"faulty3", "-h"}
);
}
private static IEnumerable<TestCaseData> GetTestCases_RunAsync_Negative()
{
yield return new TestCaseData(
new Type[0],
new string[0]
);
yield return new TestCaseData(
new[] {typeof(DefaultCommand)},
new[] {"non-existing"}
);
yield return new TestCaseData(
new[] {typeof(FaultyCommand1)},
new[] {"faulty1"}
);
yield return new TestCaseData(
new[] {typeof(FaultyCommand2)},
new[] {"faulty2"}
);
yield return new TestCaseData(
new[] {typeof(FaultyCommand3)},
new[] {"faulty3"}
);
}
[Test] [Test]
public async Task RunAsync_Test() [TestCaseSource(nameof(GetTestCases_RunAsync))]
public async Task RunAsync_Test(IReadOnlyList<Type> commandTypes, IReadOnlyList<string> commandLineArguments, string expectedStdOut)
{ {
// Arrange // Arrange
var command = new TestCommand(); using (var stdout = new StringWriter())
var expectedExitCode = await command.ExecuteAsync(); {
var console = new VirtualConsole(stdout);
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; .UseConsole(console)
.Build();
var application = new CliApplication(commandResolver); // Act
var exitCode = await application.RunAsync(commandLineArguments);
// Act // Assert
var exitCodeValue = await application.RunAsync(); exitCode.Should().Be(0);
stdout.ToString().Trim().Should().Be(expectedStdOut);
}
}
// Assert [Test]
Assert.That(exitCodeValue, Is.EqualTo(expectedExitCode.Value)); [TestCaseSource(nameof(GetTestCases_HelpAndVersion_RunAsync))]
public async Task RunAsync_HelpAndVersion_Test(IReadOnlyList<Type> commandTypes, IReadOnlyList<string> commandLineArguments)
{
// Arrange
using (var stdout = new StringWriter())
{
var console = new VirtualConsole(stdout);
var application = new CliApplicationBuilder()
.AddCommands(commandTypes)
.UseConsole(console)
.Build();
// Act
var exitCode = await application.RunAsync(commandLineArguments);
// Assert
exitCode.Should().Be(0);
stdout.ToString().Should().NotBeNullOrWhiteSpace();
}
}
[Test]
[TestCaseSource(nameof(GetTestCases_RunAsync_Negative))]
public async Task RunAsync_Negative_Test(IReadOnlyList<Type> commandTypes, IReadOnlyList<string> commandLineArguments)
{
// Arrange
using (var stderr = new StringWriter())
{
var console = new VirtualConsole(TextWriter.Null, stderr);
var application = new CliApplicationBuilder()
.AddCommands(commandTypes)
.UseConsole(console)
.Build();
// Act
var exitCode = await application.RunAsync(commandLineArguments);
// Assert
exitCode.Should().NotBe(0);
stderr.ToString().Should().NotBeNullOrWhiteSpace();
}
} }
} }
} }

View File

@@ -1,18 +1,25 @@
<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="CliWrap" Version="2.3.1" />
<PackageReference Include="coverlet.msbuild" Version="2.6.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,15 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
namespace CliFx.Tests
{
public partial class CommandFactoryTests
{
[Command]
private class TestCommand : ICommand
{
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,36 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CliFx.Models;
using CliFx.Services;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests
{
[TestFixture]
public partial 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(TestCommand)));
}
[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,21 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
namespace CliFx.Tests
{
public partial class CommandInitializerTests
{
[Command]
private class TestCommand : ICommand
{
[CommandOption("int", 'i', IsRequired = true)]
public int IntOption { get; set; } = 24;
[CommandOption("str", 's')]
public string StringOption { get; set; } = "foo bar";
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,96 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CliFx.Exceptions;
using CliFx.Models;
using CliFx.Services;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests
{
[TestFixture]
public partial 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 TestCommand(),
GetCommandSchema(typeof(TestCommand)),
new CommandInput(new[]
{
new CommandOptionInput("int", "13")
}),
new TestCommand {IntOption = 13}
);
yield return new TestCaseData(
new TestCommand(),
GetCommandSchema(typeof(TestCommand)),
new CommandInput(new[]
{
new CommandOptionInput("int", "13"),
new CommandOptionInput("str", "hello world")
}),
new TestCommand {IntOption = 13, StringOption = "hello world"}
);
yield return new TestCaseData(
new TestCommand(),
GetCommandSchema(typeof(TestCommand)),
new CommandInput(new[]
{
new CommandOptionInput("i", "13")
}),
new TestCommand {IntOption = 13}
);
}
private static IEnumerable<TestCaseData> GetTestCases_InitializeCommand_Negative()
{
yield return new TestCaseData(
new TestCommand(),
GetCommandSchema(typeof(TestCommand)),
CommandInput.Empty
);
yield return new TestCaseData(
new TestCommand(),
GetCommandSchema(typeof(TestCommand)),
new CommandInput(new[]
{
new CommandOptionInput("str", "hello world")
})
);
}
[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<MissingCommandOptionInputException>();
}
}
}

View File

@@ -0,0 +1,184 @@
using System.Collections.Generic;
using CliFx.Models;
using CliFx.Services;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests
{
[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")
})
);
}
[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

@@ -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

@@ -0,0 +1,63 @@
using System;
namespace CliFx.Tests
{
public partial class CommandOptionInputConverterTests
{
private enum TestEnum
{
Value1,
Value2,
Value3
}
private class TestStringConstructable
{
public string Value { get; }
public TestStringConstructable(string value)
{
Value = value;
}
}
private class TestStringParseable
{
public string Value { get; }
private TestStringParseable(string value)
{
Value = value;
}
public static TestStringParseable Parse(string value) => new TestStringParseable(value);
}
private class TestStringParseableWithFormatProvider
{
public string Value { get; }
private TestStringParseableWithFormatProvider(string value)
{
Value = value;
}
public static TestStringParseableWithFormatProvider Parse(string value, IFormatProvider formatProvider) =>
new TestStringParseableWithFormatProvider(value + " " + formatProvider);
}
}
// Negative
public partial class CommandOptionInputConverterTests
{
private class NonStringParseable
{
public int Value { get; }
public NonStringParseable(int value)
{
Value = value;
}
}
}
}

View File

@@ -0,0 +1,305 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using CliFx.Exceptions;
using CliFx.Models;
using CliFx.Services;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests
{
[TestFixture]
public partial 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(NonStringParseable)
);
}
[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<InvalidCommandOptionInputException>();
}
}
}

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

@@ -0,0 +1,81 @@
using System;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
namespace CliFx.Tests
{
public partial class CommandSchemaResolverTests
{
[Command("cmd", Description = "NormalCommand1 description.")]
private class NormalCommand1 : ICommand
{
[CommandOption("option-a", 'a')]
public int OptionA { get; set; }
[CommandOption("option-b", IsRequired = true)]
public string OptionB { get; set; }
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
}
[Command(Description = "NormalCommand2 description.")]
private class NormalCommand2 : ICommand
{
[CommandOption("option-c", Description = "OptionC description.")]
public bool OptionC { get; set; }
[CommandOption("option-d", 'd')]
public DateTimeOffset OptionD { get; set; }
public string NotAnOption { get; set; }
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
}
}
// Negative
public partial class CommandSchemaResolverTests
{
[Command("conflict")]
private class ConflictingCommand1 : ICommand
{
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
}
[Command("conflict")]
private class ConflictingCommand2 : ICommand
{
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
}
[Command]
private class InvalidCommand1
{
}
[Command]
private class InvalidCommand2 : ICommand
{
[CommandOption("conflict")]
public string ConflictingOption1 { get; set; }
[CommandOption("conflict")]
public string ConflictingOption2 { get; set; }
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
}
[Command]
private class InvalidCommand3 : ICommand
{
[CommandOption('c')]
public string ConflictingOption1 { get; set; }
[CommandOption('c')]
public string ConflictingOption2 { get; set; }
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using CliFx.Exceptions;
using CliFx.Models;
using CliFx.Services;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests
{
[TestFixture]
public partial class CommandSchemaResolverTests
{
private static IEnumerable<TestCaseData> GetTestCases_GetCommandSchemas()
{
yield return new TestCaseData(
new[] {typeof(NormalCommand1), typeof(NormalCommand2)},
new[]
{
new CommandSchema(typeof(NormalCommand1), "cmd", "NormalCommand1 description.",
new[]
{
new CommandOptionSchema(typeof(NormalCommand1).GetProperty(nameof(NormalCommand1.OptionA)),
"option-a", 'a', false, null),
new CommandOptionSchema(typeof(NormalCommand1).GetProperty(nameof(NormalCommand1.OptionB)),
"option-b", null, true, null)
}),
new CommandSchema(typeof(NormalCommand2), null, "NormalCommand2 description.",
new[]
{
new CommandOptionSchema(typeof(NormalCommand2).GetProperty(nameof(NormalCommand2.OptionC)),
"option-c", null, false, "OptionC description."),
new CommandOptionSchema(typeof(NormalCommand2).GetProperty(nameof(NormalCommand2.OptionD)),
"option-d", 'd', false, null)
})
}
);
}
private static IEnumerable<TestCaseData> GetTestCases_GetCommandSchemas_Negative()
{
yield return new TestCaseData(new object[]
{
new Type[0]
});
yield return new TestCaseData(new object[]
{
new[] {typeof(ConflictingCommand1), typeof(ConflictingCommand2)}
});
yield return new TestCaseData(new object[]
{
new[] {typeof(InvalidCommand1)}
});
yield return new TestCaseData(new object[]
{
new[] {typeof(InvalidCommand2)}
});
yield return new TestCaseData(new object[]
{
new[] {typeof(InvalidCommand3)}
});
}
[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<InvalidCommandSchemaException>();
}
}
}

View File

@@ -0,0 +1,15 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
namespace CliFx.Tests
{
public partial class DelegateCommandFactoryTests
{
[Command]
private class TestCommand : ICommand
{
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CliFx.Models;
using CliFx.Services;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests
{
[TestFixture]
public partial 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(TestCommand))
);
}
[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

@@ -1,6 +1,6 @@
using System.IO; using System.Threading.Tasks;
using System.Threading.Tasks;
using CliWrap; using CliWrap;
using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
namespace CliFx.Tests namespace CliFx.Tests
@@ -8,25 +8,66 @@ namespace CliFx.Tests
[TestFixture] [TestFixture]
public class DummyTests public class DummyTests
{ {
private string DummyFilePath => Path.Combine(TestContext.CurrentContext.TestDirectory, "CliFx.Tests.Dummy.exe"); private static string DummyFilePath => typeof(Dummy.Program).Assembly.Location;
private static string DummyVersionText => typeof(Dummy.Program).Assembly.GetName().Version.ToString();
[Test] [Test]
[TestCase("", "Hello world")] [TestCase("", "Hello world")]
[TestCase("-t .NET", "Hello .NET")] [TestCase("-t .NET", "Hello .NET")]
[TestCase("-e", "Hello world!!!")] [TestCase("-e", "Hello world!")]
[TestCase("add --a 1 --b 2", "3")] [TestCase("sum -v 1 2", "3")]
[TestCase("add --a 2.75 --b 3.6", "6.35")] [TestCase("sum -v 2.75 3.6 4.18", "10.53")]
[TestCase("log --value 100", "2")] [TestCase("sum -v 4 -v 16", "20")]
[TestCase("sum --values 2 5 --values 3", "10")]
[TestCase("log -v 100", "2")]
[TestCase("log --value 256 --base 2", "8")] [TestCase("log --value 256 --base 2", "8")]
public async Task Execute_Test(string arguments, string expectedOutput) public async Task CliApplication_RunAsync_Test(string arguments, string expectedOutput)
{ {
// Act // Arrange & Act
var result = await Cli.Wrap(DummyFilePath).SetArguments(arguments).ExecuteAsync(); var result = await Cli.Wrap(DummyFilePath)
.SetArguments(arguments)
.EnableExitCodeValidation()
.EnableStandardErrorValidation()
.ExecuteAsync();
// Assert // Assert
Assert.That(result.ExitCode, Is.Zero); result.StandardOutput.Trim().Should().Be(expectedOutput);
Assert.That(result.StandardOutput.Trim(), Is.EqualTo(expectedOutput)); }
Assert.That(result.StandardError.Trim(), Is.Empty);
[Test]
[TestCase("--version")]
public async Task CliApplication_RunAsync_ShowVersion_Test(string arguments)
{
// Arrange & Act
var result = await Cli.Wrap(DummyFilePath)
.SetArguments(arguments)
.EnableExitCodeValidation()
.EnableStandardErrorValidation()
.ExecuteAsync();
// Assert
result.StandardOutput.Trim().Should().Be(DummyVersionText);
}
[Test]
[TestCase("--help")]
[TestCase("-h")]
[TestCase("sum -h")]
[TestCase("sum --help")]
[TestCase("log -h")]
[TestCase("log --help")]
public async Task CliApplication_RunAsync_ShowHelp_Test(string arguments)
{
// Arrange & Act
var result = await Cli.Wrap(DummyFilePath)
.SetArguments(arguments)
.EnableExitCodeValidation()
.EnableStandardErrorValidation()
.ExecuteAsync();
// Assert
result.StandardOutput.Trim().Should().NotBeNullOrWhiteSpace();
} }
} }
} }

View File

@@ -0,0 +1,42 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
namespace CliFx.Tests
{
public partial class HelpTextRendererTests
{
[Command(Description = "DefaultCommand description.")]
private class DefaultCommand : 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;
}
[Command("cmd", Description = "NamedCommand description.")]
private class NamedCommand : 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;
}
[Command("cmd sub", Description = "NamedSubCommand description.")]
private class NamedSubCommand : 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,105 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using CliFx.Models;
using CliFx.Services;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests
{
[TestFixture]
public partial 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(DefaultCommand), typeof(NamedCommand), typeof(NamedSubCommand)},
typeof(DefaultCommand)),
new[]
{
"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", "NamedCommand description.",
"You can run", "to show help on a specific command."
}
);
yield return new TestCaseData(
CreateHelpTextSource(
new[] {typeof(DefaultCommand), typeof(NamedCommand), typeof(NamedSubCommand)},
typeof(NamedCommand)),
new[]
{
"Description",
"NamedCommand description.",
"Usage",
"cmd", "[command]", "[options]",
"Options",
"-c|--option-c", "OptionC description.",
"-d|--option-d", "OptionD description.",
"-h|--help", "Shows help text.",
"Commands",
"sub", "NamedSubCommand description.",
"You can run", "to show help on a specific command."
}
);
yield return new TestCaseData(
CreateHelpTextSource(
new[] {typeof(DefaultCommand), typeof(NamedCommand), typeof(NamedSubCommand)},
typeof(NamedSubCommand)),
new[]
{
"Description",
"NamedSubCommand 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 renderer = new HelpTextRenderer();
var console = new VirtualConsole(stdout);
// Act
renderer.RenderHelpText(console, source);
// Assert
stdout.ToString().Should().ContainAll(expectedSubstrings);
}
}
}
}

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

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

View File

@@ -16,6 +16,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("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "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
@@ -62,6 +66,30 @@ Global
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Release|x64.Build.0 = Release|Any CPU {4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Release|x64.Build.0 = Release|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Release|x86.ActiveCfg = Release|Any CPU {4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Release|x86.ActiveCfg = Release|Any CPU
{4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Release|x86.Build.0 = Release|Any CPU {4904B3EB-3286-4F1B-8B74-6FF051C8E787}.Release|x86.Build.0 = Release|Any CPU
{8ACD6DC2-D768-4850-9223-5B7C83A78513}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8ACD6DC2-D768-4850-9223-5B7C83A78513}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8ACD6DC2-D768-4850-9223-5B7C83A78513}.Debug|x64.ActiveCfg = Debug|Any CPU
{8ACD6DC2-D768-4850-9223-5B7C83A78513}.Debug|x64.Build.0 = Debug|Any CPU
{8ACD6DC2-D768-4850-9223-5B7C83A78513}.Debug|x86.ActiveCfg = Debug|Any CPU
{8ACD6DC2-D768-4850-9223-5B7C83A78513}.Debug|x86.Build.0 = Debug|Any CPU
{8ACD6DC2-D768-4850-9223-5B7C83A78513}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8ACD6DC2-D768-4850-9223-5B7C83A78513}.Release|Any CPU.Build.0 = Release|Any CPU
{8ACD6DC2-D768-4850-9223-5B7C83A78513}.Release|x64.ActiveCfg = Release|Any CPU
{8ACD6DC2-D768-4850-9223-5B7C83A78513}.Release|x64.Build.0 = Release|Any CPU
{8ACD6DC2-D768-4850-9223-5B7C83A78513}.Release|x86.ActiveCfg = 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,144 @@
using System.Collections.Generic; using System;
using System.Reflection; using System.Collections.Generic;
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;
_commandResolver = commandResolver; private readonly ICommandSchemaResolver _commandSchemaResolver;
} private readonly ICommandFactory _commandFactory;
private readonly ICommandInitializer _commandInitializer;
public CliApplication() private readonly IHelpTextRenderer _helpTextRenderer;
: this(GetDefaultCommandResolver(Assembly.GetCallingAssembly()))
/// <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)
{ {
_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));
} }
/// <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
{
// Get schemas for all available command types
var availableCommandSchemas = _commandSchemaResolver.GetCommandSchemas(_configuration.CommandTypes);
return exitCode.Value; // Parse command input from arguments
} var commandInput = _commandInputParser.ParseCommandInput(commandLineArguments);
}
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); // Handle cases where requested command is not defined
if (targetCommandSchema == null)
{
var isError = false;
// If specified a command - show error
if (commandInput.IsCommandSpecified())
{
isError = true;
_console.WithForegroundColor(ConsoleColor.Red,
() => _console.Error.WriteLine($"Specified command [{commandInput.CommandName}] is not defined."));
}
// Get parent command schema
var parentCommandSchema = availableCommandSchemas.FindParent(commandInput.CommandName);
// Show help for parent command if it's defined
if (parentCommandSchema != null)
{
var helpTextSource = new HelpTextSource(_metadata, availableCommandSchemas, parentCommandSchema);
_helpTextRenderer.RenderHelpText(_console, helpTextSource);
}
// Otherwise show help for a stub default command
else
{
var helpTextSource = new HelpTextSource(_metadata,
availableCommandSchemas.Concat(CommandSchema.StubDefaultCommand).ToArray(),
CommandSchema.StubDefaultCommand);
_helpTextRenderer.RenderHelpText(_console, helpTextSource);
}
return isError ? -1 : 0;
}
// Show version if it was requested without specifying a command
if (commandInput.IsVersionRequested() && !commandInput.IsCommandSpecified())
{
_console.Output.WriteLine(_metadata.VersionText);
return 0;
}
// Show help if it was requested
if (commandInput.IsHelpRequested())
{
var helpTextSource = new HelpTextSource(_metadata, availableCommandSchemas, targetCommandSchema);
_helpTextRenderer.RenderHelpText(_console, helpTextSource);
return 0;
}
// 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);
return 0;
}
catch (Exception ex)
{
// We want to catch exceptions in order to print errors and return correct exit codes.
// Also, by doing this we get rid of the annoying Windows troubleshooting dialog that shows up on unhandled exceptions.
// In case we catch a CliFx-specific exception, we want to just show the error message, not the stack trace.
// Stack trace isn't very useful to the user if the exception is not really coming from their code.
// CommandException is the same, but it also lets users specify exit code so we want to return that instead of default.
var message = ex is CliFxException && !ex.Message.IsNullOrWhiteSpace() ? ex.Message : ex.ToString();
var exitCode = ex is CommandException commandEx ? commandEx.ExitCode : ex.HResult;
_console.WithForegroundColor(ConsoleColor.Red, () => _console.Error.WriteLine(message));
return exitCode;
}
} }
} }
} }

View File

@@ -0,0 +1,164 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
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 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)));
foreach (var commandType in commandTypes)
AddCommand(commandType);
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;
}
private void SetFallbackValues()
{
if (_title.IsNullOrWhiteSpace())
{
// Entry assembly is null in tests
UseTitle(EntryAssembly?.GetName().Name ?? "App");
}
if (_executableName.IsNullOrWhiteSpace())
{
// Entry assembly is null in tests
var entryAssemblyLocation = EntryAssembly?.Location;
// Set different executable name depending on location
if (!entryAssemblyLocation.IsNullOrWhiteSpace())
{
// Prepend 'dotnet' to assembly file name if the entry assembly is a dll file (extension needs to be kept)
if (string.Equals(Path.GetExtension(entryAssemblyLocation), ".dll", StringComparison.OrdinalIgnoreCase))
{
UseExecutableName("dotnet " + Path.GetFileName(entryAssemblyLocation));
}
// Otherwise just use assembly file name without extension
else
{
UseExecutableName(Path.GetFileNameWithoutExtension(entryAssemblyLocation));
}
}
// If location is null then just use a stub
else
{
UseExecutableName("app");
}
}
if (_versionText.IsNullOrWhiteSpace())
{
// Entry assembly is null in tests
UseVersionText(EntryAssembly?.GetName().Version.ToString() ?? "1.0");
}
if (_console == null)
{
UseConsole(new SystemConsole());
}
if (_commandFactory == null)
{
UseCommandFactory(new CommandFactory());
}
}
/// <inheritdoc />
public ICliApplication Build()
{
// Use defaults for required parameters that were not configured
SetFallbackValues();
// Project parameters to expected types
var metadata = new ApplicationMetadata(_title, _executableName, _versionText, _description);
var configuration = new ApplicationConfiguration(_commandTypes.ToArray());
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);
private static Assembly EntryAssembly => LazyEntryAssembly.Value;
}
}

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.2</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 abstract class CliFxException : Exception
{
/// <summary>
/// Initializes an instance of <see cref="CliFxException"/>.
/// </summary>
protected CliFxException(string message)
: base(message)
{
}
/// <summary>
/// Initializes an instance of <see cref="CliFxException"/>.
/// </summary>
protected 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 : CliFxException
{
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

@@ -0,0 +1,26 @@
using System;
namespace CliFx.Exceptions
{
/// <summary>
/// Thrown when a command option can't be converted to target type specified in its schema.
/// </summary>
public class InvalidCommandOptionInputException : CliFxException
{
/// <summary>
/// Initializes an instance of <see cref="InvalidCommandOptionInputException"/>.
/// </summary>
public InvalidCommandOptionInputException(string message)
: base(message)
{
}
/// <summary>
/// Initializes an instance of <see cref="InvalidCommandOptionInputException"/>.
/// </summary>
public InvalidCommandOptionInputException(string message, Exception innerException)
: base(message, innerException)
{
}
}
}

View File

@@ -0,0 +1,26 @@
using System;
namespace CliFx.Exceptions
{
/// <summary>
/// Thrown when a command schema fails validation.
/// </summary>
public class InvalidCommandSchemaException : CliFxException
{
/// <summary>
/// Initializes an instance of <see cref="InvalidCommandSchemaException"/>.
/// </summary>
public InvalidCommandSchemaException(string message)
: base(message)
{
}
/// <summary>
/// Initializes an instance of <see cref="InvalidCommandSchemaException"/>.
/// </summary>
public InvalidCommandSchemaException(string message, Exception innerException)
: base(message, innerException)
{
}
}
}

View File

@@ -0,0 +1,26 @@
using System;
namespace CliFx.Exceptions
{
/// <summary>
/// Thrown when a required command option was not set.
/// </summary>
public class MissingCommandOptionInputException : CliFxException
{
/// <summary>
/// Initializes an instance of <see cref="MissingCommandOptionInputException"/>.
/// </summary>
public MissingCommandOptionInputException(string message)
: base(message)
{
}
/// <summary>
/// Initializes an instance of <see cref="MissingCommandOptionInputException"/>.
/// </summary>
public MissingCommandOptionInputException(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,58 @@
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>
/// 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,60 @@ 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);
public static string AsString(this char c) => c.Repeat(1);
public static string JoinToString<T>(this IEnumerable<T> source, string separator) => string.Join(separator, source);
public static string SubstringUntilLast(this string s, string sub,
StringComparison comparison = StringComparison.Ordinal)
{ {
var index = s.IndexOf(sub, comparison); var index = s.LastIndexOf(sub, comparison);
return index < 0 ? s : s.Substring(0, index); return index < 0 ? s : s.Substring(0, index);
} }
public static string SubstringAfter(this string s, string sub, StringComparison comparison = StringComparison.Ordinal) public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) =>
{ builder.Length > 0 ? builder.Append(value) : builder;
var index = s.IndexOf(sub, comparison);
return index < 0 ? string.Empty : s.Substring(index + sub.Length, s.Length - index - sub.Length);
}
public static TValue GetValueOrDefault<TKey, TValue>(this IReadOnlyDictionary<TKey, TValue> dic, TKey key) => public static TValue GetValueOrDefault<TKey, TValue>(this IReadOnlyDictionary<TKey, TValue> dic, TKey key) =>
dic.TryGetValue(key, out var result) ? result : default; dic.TryGetValue(key, out var result) ? result : default;
public static string TrimStart(this string s, string sub, StringComparison comparison = StringComparison.Ordinal) public static IEnumerable<T> ExceptNull<T>(this IEnumerable<T> source) where T : class => source.Where(i => i != null);
{
while (s.StartsWith(sub, comparison))
s = s.Substring(sub.Length);
return s; public static IEnumerable<T> Concat<T>(this IEnumerable<T> source, T value)
{
foreach (var i in source)
yield return i;
yield return value;
} }
public static string TrimEnd(this string s, string sub, StringComparison comparison = StringComparison.Ordinal) public static bool Implements(this Type type, Type interfaceType) => type.GetInterfaces().Contains(interfaceType);
{
while (s.EndsWith(sub, comparison))
s = s.Substring(0, s.Length - sub.Length);
return s; 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)
.ExceptNull()
.OrderByDescending(t => t != typeof(object)) // prioritize more specific types
.FirstOrDefault();
} }
public static string JoinToString<T>(this IEnumerable<T> source, string separator) => string.Join(separator, source); public static Array ToNonGenericArray<T>(this IEnumerable<T> source, Type elementType)
public static bool IsDerivedFrom(this Type type, Type baseType)
{ {
var currentType = type; var sourceAsCollection = source as ICollection ?? source.ToArray();
while (currentType != null)
{
if (currentType == baseType)
return true;
currentType = currentType.BaseType; var array = Array.CreateInstance(elementType, sourceAsCollection.Count);
} sourceAsCollection.CopyTo(array, 0);
return false; 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,25 @@
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 the application.
/// </summary>
public IReadOnlyList<Type> CommandTypes { get; }
/// <summary>
/// Initializes an instance of <see cref="ApplicationConfiguration"/>.
/// </summary>
public ApplicationConfiguration(IReadOnlyList<Type> commandTypes)
{
CommandTypes = commandTypes.GuardNotNull(nameof(commandTypes));
}
}
}

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,81 @@
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 options.
/// </summary>
public IReadOnlyList<CommandOptionInput> Options { get; }
/// <summary>
/// Initializes an instance of <see cref="CommandInput"/>.
/// </summary>
public CommandInput(string commandName, IReadOnlyList<CommandOptionInput> options)
{
CommandName = commandName; // can be null
Options = options.GuardNotNull(nameof(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, new CommandOptionInput[0])
{
}
/// <summary>
/// Initializes an instance of <see cref="CommandInput"/>.
/// </summary>
public CommandInput()
: this(null, new CommandOptionInput[0])
{
}
/// <inheritdoc />
public override string ToString()
{
var buffer = new StringBuilder();
if (!CommandName.IsNullOrWhiteSpace())
buffer.Append(CommandName);
foreach (var option in Options)
{
buffer.AppendIfNotEmpty(' ');
buffer.Append(option);
}
return buffer.ToString();
}
}
public partial class CommandInput
{
/// <summary>
/// Empty input.
/// </summary>
public static CommandInput Empty { get; } = new CommandInput();
}
}

View File

@@ -0,0 +1,73 @@
using System.Collections.Generic;
using System.Text;
using CliFx.Internal;
namespace CliFx.Models
{
/// <summary>
/// Parsed option from command line input.
/// </summary>
public 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, new string[0])
{
}
/// <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();
}
}
}

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);
}
}

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

@@ -0,0 +1,144 @@
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 help was requested in the input.
/// </summary>
public static bool IsHelpRequested(this CommandInput commandInput)
{
commandInput.GuardNotNull(nameof(commandInput));
var firstOption = commandInput.Options.FirstOrDefault();
return firstOption != null && CommandOptionSchema.HelpOption.MatchesAlias(firstOption.Alias);
}
/// <summary>
/// Gets whether version information was requested in the input.
/// </summary>
public static bool IsVersionRequested(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 MissingCommandOptionInputException($"One or more required options were not set: {unsetRequiredOptionNames}.");
}
}
}
}

View File

@@ -0,0 +1,71 @@
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 optionsDic = new Dictionary<string, List<string>>();
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 thereof
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 command name or part thereof
else if (lastOptionAlias.IsNullOrWhiteSpace())
{
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, 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 InvalidCommandOptionInputException($"Can't convert value [{value}] to type [{targetType}].");
}
catch (Exception ex)
{
throw new InvalidCommandOptionInputException($"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 InvalidCommandOptionInputException(
$"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,127 @@
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 CommandOptionSchema GetCommandOptionSchema(PropertyInfo optionProperty)
{
var attribute = optionProperty.GetCustomAttribute<CommandOptionAttribute>();
if (attribute == null)
return null;
return new CommandOptionSchema(optionProperty,
attribute.Name,
attribute.ShortName,
attribute.IsRequired,
attribute.Description);
}
private CommandSchema GetCommandSchema(Type commandType)
{
// Attribute is optional for commands in order to reduce runtime rule complexity
var attribute = commandType.GetCustomAttribute<CommandAttribute>();
var options = commandType.GetProperties().Select(GetCommandOptionSchema).ExceptNull().ToArray();
return new CommandSchema(commandType,
attribute?.Name,
attribute?.Description,
options);
}
/// <inheritdoc />
public IReadOnlyList<CommandSchema> GetCommandSchemas(IReadOnlyList<Type> commandTypes)
{
commandTypes.GuardNotNull(nameof(commandTypes));
// Get command schemas
var commandSchemas = commandTypes.Select(GetCommandSchema).ToArray();
// Throw if there are no commands defined
if (!commandSchemas.Any())
{
throw new InvalidCommandSchemaException("There are no commands defined.");
}
// Throw if there are multiple commands with the same name
var nonUniqueCommandNames = commandSchemas
.Select(c => c.Name)
.GroupBy(i => i, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() >= 2)
.SelectMany(g => g)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
foreach (var commandName in nonUniqueCommandNames)
{
throw new InvalidCommandSchemaException(!commandName.IsNullOrWhiteSpace()
? $"There are multiple commands defined with name [{commandName}]."
: "There are multiple default commands defined.");
}
// Throw if there are commands that don't implement ICommand
var nonImplementedCommandNames = commandSchemas
.Where(c => !c.Type.Implements(typeof(ICommand)))
.Select(c => c.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
foreach (var commandName in nonImplementedCommandNames)
{
throw new InvalidCommandSchemaException(!commandName.IsNullOrWhiteSpace()
? $"Command [{commandName}] doesn't implement ICommand."
: "Default command doesn't implement ICommand.");
}
// Throw if there are multiple options with the same name inside the same command
foreach (var commandSchema in commandSchemas)
{
var nonUniqueOptionNames = commandSchema.Options
.Where(o => !o.Name.IsNullOrWhiteSpace())
.Select(o => o.Name)
.GroupBy(i => i, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() >= 2)
.SelectMany(g => g)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
foreach (var optionName in nonUniqueOptionNames)
{
throw new InvalidCommandSchemaException(!commandSchema.Name.IsNullOrWhiteSpace()
? $"There are multiple options defined with name [{optionName}] on command [{commandSchema.Name}]."
: $"There are multiple options defined with name [{optionName}] on default command.");
}
var nonUniqueOptionShortNames = commandSchema.Options
.Where(o => o.ShortName != null)
.Select(o => o.ShortName.Value)
.GroupBy(i => i)
.Where(g => g.Count() >= 2)
.SelectMany(g => g)
.Distinct()
.ToArray();
foreach (var optionShortName in nonUniqueOptionShortNames)
{
throw new InvalidCommandSchemaException(!commandSchema.Name.IsNullOrWhiteSpace()
? $"There are multiple options defined with short name [{optionShortName}] on command [{commandSchema.Name}]."
: $"There are multiple options defined with short name [{optionShortName}] on default command.");
}
}
return commandSchemas;
}
}
}

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));
}
} }
} }

View File

@@ -0,0 +1,287 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CliFx.Internal;
using CliFx.Models;
namespace CliFx.Services
{
/// <summary>
/// Default implementation of <see cref="IHelpTextRenderer"/>.
/// </summary>
public partial class HelpTextRenderer : IHelpTextRenderer
{
/// <inheritdoc />
public void RenderHelpText(IConsole console, HelpTextSource source)
{
console.GuardNotNull(nameof(console));
source.GuardNotNull(nameof(source));
// Track position
var column = 0;
var row = 0;
// Get built-in option schemas (help and version)
var builtInOptionSchemas = new List<CommandOptionSchema> {CommandOptionSchema.HelpOption};
if (source.TargetCommandSchema.IsDefault())
builtInOptionSchemas.Add(CommandOptionSchema.VersionOption);
// Get child command schemas
var childCommandSchemas = source.AvailableCommandSchemas
.Where(c => source.AvailableCommandSchemas.FindParent(c.Name) == source.TargetCommandSchema)
.ToArray();
// Define helper functions
bool IsEmpty() => column == 0 && row == 0;
void Render(string text)
{
console.Output.Write(text);
column += text.Length;
}
void RenderNewLine()
{
console.Output.WriteLine();
column = 0;
row++;
}
void RenderMargin(int lines = 1)
{
if (!IsEmpty())
{
for (var i = 0; i < lines; i++)
RenderNewLine();
}
}
void RenderIndent(int spaces = 2)
{
Render(' '.Repeat(spaces));
}
void RenderColumnIndent(int spaces = 20, int margin = 2)
{
if (column + margin >= spaces)
{
RenderNewLine();
RenderIndent(spaces);
}
else
{
RenderIndent(spaces - column);
}
}
void RenderWithColor(string text, ConsoleColor foregroundColor)
{
console.WithForegroundColor(foregroundColor, () => Render(text));
}
void RenderWithColors(string text, ConsoleColor foregroundColor, ConsoleColor backgroundColor)
{
console.WithColors(foregroundColor, backgroundColor, () => Render(text));
}
void RenderHeader(string text)
{
RenderWithColors(text, ConsoleColor.Black, ConsoleColor.DarkGray);
RenderNewLine();
}
void RenderApplicationInfo()
{
if (!source.TargetCommandSchema.IsDefault())
return;
// Title and version
RenderWithColor(source.ApplicationMetadata.Title, ConsoleColor.Yellow);
Render(" ");
RenderWithColor(source.ApplicationMetadata.VersionText, ConsoleColor.Yellow);
RenderNewLine();
// Description
if (!source.ApplicationMetadata.Description.IsNullOrWhiteSpace())
{
Render(source.ApplicationMetadata.Description);
RenderNewLine();
}
}
void RenderDescription()
{
if (source.TargetCommandSchema.Description.IsNullOrWhiteSpace())
return;
// Margin
RenderMargin();
// Header
RenderHeader("Description");
// Description
RenderIndent();
Render(source.TargetCommandSchema.Description);
RenderNewLine();
}
void RenderUsage()
{
// Margin
RenderMargin();
// Header
RenderHeader("Usage");
// Exe name
RenderIndent();
Render(source.ApplicationMetadata.ExecutableName);
// Command name
if (!source.TargetCommandSchema.IsDefault())
{
Render(" ");
RenderWithColor(source.TargetCommandSchema.Name, ConsoleColor.Cyan);
}
// Child command
if (childCommandSchemas.Any())
{
Render(" ");
RenderWithColor("[command]", ConsoleColor.Cyan);
}
// Options
Render(" ");
RenderWithColor("[options]", ConsoleColor.White);
RenderNewLine();
}
void RenderOptions()
{
// Margin
RenderMargin();
// Header
RenderHeader("Options");
// Options
foreach (var optionSchema in source.TargetCommandSchema.Options.Concat(builtInOptionSchemas))
{
// Is required
if (optionSchema.IsRequired)
{
RenderWithColor("* ", ConsoleColor.Red);
}
else
{
RenderIndent();
}
// Short name
if (optionSchema.ShortName != null)
{
RenderWithColor($"-{optionSchema.ShortName}", ConsoleColor.White);
}
// Delimiter
if (!optionSchema.Name.IsNullOrWhiteSpace() && optionSchema.ShortName != null)
{
Render("|");
}
// Name
if (!optionSchema.Name.IsNullOrWhiteSpace())
{
RenderWithColor($"--{optionSchema.Name}", ConsoleColor.White);
}
// Description
if (!optionSchema.Description.IsNullOrWhiteSpace())
{
RenderColumnIndent();
Render(optionSchema.Description);
}
RenderNewLine();
}
}
void RenderChildCommands()
{
if (!childCommandSchemas.Any())
return;
// Margin
RenderMargin();
// Header
RenderHeader("Commands");
// Child commands
foreach (var childCommandSchema in childCommandSchemas)
{
var relativeCommandName = GetRelativeCommandName(childCommandSchema, source.TargetCommandSchema);
// Name
RenderIndent();
RenderWithColor(relativeCommandName, ConsoleColor.Cyan);
// Description
if (!childCommandSchema.Description.IsNullOrWhiteSpace())
{
RenderColumnIndent();
Render(childCommandSchema.Description);
}
RenderNewLine();
}
// Margin
RenderMargin();
// Child command help tip
Render("You can run `");
Render(source.ApplicationMetadata.ExecutableName);
if (!source.TargetCommandSchema.IsDefault())
{
Render(" ");
RenderWithColor(source.TargetCommandSchema.Name, ConsoleColor.Cyan);
}
Render(" ");
RenderWithColor("[command]", ConsoleColor.Cyan);
Render(" ");
RenderWithColor("--help", ConsoleColor.White);
Render("` to show help on a specific command.");
RenderNewLine();
}
// Reset color just in case
console.ResetColor();
// Render everything
RenderApplicationInfo();
RenderDescription();
RenderUsage();
RenderOptions();
RenderChildCommands();
}
}
public partial class HelpTextRenderer
{
private static string GetRelativeCommandName(CommandSchema commandSchema, CommandSchema parentCommandSchema) =>
parentCommandSchema.Name.IsNullOrWhiteSpace()
? commandSchema.Name
: commandSchema.Name.Substring(parentCommandSchema.Name.Length + 1);
}
}

View File

@@ -0,0 +1,16 @@
using System;
using CliFx.Models;
namespace CliFx.Services
{
/// <summary>
/// Initializes new instances of <see cref="ICommand"/>.
/// </summary>
public interface ICommandFactory
{
/// <summary>
/// Initializes an instance of <see cref="ICommand"/> with specified schema.
/// </summary>
ICommand CreateCommand(CommandSchema commandSchema);
}
}

View File

@@ -0,0 +1,15 @@
using CliFx.Models;
namespace CliFx.Services
{
/// <summary>
/// Populates <see cref="ICommand"/> instances with input according to its schema.
/// </summary>
public interface ICommandInitializer
{
/// <summary>
/// Populates an instance of <see cref="ICommand"/> with specified input according to specified schema.
/// </summary>
void InitializeCommand(ICommand command, CommandSchema commandSchema, CommandInput commandInput);
}
}

View File

@@ -0,0 +1,16 @@
using System.Collections.Generic;
using CliFx.Models;
namespace CliFx.Services
{
/// <summary>
/// Parses command line arguments.
/// </summary>
public interface ICommandInputParser
{
/// <summary>
/// Parses specified command line arguments.
/// </summary>
CommandInput ParseCommandInput(IReadOnlyList<string> commandLineArguments);
}
}

View File

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

View File

@@ -0,0 +1,16 @@
using System;
using CliFx.Models;
namespace CliFx.Services
{
/// <summary>
/// Converts input command options.
/// </summary>
public interface ICommandOptionInputConverter
{
/// <summary>
/// Converts an option to specified target type.
/// </summary>
object ConvertOptionInput(CommandOptionInput optionInput, Type targetType);
}
}

View File

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

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