133 Commits
0.0.1 ... 0.0.8

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

27
.github/workflows/CD.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: CD
on:
push:
tags:
- '*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v1
- name: Install .NET Core
uses: actions/setup-dotnet@v1
with:
dotnet-version: 3.0.100
- name: Pack
run: dotnet pack CliFx --configuration Release
- name: Deploy
run: |
dotnet nuget push CliFx/bin/Release/*.nupkg -s nuget.org -k ${{secrets.NUGET_TOKEN}}
dotnet nuget push CliFx/bin/Release/*.snupkg -s nuget.org -k ${{secrets.NUGET_TOKEN}}

22
.github/workflows/CI.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v1
- name: Install .NET Core
uses: actions/setup-dotnet@v1
with:
dotnet-version: 3.0.100
- name: Build & test
run: dotnet test --configuration Release
- name: Coverage
run: curl -s https://codecov.io/bash | bash -s -- -f CliFx.Tests/bin/Release/Coverage.xml -t ${{secrets.CODECOV_TOKEN}} -Z

1
.gitignore vendored
View File

@@ -143,6 +143,7 @@ _TeamCity*
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
.ncrunchsolution
# MightyMoose
*.mm.*

BIN
.screenshots/help.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -0,0 +1,35 @@
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);
[Benchmark(Description = "CommandLineParser")]
public void ExecuteWithCommandLineParser()
{
var parsed = new CommandLine.Parser().ParseArguments(Arguments, typeof(CommandLineParserCommand));
CommandLine.ParserResultExtensions.WithParsed<CommandLineParserCommand>(parsed, c => c.Execute());
}
[Benchmark(Description = "PowerArgs")]
public void ExecuteWithPowerArgs() => PowerArgs.Args.InvokeMain<PowerArgsCommand>(Arguments);
[Benchmark(Description = "Clipr")]
public void ExecuteWithClipr() => clipr.CliParser.Parse<CliprCommand>(Arguments).Execute();
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.11.5" />
<PackageReference Include="clipr" Version="1.6.1" />
<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 clipr;
namespace CliFx.Benchmarks.Commands
{
public class CliprCommand
{
[NamedArgument('s', "str")]
public string? StrOption { get; set; }
[NamedArgument('i', "int")]
public int IntOption { get; set; }
[NamedArgument('b', "bool", Constraint = NumArgsConstraint.Optional, Const = true)]
public bool BoolOption { get; set; }
public void Execute()
{
}
}
}

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>netcoreapp3.0</TargetFramework>
<Nullable>enable</Nullable>
</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>());
}
}

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

@@ -0,0 +1,39 @@
using System;
using System.Threading.Tasks;
using CliFx.Demo.Commands;
using CliFx.Demo.Services;
using Microsoft.Extensions.DependencyInjection;
namespace CliFx.Demo
{
public static class Program
{
private static IServiceProvider ConfigureServices()
{
// 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>();
return services.BuildServiceProvider();
}
public static Task<int> Main(string[] args)
{
var serviceProvider = ConfigureServices();
return new CliApplicationBuilder()
.AddCommandsFromThisAssembly()
.UseCommandFactory(schema => (ICommand) serviceProvider.GetRequiredService(schema.Type))
.Build()
.RunAsync(args);
}
}
}

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

@@ -0,0 +1,7 @@
# CliFx Demo Project
Sample command line interface for managing a library of books.
This demo project shows basic CliFx functionality such as command routing, option parsing, autogenerated help text, and some other things.
You can get a list of available commands by running `CliFx.Demo --help`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net45</TargetFramework>
<TargetFramework>netcoreapp3.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<LangVersion>latest</LangVersion>
<CollectCoverage>true</CollectCoverage>
<CoverletOutputFormat>opencover</CoverletOutputFormat>
<CoverletOutput>bin/$(Configuration)/Coverage.xml</CoverletOutput>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.0.1" />
<PackageReference Include="NUnit" Version="3.11.0" />
<PackageReference Include="NUnit3TestAdapter" Version="3.11.0" />
<PackageReference Include="Moq" Version="4.11.0" />
<PackageReference Include="CliWrap" Version="2.3.0" />
<PackageReference Include="FluentAssertions" Version="5.9.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.4.0" />
<PackageReference Include="NUnit" Version="3.12.0" />
<PackageReference Include="NUnit3TestAdapter" Version="3.15.1" />
<PackageReference Include="coverlet.msbuild" Version="2.7.0" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CliFx.Tests.Dummy\CliFx.Tests.Dummy.csproj" />
<ProjectReference Include="..\CliFx\CliFx.csproj" />
</ItemGroup>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CliFx.Models;
using CliFx.Services;
using CliFx.Tests.TestCommands;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests.Services
{
[TestFixture]
public class CommandFactoryTests
{
private static CommandSchema GetCommandSchema(Type commandType) =>
new CommandSchemaResolver().GetCommandSchemas(new[] {commandType}).Single();
private static IEnumerable<TestCaseData> GetTestCases_CreateCommand()
{
yield return new TestCaseData(GetCommandSchema(typeof(HelloWorldDefaultCommand)));
}
[Test]
[TestCaseSource(nameof(GetTestCases_CreateCommand))]
public void CreateCommand_Test(CommandSchema commandSchema)
{
// Arrange
var factory = new CommandFactory();
// Act
var command = factory.CreateCommand(commandSchema);
// Assert
command.Should().BeOfType(commandSchema.Type);
}
}
}

View File

@@ -0,0 +1,174 @@
using FluentAssertions;
using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.Linq;
using CliFx.Exceptions;
using CliFx.Models;
using CliFx.Services;
using CliFx.Tests.TestCommands;
using CliFx.Tests.Stubs;
using System.IO;
namespace CliFx.Tests.Services
{
[TestFixture]
public class CommandInitializerTests
{
private static CommandSchema GetCommandSchema(Type commandType) =>
new CommandSchemaResolver().GetCommandSchemas(new[] { commandType }).Single();
private static IEnumerable<TestCaseData> GetTestCases_InitializeCommand()
{
yield return new TestCaseData(
new DivideCommand(),
GetCommandSchema(typeof(DivideCommand)),
new CommandInput("div", new[]
{
new CommandOptionInput("dividend", "13"),
new CommandOptionInput("divisor", "8")
}),
new DivideCommand { Dividend = 13, Divisor = 8 }
);
yield return new TestCaseData(
new DivideCommand(),
GetCommandSchema(typeof(DivideCommand)),
new CommandInput("div", new[]
{
new CommandOptionInput("dividend", "13"),
new CommandOptionInput("d", "8")
}),
new DivideCommand { Dividend = 13, Divisor = 8 }
);
yield return new TestCaseData(
new DivideCommand(),
GetCommandSchema(typeof(DivideCommand)),
new CommandInput("div", new[]
{
new CommandOptionInput("D", "13"),
new CommandOptionInput("d", "8")
}),
new DivideCommand { Dividend = 13, Divisor = 8 }
);
yield return new TestCaseData(
new ConcatCommand(),
GetCommandSchema(typeof(ConcatCommand)),
new CommandInput("concat", new[]
{
new CommandOptionInput("i", new[] {"foo", " ", "bar"})
}),
new ConcatCommand { Inputs = new[] { "foo", " ", "bar" } }
);
yield return new TestCaseData(
new ConcatCommand(),
GetCommandSchema(typeof(ConcatCommand)),
new CommandInput("concat", new[]
{
new CommandOptionInput("i", new[] {"foo", "bar"}),
new CommandOptionInput("s", " ")
}),
new ConcatCommand { Inputs = new[] { "foo", "bar" }, Separator = " " }
);
//Will read a value from environment variables because none is supplied via CommandInput
yield return new TestCaseData(
new EnvironmentVariableCommand(),
GetCommandSchema(typeof(EnvironmentVariableCommand)),
new CommandInput(null, new CommandOptionInput[0], EnvironmentVariablesProviderStub.EnvironmentVariables),
new EnvironmentVariableCommand { Option = "A" }
);
//Will read multiple values from environment variables because none is supplied via CommandInput
yield return new TestCaseData(
new EnvironmentVariableWithMultipleValuesCommand(),
GetCommandSchema(typeof(EnvironmentVariableWithMultipleValuesCommand)),
new CommandInput(null, new CommandOptionInput[0], EnvironmentVariablesProviderStub.EnvironmentVariables),
new EnvironmentVariableWithMultipleValuesCommand { Option = new[] { "A", "B", "C" } }
);
//Will not read a value from environment variables because one is supplied via CommandInput
yield return new TestCaseData(
new EnvironmentVariableCommand(),
GetCommandSchema(typeof(EnvironmentVariableCommand)),
new CommandInput(null, new[]
{
new CommandOptionInput("opt", new[] { "X" })
},
EnvironmentVariablesProviderStub.EnvironmentVariables),
new EnvironmentVariableCommand { Option = "X" }
);
//Will not split environment variable values because underlying property is not a collection
yield return new TestCaseData(
new EnvironmentVariableWithoutCollectionPropertyCommand(),
GetCommandSchema(typeof(EnvironmentVariableWithoutCollectionPropertyCommand)),
new CommandInput(null, new CommandOptionInput[0], EnvironmentVariablesProviderStub.EnvironmentVariables),
new EnvironmentVariableWithoutCollectionPropertyCommand { Option = $"A{Path.PathSeparator}B{Path.PathSeparator}C{Path.PathSeparator}" }
);
}
private static IEnumerable<TestCaseData> GetTestCases_InitializeCommand_Negative()
{
yield return new TestCaseData(
new DivideCommand(),
GetCommandSchema(typeof(DivideCommand)),
new CommandInput("div")
);
yield return new TestCaseData(
new DivideCommand(),
GetCommandSchema(typeof(DivideCommand)),
new CommandInput("div", new[]
{
new CommandOptionInput("D", "13")
})
);
yield return new TestCaseData(
new ConcatCommand(),
GetCommandSchema(typeof(ConcatCommand)),
new CommandInput("concat")
);
yield return new TestCaseData(
new ConcatCommand(),
GetCommandSchema(typeof(ConcatCommand)),
new CommandInput("concat", new[]
{
new CommandOptionInput("s", "_")
})
);
}
[Test]
[TestCaseSource(nameof(GetTestCases_InitializeCommand))]
public void InitializeCommand_Test(ICommand command, CommandSchema commandSchema, CommandInput commandInput,
ICommand expectedCommand)
{
// Arrange
var initializer = new CommandInitializer();
// Act
initializer.InitializeCommand(command, commandSchema, commandInput);
// Assert
command.Should().BeEquivalentTo(expectedCommand, o => o.RespectingRuntimeTypes());
}
[Test]
[TestCaseSource(nameof(GetTestCases_InitializeCommand_Negative))]
public void InitializeCommand_Negative_Test(ICommand command, CommandSchema commandSchema, CommandInput commandInput)
{
// Arrange
var initializer = new CommandInitializer();
// Act & Assert
initializer.Invoking(i => i.InitializeCommand(command, commandSchema, commandInput))
.Should().ThrowExactly<CliFxException>();
}
}
}

View File

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

View File

@@ -0,0 +1,323 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using CliFx.Exceptions;
using CliFx.Models;
using CliFx.Services;
using CliFx.Tests.TestCustomTypes;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests.Services
{
[TestFixture]
public class CommandOptionInputConverterTests
{
private static IEnumerable<TestCaseData> GetTestCases_ConvertOptionInput()
{
yield return new TestCaseData(
new CommandOptionInput("option", "value"),
typeof(string),
"value"
);
yield return new TestCaseData(
new CommandOptionInput("option", "value"),
typeof(object),
"value"
);
yield return new TestCaseData(
new CommandOptionInput("option", "true"),
typeof(bool),
true
);
yield return new TestCaseData(
new CommandOptionInput("option", "false"),
typeof(bool),
false
);
yield return new TestCaseData(
new CommandOptionInput("option"),
typeof(bool),
true
);
yield return new TestCaseData(
new CommandOptionInput("option", "a"),
typeof(char),
'a'
);
yield return new TestCaseData(
new CommandOptionInput("option", "15"),
typeof(sbyte),
(sbyte) 15
);
yield return new TestCaseData(
new CommandOptionInput("option", "15"),
typeof(byte),
(byte) 15
);
yield return new TestCaseData(
new CommandOptionInput("option", "15"),
typeof(short),
(short) 15
);
yield return new TestCaseData(
new CommandOptionInput("option", "15"),
typeof(ushort),
(ushort) 15
);
yield return new TestCaseData(
new CommandOptionInput("option", "123"),
typeof(int),
123
);
yield return new TestCaseData(
new CommandOptionInput("option", "123"),
typeof(uint),
123u
);
yield return new TestCaseData(
new CommandOptionInput("option", "123"),
typeof(long),
123L
);
yield return new TestCaseData(
new CommandOptionInput("option", "123"),
typeof(ulong),
123UL
);
yield return new TestCaseData(
new CommandOptionInput("option", "123.45"),
typeof(float),
123.45f
);
yield return new TestCaseData(
new CommandOptionInput("option", "123.45"),
typeof(double),
123.45
);
yield return new TestCaseData(
new CommandOptionInput("option", "123.45"),
typeof(decimal),
123.45m
);
yield return new TestCaseData(
new CommandOptionInput("option", "28 Apr 1995"),
typeof(DateTime),
new DateTime(1995, 04, 28)
);
yield return new TestCaseData(
new CommandOptionInput("option", "28 Apr 1995"),
typeof(DateTimeOffset),
new DateTimeOffset(new DateTime(1995, 04, 28))
);
yield return new TestCaseData(
new CommandOptionInput("option", "00:14:59"),
typeof(TimeSpan),
new TimeSpan(00, 14, 59)
);
yield return new TestCaseData(
new CommandOptionInput("option", "value2"),
typeof(TestEnum),
TestEnum.Value2
);
yield return new TestCaseData(
new CommandOptionInput("option", "666"),
typeof(int?),
666
);
yield return new TestCaseData(
new CommandOptionInput("option"),
typeof(int?),
null
);
yield return new TestCaseData(
new CommandOptionInput("option", "value3"),
typeof(TestEnum?),
TestEnum.Value3
);
yield return new TestCaseData(
new CommandOptionInput("option"),
typeof(TestEnum?),
null
);
yield return new TestCaseData(
new CommandOptionInput("option", "01:00:00"),
typeof(TimeSpan?),
new TimeSpan(01, 00, 00)
);
yield return new TestCaseData(
new CommandOptionInput("option"),
typeof(TimeSpan?),
null
);
yield return new TestCaseData(
new CommandOptionInput("option", "value"),
typeof(TestStringConstructable),
new TestStringConstructable("value")
);
yield return new TestCaseData(
new CommandOptionInput("option", "value"),
typeof(TestStringParseable),
TestStringParseable.Parse("value")
);
yield return new TestCaseData(
new CommandOptionInput("option", "value"),
typeof(TestStringParseableWithFormatProvider),
TestStringParseableWithFormatProvider.Parse("value", CultureInfo.InvariantCulture)
);
yield return new TestCaseData(
new CommandOptionInput("option", new[] {"value1", "value2"}),
typeof(string[]),
new[] {"value1", "value2"}
);
yield return new TestCaseData(
new CommandOptionInput("option", new[] {"value1", "value2"}),
typeof(object[]),
new[] {"value1", "value2"}
);
yield return new TestCaseData(
new CommandOptionInput("option", new[] {"47", "69"}),
typeof(int[]),
new[] {47, 69}
);
yield return new TestCaseData(
new CommandOptionInput("option", new[] {"47"}),
typeof(int[]),
new[] {47}
);
yield return new TestCaseData(
new CommandOptionInput("option", new[] {"value1", "value3"}),
typeof(TestEnum[]),
new[] {TestEnum.Value1, TestEnum.Value3}
);
yield return new TestCaseData(
new CommandOptionInput("option", new[] {"1337", "2441"}),
typeof(int?[]),
new int?[] {1337, 2441}
);
yield return new TestCaseData(
new CommandOptionInput("option", new[] {"value1", "value2"}),
typeof(TestStringConstructable[]),
new[] {new TestStringConstructable("value1"), new TestStringConstructable("value2")}
);
yield return new TestCaseData(
new CommandOptionInput("option", new[] {"value1", "value2"}),
typeof(IEnumerable),
new[] {"value1", "value2"}
);
yield return new TestCaseData(
new CommandOptionInput("option", new[] {"value1", "value2"}),
typeof(IEnumerable<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", new[] {"123", "456"}),
typeof(int)
);
yield return new TestCaseData(
new CommandOptionInput("option"),
typeof(int)
);
yield return new TestCaseData(
new CommandOptionInput("option", "123"),
typeof(TestNonStringParseable)
);
}
[Test]
[TestCaseSource(nameof(GetTestCases_ConvertOptionInput))]
public void ConvertOptionInput_Test(CommandOptionInput optionInput, Type targetType,
object expectedConvertedValue)
{
// Arrange
var converter = new CommandOptionInputConverter();
// Act
var convertedValue = converter.ConvertOptionInput(optionInput, targetType);
// Assert
convertedValue.Should().BeEquivalentTo(expectedConvertedValue);
convertedValue?.Should().BeAssignableTo(targetType);
}
[Test]
[TestCaseSource(nameof(GetTestCases_ConvertOptionInput_Negative))]
public void ConvertOptionInput_Negative_Test(CommandOptionInput optionInput, Type targetType)
{
// Arrange
var converter = new CommandOptionInputConverter();
// Act & Assert
converter.Invoking(c => c.ConvertOptionInput(optionInput, targetType))
.Should().ThrowExactly<CliFxException>();
}
}
}

View File

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

View File

@@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CliFx.Models;
using CliFx.Services;
using CliFx.Tests.TestCommands;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests.Services
{
[TestFixture]
public class DelegateCommandFactoryTests
{
private static CommandSchema GetCommandSchema(Type commandType) =>
new CommandSchemaResolver().GetCommandSchemas(new[] {commandType}).Single();
private static IEnumerable<TestCaseData> GetTestCases_CreateCommand()
{
yield return new TestCaseData(
new Func<CommandSchema, ICommand>(schema => (ICommand) Activator.CreateInstance(schema.Type!)!),
GetCommandSchema(typeof(HelloWorldDefaultCommand))
);
}
[Test]
[TestCaseSource(nameof(GetTestCases_CreateCommand))]
public void CreateCommand_Test(Func<CommandSchema, ICommand> factoryMethod, CommandSchema commandSchema)
{
// Arrange
var factory = new DelegateCommandFactory(factoryMethod);
// Act
var command = factory.CreateCommand(commandSchema);
// Assert
command.Should().BeOfType(commandSchema.Type);
}
}
}

View File

@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using CliFx.Models;
using CliFx.Services;
using CliFx.Tests.TestCommands;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests.Services
{
[TestFixture]
public class HelpTextRendererTests
{
private static HelpTextSource CreateHelpTextSource(IReadOnlyList<Type> availableCommandTypes, Type targetCommandType)
{
var commandSchemaResolver = new CommandSchemaResolver();
var applicationMetadata = new ApplicationMetadata("TestApp", "testapp", "1.0", null);
var availableCommandSchemas = commandSchemaResolver.GetCommandSchemas(availableCommandTypes);
var targetCommandSchema = availableCommandSchemas.Single(s => s.Type == targetCommandType);
return new HelpTextSource(applicationMetadata, availableCommandSchemas, targetCommandSchema);
}
private static IEnumerable<TestCaseData> GetTestCases_RenderHelpText()
{
yield return new TestCaseData(
CreateHelpTextSource(
new[] {typeof(HelpDefaultCommand), typeof(HelpNamedCommand), typeof(HelpSubCommand)},
typeof(HelpDefaultCommand)),
new[]
{
"Description",
"HelpDefaultCommand description.",
"Usage",
"[command]", "[options]",
"Options",
"-a|--option-a", "OptionA description.",
"-b|--option-b", "OptionB description.",
"-h|--help", "Shows help text.",
"--version", "Shows version information.",
"Commands",
"cmd", "HelpNamedCommand description.",
"You can run", "to show help on a specific command."
}
);
yield return new TestCaseData(
CreateHelpTextSource(
new[] {typeof(HelpDefaultCommand), typeof(HelpNamedCommand), typeof(HelpSubCommand)},
typeof(HelpNamedCommand)),
new[]
{
"Description",
"HelpNamedCommand description.",
"Usage",
"cmd", "[command]", "[options]",
"Options",
"-c|--option-c", "OptionC description.",
"-d|--option-d", "OptionD description.",
"-h|--help", "Shows help text.",
"Commands",
"sub", "HelpSubCommand description.",
"You can run", "to show help on a specific command."
}
);
yield return new TestCaseData(
CreateHelpTextSource(
new[] {typeof(HelpDefaultCommand), typeof(HelpNamedCommand), typeof(HelpSubCommand)},
typeof(HelpSubCommand)),
new[]
{
"Description",
"HelpSubCommand description.",
"Usage",
"cmd sub", "[options]",
"Options",
"-e|--option-e", "OptionE description.",
"-h|--help", "Shows help text."
}
);
}
[Test]
[TestCaseSource(nameof(GetTestCases_RenderHelpText))]
public void RenderHelpText_Test(HelpTextSource source,
IReadOnlyList<string> expectedSubstrings)
{
// Arrange
using var stdout = new StringWriter();
var console = new VirtualConsole(stdout);
var renderer = new HelpTextRenderer();
// Act
renderer.RenderHelpText(console, source);
// Assert
stdout.ToString().Should().ContainAll(expectedSubstrings);
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
using System.Collections.Generic;
using CliFx.Services;
namespace CliFx.Tests.Stubs
{
public class EmptyEnvironmentVariablesProviderStub : IEnvironmentVariablesProvider
{
public IReadOnlyDictionary<string, string> GetEnvironmentVariables() => new Dictionary<string, string>();
}
}

View File

@@ -0,0 +1,18 @@
using System.Collections.Generic;
using System.IO;
using CliFx.Services;
namespace CliFx.Tests.Stubs
{
public class EnvironmentVariablesProviderStub : IEnvironmentVariablesProvider
{
public static readonly Dictionary<string, string> EnvironmentVariables = new Dictionary<string, string>
{
["ENV_SINGLE_VALUE"] = "A",
["ENV_MULTIPLE_VALUES"] = $"A{Path.PathSeparator}B{Path.PathSeparator}C{Path.PathSeparator}",
["ENV_ESCAPED_MULTIPLE_VALUES"] = $"\"A{Path.PathSeparator}B{Path.PathSeparator}C{Path.PathSeparator}\""
};
public IReadOnlyDictionary<string, string> GetEnvironmentVariables() => EnvironmentVariables;
}
}

View File

@@ -0,0 +1,22 @@
using System;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
namespace CliFx.Tests.TestCommands
{
[Command("cancel")]
public class CancellableCommand : ICommand
{
public async Task ExecuteAsync(IConsole console)
{
await Task.Yield();
console.Output.WriteLine("Printed");
await Task.Delay(TimeSpan.FromSeconds(1), console.GetCancellationToken()).ConfigureAwait(false);
console.Output.WriteLine("Never printed");
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
namespace CliFx.Tests.TestCommands
{
[Command(Description = "Reads option values from environment variables.")]
public class EnvironmentVariableCommand : ICommand
{
[CommandOption("opt", EnvironmentVariableName = "ENV_SINGLE_VALUE")]
public string? Option { get; set; }
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
}
}

View File

@@ -0,0 +1,16 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
namespace CliFx.Tests.TestCommands
{
[Command(Description = "Reads multiple option values from environment variables.")]
public class EnvironmentVariableWithMultipleValuesCommand : ICommand
{
[CommandOption("opt", EnvironmentVariableName = "ENV_MULTIPLE_VALUES")]
public IEnumerable<string>? Option { get; set; }
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
}
}

View File

@@ -0,0 +1,15 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
namespace CliFx.Tests.TestCommands
{
[Command(Description = "Reads one option value from environment variables because target property is not a collection.")]
public class EnvironmentVariableWithoutCollectionPropertyCommand : ICommand
{
[CommandOption("opt", EnvironmentVariableName = "ENV_MULTIPLE_VALUES")]
public string? Option { get; set; }
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,14 +2,36 @@
namespace CliFx.Attributes
{
/// <summary>
/// Annotates a type that defines a command.
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class CommandAttribute : Attribute
{
public string Name { get; }
/// <summary>
/// Command name.
/// This can be null if this is the default command.
/// </summary>
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)
{
Name = name;
}
/// <summary>
/// Initializes an instance of <see cref="CommandAttribute"/>.
/// </summary>
public CommandAttribute()
{
}
}
}

View File

@@ -2,20 +2,70 @@
namespace CliFx.Attributes
{
/// <summary>
/// Annotates a property that defines a command option.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public class CommandOptionAttribute : Attribute
{
public string Name { get; }
/// <summary>
/// Option name.
/// Either <see cref="Name"/> or <see cref="ShortName"/> must be set.
/// </summary>
public string? Name { get; }
public char ShortName { get; set; }
/// <summary>
/// Option short name.
/// Either <see cref="Name"/> or <see cref="ShortName"/> must be set.
/// </summary>
public char? ShortName { get; }
/// <summary>
/// Whether an option is required.
/// </summary>
public bool IsRequired { get; set; }
public string Description { get; set; }
/// <summary>
/// Option description, which is used in help text.
/// </summary>
public string? Description { get; set; }
public CommandOptionAttribute(string name)
/// <summary>
/// Optional environment variable name that will be used as fallback value if no option value is specified.
/// </summary>
public string? EnvironmentVariableName { get; set; }
/// <summary>
/// Initializes an instance of <see cref="CommandOptionAttribute"/>.
/// </summary>
private CommandOptionAttribute(string? name, char? shortName)
{
Name = name;
ShortName = shortName;
}
/// <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, (char?) shortName)
{
}
}
}

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

View File

@@ -0,0 +1,170 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using CliFx.Attributes;
using CliFx.Internal;
using CliFx.Models;
using CliFx.Services;
namespace CliFx
{
/// <summary>
/// Default implementation of <see cref="ICliApplicationBuilder"/>.
/// </summary>
public partial class CliApplicationBuilder : ICliApplicationBuilder
{
private readonly HashSet<Type> _commandTypes = new HashSet<Type>();
private bool _isDebugModeAllowed = true;
private bool _isPreviewModeAllowed = true;
private string? _title;
private string? _executableName;
private string? _versionText;
private string? _description;
private IConsole? _console;
private ICommandFactory? _commandFactory;
private ICommandOptionInputConverter? _commandOptionInputConverter;
private IEnvironmentVariablesProvider? _environmentVariablesProvider;
/// <inheritdoc />
public ICliApplicationBuilder AddCommand(Type commandType)
{
_commandTypes.Add(commandType);
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder AddCommandsFrom(Assembly commandAssembly)
{
var commandTypes = commandAssembly.ExportedTypes
.Where(t => t.Implements(typeof(ICommand)))
.Where(t => t.IsDefined(typeof(CommandAttribute)))
.Where(t => !t.IsAbstract && !t.IsInterface);
foreach (var commandType in commandTypes)
AddCommand(commandType);
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder AllowDebugMode(bool isAllowed = true)
{
_isDebugModeAllowed = isAllowed;
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder AllowPreviewMode(bool isAllowed = true)
{
_isPreviewModeAllowed = isAllowed;
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder UseTitle(string title)
{
_title = title;
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder UseExecutableName(string executableName)
{
_executableName = executableName;
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder UseVersionText(string versionText)
{
_versionText = versionText;
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder UseDescription(string? description)
{
_description = description;
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder UseConsole(IConsole console)
{
_console = console;
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder UseCommandFactory(ICommandFactory factory)
{
_commandFactory = factory;
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder UseCommandOptionInputConverter(ICommandOptionInputConverter converter)
{
_commandOptionInputConverter = converter;
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder UseEnvironmentVariablesProvider(IEnvironmentVariablesProvider environmentVariablesProvider)
{
_environmentVariablesProvider = environmentVariablesProvider;
return this;
}
/// <inheritdoc />
public ICliApplication Build()
{
// Use defaults for required parameters that were not configured
_title ??= GetDefaultTitle() ?? "App";
_executableName ??= GetDefaultExecutableName() ?? "app";
_versionText ??= GetDefaultVersionText() ?? "v1.0";
_console ??= new SystemConsole();
_commandFactory ??= new CommandFactory();
_commandOptionInputConverter ??= new CommandOptionInputConverter();
_environmentVariablesProvider ??= new EnvironmentVariablesProvider();
// Project parameters to expected types
var metadata = new ApplicationMetadata(_title, _executableName, _versionText, _description);
var configuration = new ApplicationConfiguration(_commandTypes.ToArray(), _isDebugModeAllowed, _isPreviewModeAllowed);
return new CliApplication(metadata, configuration,
_console, new CommandInputParser(_environmentVariablesProvider), new CommandSchemaResolver(),
_commandFactory, new CommandInitializer(_commandOptionInputConverter, new EnvironmentVariablesParser()), new HelpTextRenderer());
}
}
public partial class CliApplicationBuilder
{
private static readonly Lazy<Assembly> LazyEntryAssembly = new Lazy<Assembly>(Assembly.GetEntryAssembly);
// Entry assembly is null in tests
private static Assembly EntryAssembly => LazyEntryAssembly.Value;
private static string GetDefaultTitle() => EntryAssembly?.GetName().Name ?? "";
private static string GetDefaultExecutableName()
{
var entryAssemblyLocation = EntryAssembly?.Location;
// If it's a .dll assembly, prepend 'dotnet' and keep the file extension
if (string.Equals(Path.GetExtension(entryAssemblyLocation), ".dll", StringComparison.OrdinalIgnoreCase))
{
return "dotnet " + Path.GetFileName(entryAssemblyLocation);
}
// Otherwise just use assembly file name without extension
return Path.GetFileNameWithoutExtension(entryAssemblyLocation);
}
private static string GetDefaultVersionText() => EntryAssembly != null ? $"v{EntryAssembly.GetName().Version}" : "";
}
}

View File

@@ -1,9 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net45;netstandard2.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<Version>0.0.1</Version>
<TargetFrameworks>net45;netstandard2.0;netstandard2.1</TargetFrameworks>
<Version>0.0.8</Version>
<Company>Tyrrrz</Company>
<Authors>$(Company)</Authors>
<Copyright>Copyright (C) Alexey Golub</Copyright>
@@ -11,13 +10,27 @@
<PackageTags>command line executable interface framework parser arguments net core</PackageTags>
<PackageProjectUrl>https://github.com/Tyrrrz/CliFx</PackageProjectUrl>
<PackageReleaseNotes>https://github.com/Tyrrrz/CliFx/blob/master/Changelog.md</PackageReleaseNotes>
<PackageIconUrl>https://raw.githubusercontent.com/Tyrrrz/CliFx/master/favicon.png</PackageIconUrl>
<PackageIcon>favicon.png</PackageIcon>
<PackageLicenseExpression>LGPL-3.0-only</PackageLicenseExpression>
<RepositoryUrl>https://github.com/Tyrrrz/CliFx</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageRequireLicenseAcceptance>True</PackageRequireLicenseAcceptance>
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<PublishRepositoryUrl>True</PublishRepositoryUrl>
<EmbedUntrackedSources>True</EmbedUntrackedSources>
<IncludeSymbols>True</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.0-preview.2" PrivateAssets="all" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0-beta2-19554-01" PrivateAssets="all" />
<PackageReference Include="Nullable" Version="1.1.1" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<None Include="../favicon.png" Pack="True" PackagePath="" />
</ItemGroup>
</Project>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,48 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Reflection;
using CliFx.Models;
using CliFx.Services;
namespace CliFx
{
/// <summary>
/// Extensions for <see cref="CliFx"/>.
/// </summary>
public static class Extensions
{
public static Task<int> RunAsync(this ICliApplication application) =>
application.RunAsync(Environment.GetCommandLineArgs().Skip(1).ToArray());
/// <summary>
/// Adds multiple commands to the application.
/// </summary>
public static ICliApplicationBuilder AddCommands(this ICliApplicationBuilder builder, IReadOnlyList<Type> 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)
{
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.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.UseCommandFactory(new DelegateCommandFactory(factoryMethod));
}
}

View File

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

View File

@@ -0,0 +1,78 @@
using System;
using System.Reflection;
using CliFx.Services;
namespace CliFx
{
/// <summary>
/// Builds an instance of <see cref="ICliApplication"/>.
/// </summary>
public interface ICliApplicationBuilder
{
/// <summary>
/// Adds a command of specified type to the application.
/// </summary>
ICliApplicationBuilder AddCommand(Type commandType);
/// <summary>
/// Adds commands from specified assembly to the application.
/// </summary>
ICliApplicationBuilder AddCommandsFrom(Assembly commandAssembly);
/// <summary>
/// Specifies whether debug mode (enabled with [debug] directive) is allowed in the application.
/// </summary>
ICliApplicationBuilder AllowDebugMode(bool isAllowed = true);
/// <summary>
/// Specifies whether preview mode (enabled with [preview] directive) is allowed in the application.
/// </summary>
ICliApplicationBuilder AllowPreviewMode(bool isAllowed = true);
/// <summary>
/// Sets application title, which appears in the help text.
/// </summary>
ICliApplicationBuilder UseTitle(string title);
/// <summary>
/// Sets application executable name, which appears in the help text.
/// </summary>
ICliApplicationBuilder UseExecutableName(string executableName);
/// <summary>
/// Sets application version text, which appears in the help text and when the user requests version information.
/// </summary>
ICliApplicationBuilder UseVersionText(string versionText);
/// <summary>
/// Sets application description, which appears in the help text.
/// </summary>
ICliApplicationBuilder UseDescription(string? description);
/// <summary>
/// Configures application to use specified implementation of <see cref="IConsole"/>.
/// </summary>
ICliApplicationBuilder UseConsole(IConsole console);
/// <summary>
/// Configures application to use specified implementation of <see cref="ICommandFactory"/>.
/// </summary>
ICliApplicationBuilder UseCommandFactory(ICommandFactory factory);
/// <summary>
/// Configures application to use specified implementation of <see cref="ICommandOptionInputConverter"/>.
/// </summary>
ICliApplicationBuilder UseCommandOptionInputConverter(ICommandOptionInputConverter converter);
/// <summary>
/// Configures application to use specified implementation of <see cref="IEnvironmentVariablesProvider"/>.
/// </summary>
ICliApplicationBuilder UseEnvironmentVariablesProvider(IEnvironmentVariablesProvider environmentVariablesProvider);
/// <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,57 +1,70 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace CliFx.Internal
{
internal static class Extensions
{
public static bool IsNullOrWhiteSpace(this string s) => string.IsNullOrWhiteSpace(s);
public static string Repeat(this char c, int count) => new string(c, count);
public static string SubstringUntil(this string s, string sub, StringComparison comparison = StringComparison.Ordinal)
{
var index = s.IndexOf(sub, comparison);
return index < 0 ? s : s.Substring(0, index);
}
public static string SubstringAfter(this string s, string sub, StringComparison comparison = StringComparison.Ordinal)
{
var index = s.IndexOf(sub, comparison);
return index < 0 ? string.Empty : s.Substring(index + sub.Length, s.Length - index - sub.Length);
}
public static TValue GetValueOrDefault<TKey, TValue>(this IReadOnlyDictionary<TKey, TValue> dic, TKey key) =>
dic.TryGetValue(key, out var result) ? result : default;
public static string TrimStart(this string s, string sub, StringComparison comparison = StringComparison.Ordinal)
{
while (s.StartsWith(sub, comparison))
s = s.Substring(sub.Length);
return s;
}
public static string TrimEnd(this string s, string sub, StringComparison comparison = StringComparison.Ordinal)
{
while (s.EndsWith(sub, comparison))
s = s.Substring(0, s.Length - sub.Length);
return s;
}
public static string AsString(this char c) => c.Repeat(1);
public static string JoinToString<T>(this IEnumerable<T> source, string separator) => string.Join(separator, source);
public static bool IsDerivedFrom(this Type type, Type baseType)
public static string SubstringUntilLast(this string s, string sub,
StringComparison comparison = StringComparison.Ordinal)
{
var currentType = type;
while (currentType != null)
{
if (currentType == baseType)
return true;
currentType = currentType.BaseType;
var index = s.LastIndexOf(sub, comparison);
return index < 0 ? s : s.Substring(0, index);
}
return false;
public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) =>
builder.Length > 0 ? builder.Append(value) : builder;
public static IEnumerable<T> Concat<T>(this IEnumerable<T> source, T value)
{
foreach (var i in source)
yield return i;
yield return value;
}
public static bool Implements(this Type type, Type interfaceType) => type.GetInterfaces().Contains(interfaceType);
public static Type? GetNullableUnderlyingType(this Type type) => Nullable.GetUnderlyingType(type);
public static Type? GetEnumerableUnderlyingType(this Type type)
{
if (type.IsPrimitive)
return null;
if (type == typeof(IEnumerable))
return typeof(object);
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>))
return type.GetGenericArguments().FirstOrDefault();
return type.GetInterfaces()
.Select(GetEnumerableUnderlyingType)
.Where(t => t != default)
.OrderByDescending(t => t != typeof(object)) // prioritize more specific types
.FirstOrDefault();
}
public static Array ToNonGenericArray<T>(this IEnumerable<T> source, Type elementType)
{
var sourceAsCollection = source as ICollection ?? source.ToArray();
var array = Array.CreateInstance(elementType, sourceAsCollection.Count);
sourceAsCollection.CopyTo(array, 0);
return array;
}
public static bool IsCollection(this Type type) =>
type != typeof(string) && type.GetEnumerableUnderlyingType() != null;
}
}

View File

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

View File

@@ -0,0 +1,39 @@
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;
ExecutableName = executableName;
VersionText = versionText;
Description = description;
}
}
}

View File

@@ -0,0 +1,120 @@
using System.Collections.Generic;
using System.Text;
using CliFx.Internal;
namespace CliFx.Models
{
/// <summary>
/// Parsed command line input.
/// </summary>
public partial class CommandInput
{
/// <summary>
/// Specified command name.
/// Can be null if command was not specified.
/// </summary>
public string? CommandName { get; }
/// <summary>
/// Specified directives.
/// </summary>
public IReadOnlyList<string> Directives { get; }
/// <summary>
/// Specified options.
/// </summary>
public IReadOnlyList<CommandOptionInput> Options { get; }
/// <summary>
/// Environment variables available when the command was parsed
/// </summary>
public IReadOnlyDictionary<string, string> EnvironmentVariables { get; }
/// <summary>
/// Initializes an instance of <see cref="CommandInput"/>.
/// </summary>
public CommandInput(string? commandName, IReadOnlyList<string> directives, IReadOnlyList<CommandOptionInput> options,
IReadOnlyDictionary<string, string> environmentVariables)
{
CommandName = commandName;
Directives = directives;
Options = options;
EnvironmentVariables = environmentVariables;
}
/// <summary>
/// Initializes an instance of <see cref="CommandInput"/>.
/// </summary>
public CommandInput(string? commandName, IReadOnlyList<string> directives, IReadOnlyList<CommandOptionInput> options)
: this(commandName, directives, options, EmptyEnvironmentVariables)
{
}
/// <summary>
/// Initializes an instance of <see cref="CommandInput"/>.
/// </summary>
public CommandInput(string? commandName, IReadOnlyList<CommandOptionInput> options, IReadOnlyDictionary<string, string> environmentVariables)
: this(commandName, EmptyDirectives, options, environmentVariables)
{
}
/// <summary>
/// Initializes an instance of <see cref="CommandInput"/>.
/// </summary>
public CommandInput(string? commandName, IReadOnlyList<CommandOptionInput> options)
: this(commandName, EmptyDirectives, options)
{
}
/// <summary>
/// Initializes an instance of <see cref="CommandInput"/>.
/// </summary>
public CommandInput(IReadOnlyList<CommandOptionInput> options)
: this(null, options)
{
}
/// <summary>
/// Initializes an instance of <see cref="CommandInput"/>.
/// </summary>
public CommandInput(string? commandName)
: this(commandName, EmptyOptions)
{
}
/// <inheritdoc />
public override string ToString()
{
var buffer = new StringBuilder();
if (!string.IsNullOrWhiteSpace(CommandName))
buffer.Append(CommandName);
foreach (var directive in Directives)
{
buffer.AppendIfNotEmpty(' ');
buffer.Append(directive);
}
foreach (var option in Options)
{
buffer.AppendIfNotEmpty(' ');
buffer.Append(option);
}
return buffer.ToString();
}
}
public partial class CommandInput
{
private static readonly IReadOnlyList<string> EmptyDirectives = new string[0];
private static readonly IReadOnlyList<CommandOptionInput> EmptyOptions = new CommandOptionInput[0];
private static readonly IReadOnlyDictionary<string, string> EmptyEnvironmentVariables = new Dictionary<string, string>();
/// <summary>
/// Empty input.
/// </summary>
public static CommandInput Empty { get; } = new CommandInput(EmptyOptions);
}
}

View File

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

View File

@@ -0,0 +1,88 @@
using System.Reflection;
using System.Text;
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>
/// Optional environment variable name that will be used as fallback value if no option value is specified.
/// </summary>
public string? EnvironmentVariableName { get; }
/// <summary>
/// Initializes an instance of <see cref="CommandOptionSchema"/>.
/// </summary>
public CommandOptionSchema(PropertyInfo? property, string? name, char? shortName, bool isRequired, string? description, string? environmentVariableName)
{
Property = property;
Name = name;
ShortName = shortName;
IsRequired = isRequired;
Description = description;
EnvironmentVariableName = environmentVariableName;
}
/// <inheritdoc />
public override string ToString()
{
var buffer = new StringBuilder();
if (IsRequired)
buffer.Append('*');
if (!string.IsNullOrWhiteSpace(Name))
buffer.Append(Name);
if (!string.IsNullOrWhiteSpace(Name) && ShortName != null)
buffer.Append('|');
if (ShortName != null)
buffer.Append(ShortName);
return buffer.ToString();
}
}
public partial class CommandOptionSchema
{
// Here we define some built-in options.
// This is probably a bit hacky but I couldn't come up with a better solution given this architecture.
// We define them here to serve as a single source of truth, because they are used...
// ...in CliApplication (when reading) and HelpTextRenderer (when writing).
internal static CommandOptionSchema HelpOption { get; } =
new CommandOptionSchema(null, "help", 'h', false, "Shows help text.", null);
internal static CommandOptionSchema VersionOption { get; } =
new CommandOptionSchema(null, "version", null, false, "Shows version information.", null);
}
}

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;
Name = name;
Description = description;
Options = options;
}
/// <inheritdoc />
public override string ToString()
{
var buffer = new StringBuilder();
if (!string.IsNullOrWhiteSpace(Name))
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);
}
}

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

@@ -0,0 +1,130 @@
using CliFx.Internal;
using System;
using System.Collections.Generic;
using System.Linq;
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)
{
// If looking for default command, don't compare names directly
// ...because null and empty are both valid names for default command
if (string.IsNullOrWhiteSpace(commandName))
return commandSchemas.FirstOrDefault(c => c.IsDefault());
return commandSchemas.FirstOrDefault(c => string.Equals(c.Name, commandName, StringComparison.OrdinalIgnoreCase));
}
/// <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)
{
// If command has no name, it's the default command so it doesn't have a parent
if (string.IsNullOrWhiteSpace(commandName))
return null;
// Repeatedly cut off individual words from the name until we find a command with that name
var temp = commandName;
while (temp.Contains(" "))
{
temp = temp.SubstringUntilLast(" ");
var parent = commandSchemas.FindByName(temp);
if (parent != null)
return parent;
}
// If no parent is matched by name, then the parent is the default command
return commandSchemas.FirstOrDefault(c => c.IsDefault());
}
/// <summary>
/// Determines whether an option schema matches specified alias.
/// </summary>
public static bool MatchesAlias(this CommandOptionSchema optionSchema, string alias)
{
// Compare against name. Case is ignored.
var matchesByName =
!string.IsNullOrWhiteSpace(optionSchema.Name) &&
string.Equals(optionSchema.Name, alias, StringComparison.OrdinalIgnoreCase);
// Compare against short name. Case is NOT ignored.
var matchesByShortName =
optionSchema.ShortName != null &&
alias.Length == 1 && alias[0] == optionSchema.ShortName;
return matchesByName || matchesByShortName;
}
/// <summary>
/// Finds an option input that matches the option schema specified, or null if not found.
/// </summary>
public static CommandOptionInput? FindByOptionSchema(this IReadOnlyList<CommandOptionInput> optionInputs, CommandOptionSchema optionSchema) =>
optionInputs.FirstOrDefault(o => optionSchema.MatchesAlias(o.Alias));
/// <summary>
/// Gets valid aliases for the option.
/// </summary>
public static IReadOnlyList<string> GetAliases(this CommandOptionSchema optionSchema)
{
var result = new List<string>(2);
if (!string.IsNullOrWhiteSpace(optionSchema.Name))
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) => !string.IsNullOrWhiteSpace(commandInput.CommandName);
/// <summary>
/// Gets whether debug directive was specified in the input.
/// </summary>
public static bool IsDebugDirectiveSpecified(this CommandInput commandInput) =>
commandInput.Directives.Contains("debug", StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets whether preview directive was specified in the input.
/// </summary>
public static bool IsPreviewDirectiveSpecified(this CommandInput commandInput) =>
commandInput.Directives.Contains("preview", StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets whether help option was specified in the input.
/// </summary>
public static bool IsHelpOptionSpecified(this CommandInput commandInput)
{
var firstOption = commandInput.Options.FirstOrDefault();
return firstOption != null && CommandOptionSchema.HelpOption.MatchesAlias(firstOption.Alias);
}
/// <summary>
/// Gets whether version option was specified in the input.
/// </summary>
public static bool IsVersionOptionSpecified(this CommandInput commandInput)
{
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) => string.IsNullOrWhiteSpace(commandSchema.Name);
}
}

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