155 Commits
0.0.1 ... 1.0

Author SHA1 Message Date
Alexey Golub
4365ad457a Update version 2020-01-30 12:31:37 +02:00
Alexey Golub
fb3617980e Add argument syntax info to readme 2020-01-30 12:28:56 +02:00
Alexey Golub
7690aae456 Update readme 2020-01-30 11:48:31 +02:00
Alexey Golub
076678a08c Change how headers are rendered in help text 2020-01-30 11:44:36 +02:00
Alexey Golub
104279d6e9 Change how non-scalar arguments are displayed in usage 2020-01-30 10:58:36 +02:00
Alexey Golub
515d51a91d Add dummy tests to cover difficult scenarios 2020-01-29 23:35:45 +02:00
Alexey Golub
4fdf543190 Ensure delegate type activator doesn't return null 2020-01-29 20:03:06 +02:00
Alexey Golub
4e1ab096c9 Add info about environment variables 2020-01-29 19:40:58 +02:00
Alexey Golub
8aa6911cca Update readme 2020-01-29 10:59:45 +02:00
Alexey Golub
f0362019ed Use lowercase default display name for parameters 2020-01-28 22:24:38 +02:00
Alexey Golub
82895f2e42 Improve preview directive 2020-01-28 14:48:21 +02:00
Alexey Golub
4cf622abe5 Add Cocona to benchmarks 2020-01-27 21:50:25 +02:00
Alexey Golub
d4e22a78d6 Change to a more permissive license 2020-01-27 21:35:14 +02:00
Alexey Golub
3883c831e9 Rework (#36) 2020-01-27 21:10:14 +02:00
dgarcia202
63441688fe Add required options to the usage help text (#35) 2020-01-17 23:05:01 +02:00
Thorkil Holm-Jacobsen
e48839b938 Add positional arguments (#32) 2020-01-13 13:31:05 +02:00
Alexey Golub
ed87373dc3 Make exceptions slightly more friendly 2019-12-16 22:58:31 +02:00
Alexey Golub
6ce52c70f7 Use ValueTask 2019-12-16 22:16:16 +02:00
Alexey Golub
d2b0b16121 Use shared props files 2019-12-01 16:08:41 +02:00
Alexey Golub
d67a9fe762 Update readme 2019-11-30 23:32:06 +02:00
Alexey Golub
ce2a3153e6 Update donation info 2019-11-19 21:14:15 +02:00
Alexey Golub
d4b54231fb Remove unnecessary CD step 2019-11-13 20:41:57 +02:00
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 6535 additions and 2020 deletions

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

@@ -0,0 +1,3 @@
github: Tyrrrz
patreon: Tyrrrz
custom: ['buymeacoffee.com/Tyrrrz', 'tyrrrz.me/donate']

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

@@ -0,0 +1,25 @@
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.1.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}}

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.1.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: 20 KiB

View File

@@ -0,0 +1,46 @@
using System.Threading.Tasks;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Order;
using CliFx.Benchmarks.Commands;
using CommandLine;
namespace CliFx.Benchmarks
{
[SimpleJob]
[RankColumn]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
public class Benchmark
{
private static readonly string[] Arguments = {"--str", "hello world", "-i", "13", "-b"};
[Benchmark(Description = "CliFx", Baseline = true)]
public async ValueTask<int> ExecuteWithCliFx() =>
await new CliApplicationBuilder().AddCommand(typeof(CliFxCommand)).Build().RunAsync(Arguments);
[Benchmark(Description = "System.CommandLine")]
public async Task<int> ExecuteWithSystemCommandLine() =>
await 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() =>
new Parser()
.ParseArguments(Arguments, typeof(CommandLineParserCommand))
.WithParsed<CommandLineParserCommand>(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();
[Benchmark(Description = "Cocona")]
public void ExecuteWithCocona() =>
Cocona.CoconaApp.Run<CoconaCommand>(Arguments);
}
}

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../CliFx.props" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.12.0" />
<PackageReference Include="clipr" Version="1.6.1" />
<PackageReference Include="Cocona" Version="1.0.0" />
<PackageReference Include="CommandLineParser" Version="2.7.82" />
<PackageReference Include="McMaster.Extensions.CommandLineUtils" Version="2.5.0" />
<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,20 @@
using System.Threading.Tasks;
using CliFx.Attributes;
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 ValueTask ExecuteAsync(IConsole console) => default;
}
}

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,17 @@
using Cocona;
namespace CliFx.Benchmarks.Commands
{
public class CoconaCommand
{
public void Execute(
[Option("str", new []{'s'})]
string? strOption,
[Option("int", new []{'i'})]
int intOption,
[Option("bool", new []{'b'})]
bool boolOption)
{
}
}
}

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">
<Import Project="../CliFx.props" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="3.1.1" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CliFx\CliFx.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,68 @@
using System;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Demo.Internal;
using CliFx.Demo.Models;
using CliFx.Demo.Services;
using CliFx.Exceptions;
namespace CliFx.Demo.Commands
{
[Command("book add", Description = "Add a book to the library.")]
public partial class BookAddCommand : ICommand
{
private readonly LibraryService _libraryService;
[CommandParameter(0, Name = "title", Description = "Book title.")]
public string Title { get; set; } = "";
[CommandOption("author", 'a', IsRequired = true, Description = "Book author.")]
public string Author { get; set; } = "";
[CommandOption("published", 'p', Description = "Book publish date.")]
public DateTimeOffset Published { get; set; } = CreateRandomDate();
[CommandOption("isbn", 'n', Description = "Book ISBN.")]
public Isbn Isbn { get; set; } = CreateRandomIsbn();
public BookAddCommand(LibraryService libraryService)
{
_libraryService = libraryService;
}
public ValueTask ExecuteAsync(IConsole console)
{
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 default;
}
}
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);
private 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,34 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Demo.Internal;
using CliFx.Demo.Services;
using CliFx.Exceptions;
namespace CliFx.Demo.Commands
{
[Command("book", Description = "View, list, add or remove books.")]
public class BookCommand : ICommand
{
private readonly LibraryService _libraryService;
[CommandParameter(0, Name = "title", Description = "Book title.")]
public string Title { get; set; } = "";
public BookCommand(LibraryService libraryService)
{
_libraryService = libraryService;
}
public ValueTask ExecuteAsync(IConsole console)
{
var book = _libraryService.GetBook(Title);
if (book == null)
throw new CommandException("Book not found.", 1);
console.RenderBook(book);
return default;
}
}
}

View File

@@ -0,0 +1,37 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Demo.Internal;
using CliFx.Demo.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 ValueTask 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 default;
}
}
}

View File

@@ -0,0 +1,35 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Demo.Services;
using CliFx.Exceptions;
namespace CliFx.Demo.Commands
{
[Command("book remove", Description = "Remove a book from the library.")]
public class BookRemoveCommand : ICommand
{
private readonly LibraryService _libraryService;
[CommandParameter(0, Name = "title", Description = "Book title.")]
public string Title { get; set; } = "";
public BookRemoveCommand(LibraryService libraryService)
{
_libraryService = libraryService;
}
public ValueTask 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 default;
}
}
}

View File

@@ -0,0 +1,29 @@
using System;
using CliFx.Demo.Models;
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);
}
}
}

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

@@ -0,0 +1,45 @@
using System;
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, IFormatProvider formatProvider)
{
var components = value.Split('-', 5, StringSplitOptions.RemoveEmptyEntries);
return new Isbn(
int.Parse(components[0], formatProvider),
int.Parse(components[1], formatProvider),
int.Parse(components[2], formatProvider),
int.Parse(components[3], formatProvider),
int.Parse(components[4], formatProvider)
);
}
}
}

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

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

@@ -0,0 +1,35 @@
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 GetServiceProvider()
{
// 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 async Task<int> Main() =>
await new CliApplicationBuilder()
.AddCommandsFromThisAssembly()
.UseTypeActivator(GetServiceProvider().GetService)
.Build()
.RunAsync();
}
}

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, argument 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,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../CliFx.props" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net45</TargetFramework>
<LangVersion>latest</LangVersion>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>
<ItemGroup>

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
using System.Threading.Tasks;
using CliFx.Attributes;
namespace CliFx.Tests.Dummy.Commands
{
[Command]
public class HelloWorldCommand : ICommand
{
[CommandOption("target", EnvironmentVariableName = "ENV_TARGET")]
public string Target { get; set; } = "World";
public ValueTask ExecuteAsync(IConsole console)
{
console.Output.WriteLine($"Hello {Target}!");
return default;
}
}
}

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

@@ -2,8 +2,12 @@
namespace CliFx.Tests.Dummy
{
public static class Program
public class Program
{
public static Task<int> Main(string[] args) => new CliApplication().RunAsync(args);
public static async Task Main() =>
await new CliApplicationBuilder()
.AddCommandsFromThisAssembly()
.Build()
.RunAsync();
}
}

View File

@@ -0,0 +1,45 @@
using NUnit.Framework;
using System;
using System.IO;
using CliFx.Tests.TestCommands;
namespace CliFx.Tests
{
[TestFixture]
public class CliApplicationBuilderTests
{
[Test(Description = "All builder methods must return without exceptions")]
public void 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))
.UseTypeActivator(Activator.CreateInstance)
.Build();
}
[Test(Description = "Builder must be able to produce an application when no parameters are specified")]
public void Build_Test()
{
// Arrange
var builder = new CliApplicationBuilder();
// Act
builder.Build();
}
}
}

View File

@@ -1,33 +1,451 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx.Services;
using CliFx.Tests.TestObjects;
using Moq;
using FluentAssertions;
using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using CliFx.Tests.TestCommands;
namespace CliFx.Tests
{
[TestFixture]
public class CliApplicationTests
{
[Test]
public async Task RunAsync_Test()
private const string TestAppName = "TestApp";
private const string TestVersionText = "v1.0";
private static IEnumerable<TestCaseData> GetTestCases_RunAsync()
{
yield return new TestCaseData(
new[] {typeof(HelloWorldDefaultCommand)},
new string[0],
new Dictionary<string, string>(),
"Hello world."
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"concat", "-i", "foo", "-i", "bar", "-s", " "},
new Dictionary<string, string>(),
"foo bar"
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"concat", "-i", "one", "two", "three", "-s", ", "},
new Dictionary<string, string>(),
"one, two, three"
);
yield return new TestCaseData(
new[] {typeof(DivideCommand)},
new[] {"div", "-D", "24", "-d", "8"},
new Dictionary<string, string>(),
"3"
);
yield return new TestCaseData(
new[] {typeof(HelloWorldDefaultCommand)},
new[] {"--version"},
new Dictionary<string, string>(),
TestVersionText
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"--version"},
new Dictionary<string, string>(),
TestVersionText
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new string[0],
new Dictionary<string, string>(),
null
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"-h"},
new Dictionary<string, string>(),
null
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"--help"},
new Dictionary<string, string>(),
null
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"concat", "-h"},
new Dictionary<string, string>(),
null
);
yield return new TestCaseData(
new[] {typeof(ExceptionCommand)},
new[] {"exc", "-h"},
new Dictionary<string, string>(),
null
);
yield return new TestCaseData(
new[] {typeof(CommandExceptionCommand)},
new[] {"exc", "-h"},
new Dictionary<string, string>(),
null
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"[preview]"},
new Dictionary<string, string>(),
null
);
yield return new TestCaseData(
new[] {typeof(ExceptionCommand)},
new[] {"[preview]", "exc"},
new Dictionary<string, string>(),
null
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"[preview]", "concat", "-o", "value"},
new Dictionary<string, string>(),
null
);
}
private static IEnumerable<TestCaseData> GetTestCases_RunAsync_Negative()
{
yield return new TestCaseData(
new Type[0],
new string[0],
new Dictionary<string, string>(),
null, null
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"non-existing"},
new Dictionary<string, string>(),
null, null
);
yield return new TestCaseData(
new[] {typeof(ExceptionCommand)},
new[] {"exc"},
new Dictionary<string, string>(),
null, null
);
yield return new TestCaseData(
new[] {typeof(CommandExceptionCommand)},
new[] {"exc"},
new Dictionary<string, string>(),
null, null
);
yield return new TestCaseData(
new[] {typeof(CommandExceptionCommand)},
new[] {"exc"},
new Dictionary<string, string>(),
null, null
);
yield return new TestCaseData(
new[] {typeof(CommandExceptionCommand)},
new[] {"exc", "-m", "foo bar"},
new Dictionary<string, string>(),
"foo bar", null
);
yield return new TestCaseData(
new[] {typeof(CommandExceptionCommand)},
new[] {"exc", "-m", "foo bar", "-c", "666"},
new Dictionary<string, string>(),
"foo bar", 666
);
}
private static IEnumerable<TestCaseData> GetTestCases_RunAsync_Help()
{
yield return new TestCaseData(
new[] {typeof(HelpDefaultCommand), typeof(HelpNamedCommand), typeof(HelpSubCommand)},
new[] {"--help"},
new[]
{
TestVersionText,
"Description",
"HelpDefaultCommand description.",
"Usage",
TestAppName, "[command]", "[options]",
"Options",
"-a|--option-a", "OptionA description.",
"-b|--option-b", "OptionB description.",
"-h|--help", "Shows help text.",
"--version", "Shows version information.",
"Commands",
"cmd", "HelpNamedCommand description.",
"You can run", "to show help on a specific command."
}
);
yield return new TestCaseData(
new[] {typeof(HelpSubCommand)},
new[] {"--help"},
new[]
{
TestVersionText,
"Usage",
TestAppName, "[command]",
"Options",
"-h|--help", "Shows help text.",
"--version", "Shows version information.",
"Commands",
"cmd sub", "HelpSubCommand description.",
"You can run", "to show help on a specific command."
}
);
yield return new TestCaseData(
new[] {typeof(HelpDefaultCommand), typeof(HelpNamedCommand), typeof(HelpSubCommand)},
new[] {"cmd", "--help"},
new[]
{
"Description",
"HelpNamedCommand description.",
"Usage",
TestAppName, "cmd", "[command]", "[options]",
"Options",
"-c|--option-c", "OptionC description.",
"-d|--option-d", "OptionD description.",
"-h|--help", "Shows help text.",
"Commands",
"sub", "HelpSubCommand description.",
"You can run", "to show help on a specific command."
}
);
yield return new TestCaseData(
new[] {typeof(HelpDefaultCommand), typeof(HelpNamedCommand), typeof(HelpSubCommand)},
new[] {"cmd", "sub", "--help"},
new[]
{
"Description",
"HelpSubCommand description.",
"Usage",
TestAppName, "cmd sub", "[options]",
"Options",
"-e|--option-e", "OptionE description.",
"-h|--help", "Shows help text."
}
);
yield return new TestCaseData(
new[] {typeof(ParameterCommand)},
new[] {"param", "cmd", "--help"},
new[]
{
"Description",
"Command using positional parameters",
"Usage",
TestAppName, "param cmd", "<first>", "<parameterb>", "<third list...>", "[options]",
"Parameters",
"* first",
"* parameterb",
"* third list", "A list of numbers",
"Options",
"-o|--option",
"-h|--help", "Shows help text."
}
);
yield return new TestCaseData(
new[] {typeof(AllRequiredOptionsCommand)},
new[] {"allrequired", "--help"},
new[]
{
"Description",
"AllRequiredOptionsCommand description.",
"Usage",
TestAppName, "allrequired --option-f <value> --option-g <value>"
}
);
yield return new TestCaseData(
new[] {typeof(SomeRequiredOptionsCommand)},
new[] {"somerequired", "--help"},
new[]
{
"Description",
"SomeRequiredOptionsCommand description.",
"Usage",
TestAppName, "somerequired --option-f <value> [options]"
}
);
yield return new TestCaseData(
new[] {typeof(EnvironmentVariableCommand)},
new[] {"--help"},
new[]
{
"Environment variable:", "ENV_SINGLE_VALUE"
}
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"concat", "--help"},
new[]
{
"Usage",
TestAppName, "concat", "-i", "<values...>", "[options]",
}
);
}
[TestCaseSource(nameof(GetTestCases_RunAsync))]
public async Task RunAsync_Test(
IReadOnlyList<Type> commandTypes,
IReadOnlyList<string> commandLineArguments,
IReadOnlyDictionary<string, string> environmentVariables,
string? expectedStdOut = null)
{
// Arrange
var command = new TestCommand();
var expectedExitCode = await command.ExecuteAsync();
await using var stdOutStream = new StringWriter();
var console = new VirtualConsole(stdOutStream);
var commandResolverMock = new Mock<ICommandResolver>();
commandResolverMock.Setup(m => m.ResolveCommand(It.IsAny<IReadOnlyList<string>>())).Returns(command);
var commandResolver = commandResolverMock.Object;
var application = new CliApplication(commandResolver);
var application = new CliApplicationBuilder()
.AddCommands(commandTypes)
.UseTitle(TestAppName)
.UseExecutableName(TestAppName)
.UseVersionText(TestVersionText)
.UseConsole(console)
.Build();
// Act
var exitCodeValue = await application.RunAsync();
var exitCode = await application.RunAsync(commandLineArguments, environmentVariables);
var stdOut = stdOutStream.ToString().Trim();
// Assert
Assert.That(exitCodeValue, Is.EqualTo(expectedExitCode.Value));
exitCode.Should().Be(0);
stdOut.Should().NotBeNullOrWhiteSpace();
if (expectedStdOut != null)
stdOut.Should().Be(expectedStdOut);
Console.WriteLine(stdOut);
}
[TestCaseSource(nameof(GetTestCases_RunAsync_Negative))]
public async Task RunAsync_Negative_Test(
IReadOnlyList<Type> commandTypes,
IReadOnlyList<string> commandLineArguments,
IReadOnlyDictionary<string, string> environmentVariables,
string? expectedStdErr = null,
int? expectedExitCode = null)
{
// Arrange
await using var stdErrStream = new StringWriter();
var console = new VirtualConsole(TextWriter.Null, stdErrStream);
var application = new CliApplicationBuilder()
.AddCommands(commandTypes)
.UseTitle(TestAppName)
.UseExecutableName(TestAppName)
.UseVersionText(TestVersionText)
.UseConsole(console)
.Build();
// Act
var exitCode = await application.RunAsync(commandLineArguments, environmentVariables);
var stderr = stdErrStream.ToString().Trim();
// Assert
exitCode.Should().NotBe(0);
stderr.Should().NotBeNullOrWhiteSpace();
if (expectedExitCode != null)
exitCode.Should().Be(expectedExitCode);
if (expectedStdErr != null)
stderr.Should().Be(expectedStdErr);
Console.WriteLine(stderr);
}
[TestCaseSource(nameof(GetTestCases_RunAsync_Help))]
public async Task RunAsync_Help_Test(
IReadOnlyList<Type> commandTypes,
IReadOnlyList<string> commandLineArguments,
IReadOnlyList<string>? expectedSubstrings = null)
{
// Arrange
await using var stdOutStream = new StringWriter();
var console = new VirtualConsole(stdOutStream);
var application = new CliApplicationBuilder()
.AddCommands(commandTypes)
.UseTitle(TestAppName)
.UseExecutableName(TestAppName)
.UseVersionText(TestVersionText)
.UseConsole(console)
.Build();
var environmentVariables = new Dictionary<string, string>();
// Act
var exitCode = await application.RunAsync(commandLineArguments, environmentVariables);
var stdOut = stdOutStream.ToString().Trim();
// Assert
exitCode.Should().Be(0);
stdOut.Should().NotBeNullOrWhiteSpace();
if (expectedSubstrings != null)
stdOut.Should().ContainAll(expectedSubstrings);
Console.WriteLine(stdOut);
}
[Test]
public async Task RunAsync_Cancellation_Test()
{
// Arrange
using var cancellationTokenSource = new CancellationTokenSource();
await using var stdOutStream = new StringWriter();
await using var stdErrStream = new StringWriter();
var console = new VirtualConsole(stdOutStream, stdErrStream, cancellationTokenSource.Token);
var application = new CliApplicationBuilder()
.AddCommand(typeof(CancellableCommand))
.UseConsole(console)
.Build();
var commandLineArguments = new[] {"cancel"};
var environmentVariables = new Dictionary<string, string>();
// Act
cancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(0.2));
var exitCode = await application.RunAsync(commandLineArguments, environmentVariables);
var stdOut = stdOutStream.ToString().Trim();
var stdErr = stdErrStream.ToString().Trim();
// Assert
exitCode.Should().NotBe(0);
stdOut.Should().BeNullOrWhiteSpace();
stdErr.Should().NotBeNullOrWhiteSpace();
Console.WriteLine(stdErr);
}
}
}

View File

@@ -1,18 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../CliFx.props" />
<PropertyGroup>
<TargetFramework>net45</TargetFramework>
<TargetFramework>netcoreapp3.1</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<LangVersion>latest</LangVersion>
<CollectCoverage>true</CollectCoverage>
<CoverletOutputFormat>opencover</CoverletOutputFormat>
<CoverletOutput>bin/$(Configuration)/Coverage.xml</CoverletOutput>
</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="CliWrap" Version="2.5.0" />
<PackageReference Include="FluentAssertions" Version="5.10.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.4.0" />
<PackageReference Include="NUnit" Version="3.12.0" />
<PackageReference Include="NUnit3TestAdapter" Version="3.16.1" />
<PackageReference Include="coverlet.msbuild" Version="2.8.0" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
@@ -20,4 +24,8 @@
<ProjectReference Include="..\CliFx\CliFx.csproj" />
</ItemGroup>
<Target Name="Copy dummy's runtime config" AfterTargets="AfterBuild">
<Copy SourceFiles="../CliFx.Tests.Dummy/bin/$(Configuration)/$(TargetFramework)/CliFx.Tests.Dummy.runtimeconfig.json" DestinationFiles="$(OutputPath)CliFx.Tests.Dummy.runtimeconfig.json" />
</Target>
</Project>

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

@@ -0,0 +1,48 @@
using System;
using System.Collections.Generic;
using CliFx.Exceptions;
using CliFx.Tests.TestCommands;
using CliFx.Tests.TestCustomTypes;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests
{
[TestFixture]
public class DefaultCommandFactoryTests
{
private static IEnumerable<TestCaseData> GetTestCases_CreateInstance()
{
yield return new TestCaseData(typeof(HelloWorldDefaultCommand));
}
private static IEnumerable<TestCaseData> GetTestCases_CreateInstance_Negative()
{
yield return new TestCaseData(typeof(TestNonStringParseable));
}
[TestCaseSource(nameof(GetTestCases_CreateInstance))]
public void CreateInstance_Test(Type type)
{
// Arrange
var activator = new DefaultTypeActivator();
// Act
var obj = activator.CreateInstance(type);
// Assert
obj.Should().BeOfType(type);
}
[TestCaseSource(nameof(GetTestCases_CreateInstance_Negative))]
public void CreateInstance_Negative_Test(Type type)
{
// Arrange
var activator = new DefaultTypeActivator();
// Act & Assert
var ex = Assert.Throws<CliFxException>(() => activator.CreateInstance(type));
Console.WriteLine(ex.Message);
}
}
}

View File

@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using CliFx.Exceptions;
using CliFx.Tests.TestCommands;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests
{
[TestFixture]
public class DelegateCommandFactoryTests
{
private static IEnumerable<TestCaseData> GetTestCases_CreateInstance()
{
yield return new TestCaseData(
new Func<Type, object>(Activator.CreateInstance),
typeof(HelloWorldDefaultCommand)
);
}
private static IEnumerable<TestCaseData> GetTestCases_CreateInstance_Negative()
{
yield return new TestCaseData(
new Func<Type, object>(_ => null),
typeof(HelloWorldDefaultCommand)
);
}
[TestCaseSource(nameof(GetTestCases_CreateInstance))]
public void CreateInstance_Test(Func<Type, object> activatorFunc, Type type)
{
// Arrange
var activator = new DelegateTypeActivator(activatorFunc);
// Act
var obj = activator.CreateInstance(type);
// Assert
obj.Should().BeOfType(type);
}
[TestCaseSource(nameof(GetTestCases_CreateInstance_Negative))]
public void CreateInstance_Negative_Test(Func<Type, object> activatorFunc, Type type)
{
// Arrange
var activator = new DelegateTypeActivator(activatorFunc);
// Act & Assert
var ex = Assert.Throws<CliFxException>(() => activator.CreateInstance(type));
Console.WriteLine(ex.Message);
}
}
}

View File

@@ -0,0 +1,888 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using CliFx.Domain;
using CliFx.Exceptions;
using CliFx.Tests.TestCommands;
using CliFx.Tests.TestCustomTypes;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests.Domain
{
[TestFixture]
internal partial class ApplicationSchemaTests
{
private static IEnumerable<TestCaseData> GetTestCases_Resolve()
{
yield return new TestCaseData(
new[]
{
typeof(DivideCommand),
typeof(ConcatCommand),
typeof(EnvironmentVariableCommand)
},
new[]
{
new CommandSchema(typeof(DivideCommand), "div", "Divide one number by another.",
new CommandParameterSchema[0], new[]
{
new CommandOptionSchema(typeof(DivideCommand).GetProperty(nameof(DivideCommand.Dividend)),
"dividend", 'D', null, true, "The number to divide."),
new CommandOptionSchema(typeof(DivideCommand).GetProperty(nameof(DivideCommand.Divisor)),
"divisor", 'd', null, true, "The number to divide by.")
}),
new CommandSchema(typeof(ConcatCommand), "concat", "Concatenate strings.",
new CommandParameterSchema[0],
new[]
{
new CommandOptionSchema(typeof(ConcatCommand).GetProperty(nameof(ConcatCommand.Inputs)),
null, 'i', null, true, "Input strings."),
new CommandOptionSchema(typeof(ConcatCommand).GetProperty(nameof(ConcatCommand.Separator)),
null, 's', null, false, "String separator.")
}),
new CommandSchema(typeof(EnvironmentVariableCommand), null, "Reads option values from environment variables.",
new CommandParameterSchema[0],
new[]
{
new CommandOptionSchema(typeof(EnvironmentVariableCommand).GetProperty(nameof(EnvironmentVariableCommand.Option)),
"opt", null, "ENV_SINGLE_VALUE", false, null)
}
)
}
);
yield return new TestCaseData(
new[] {typeof(SimpleParameterCommand)},
new[]
{
new CommandSchema(typeof(SimpleParameterCommand), "param cmd2", "Command using positional parameters",
new[]
{
new CommandParameterSchema(typeof(SimpleParameterCommand).GetProperty(nameof(SimpleParameterCommand.ParameterA)),
0, "first", null),
new CommandParameterSchema(typeof(SimpleParameterCommand).GetProperty(nameof(SimpleParameterCommand.ParameterB)),
10, null, null)
},
new[]
{
new CommandOptionSchema(typeof(SimpleParameterCommand).GetProperty(nameof(SimpleParameterCommand.OptionA)),
"option", 'o', null, false, null)
})
}
);
yield return new TestCaseData(
new[] {typeof(HelloWorldDefaultCommand)},
new[]
{
new CommandSchema(typeof(HelloWorldDefaultCommand), null, null,
new CommandParameterSchema[0],
new CommandOptionSchema[0])
}
);
}
private static IEnumerable<TestCaseData> GetTestCases_Resolve_Negative()
{
yield return new TestCaseData(new object[]
{
new Type[0]
});
// Command validation failure
yield return new TestCaseData(new object[]
{
new[] {typeof(NonImplementedCommand)}
});
yield return new TestCaseData(new object[]
{
// Same name
new[] {typeof(ExceptionCommand), typeof(CommandExceptionCommand)}
});
yield return new TestCaseData(new object[]
{
new[] {typeof(NonAnnotatedCommand)}
});
// Parameter validation failure
yield return new TestCaseData(new object[]
{
new[] {typeof(DuplicateParameterOrderCommand)}
});
yield return new TestCaseData(new object[]
{
new[] {typeof(DuplicateParameterNameCommand)}
});
yield return new TestCaseData(new object[]
{
new[] {typeof(MultipleNonScalarParametersCommand)}
});
yield return new TestCaseData(new object[]
{
new[] {typeof(NonLastNonScalarParameterCommand)}
});
// Option validation failure
yield return new TestCaseData(new object[]
{
new[] {typeof(DuplicateOptionNamesCommand)}
});
yield return new TestCaseData(new object[]
{
new[] {typeof(DuplicateOptionShortNamesCommand)}
});
yield return new TestCaseData(new object[]
{
new[] {typeof(DuplicateOptionEnvironmentVariableNamesCommand)}
});
}
[Test]
[TestCaseSource(nameof(GetTestCases_Resolve))]
public void Resolve_Test(
IReadOnlyList<Type> commandTypes,
IReadOnlyList<CommandSchema> expectedCommandSchemas)
{
// Act
var applicationSchema = ApplicationSchema.Resolve(commandTypes);
// Assert
applicationSchema.Commands.Should().BeEquivalentTo(expectedCommandSchemas);
}
[Test]
[TestCaseSource(nameof(GetTestCases_Resolve_Negative))]
public void Resolve_Negative_Test(IReadOnlyList<Type> commandTypes)
{
// Act & Assert
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
Console.WriteLine(ex.Message);
}
}
internal partial class ApplicationSchemaTests
{
private static IEnumerable<TestCaseData> GetTestCases_InitializeEntryPoint()
{
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.Object), "value")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {Object = "value"}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.String), "value")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {String = "value"}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.Bool), "true")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {Bool = true}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.Bool), "false")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {Bool = false}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.Bool))
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {Bool = true}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.Char), "a")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {Char = 'a'}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.Sbyte), "15")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {Sbyte = 15}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.Byte), "15")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {Byte = 15}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.Short), "15")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {Short = 15}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.Ushort), "15")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {Ushort = 15}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.Int), "15")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {Int = 15}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.Uint), "15")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {Uint = 15}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.Long), "15")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {Long = 15}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.Ulong), "15")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {Ulong = 15}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.Float), "123.45")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {Float = 123.45f}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.Double), "123.45")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {Double = 123.45}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.Decimal), "123.45")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {Decimal = 123.45m}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.DateTime), "28 Apr 1995")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {DateTime = new DateTime(1995, 04, 28)}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.DateTimeOffset), "28 Apr 1995")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {DateTimeOffset = new DateTime(1995, 04, 28)}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.TimeSpan), "00:14:59")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {TimeSpan = new TimeSpan(00, 14, 59)}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.TestEnum), "value2")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {TestEnum = TestEnum.Value2}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.IntNullable), "666")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {IntNullable = 666}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.IntNullable))
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {IntNullable = null}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.TestEnumNullable), "value3")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {TestEnumNullable = TestEnum.Value3}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.TestEnumNullable))
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {TestEnumNullable = null}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.TimeSpanNullable), "01:00:00")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {TimeSpanNullable = new TimeSpan(01, 00, 00)}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.TimeSpanNullable))
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {TimeSpanNullable = null}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.TestStringConstructable), "value")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {TestStringConstructable = new TestStringConstructable("value")}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.TestStringParseable), "value")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {TestStringParseable = TestStringParseable.Parse("value")}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.TestStringParseableWithFormatProvider), "value")
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand
{
TestStringParseableWithFormatProvider =
TestStringParseableWithFormatProvider.Parse("value", CultureInfo.InvariantCulture)
}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.ObjectArray), new[] {"value1", "value2"})
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {ObjectArray = new object[] {"value1", "value2"}}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.StringArray), new[] {"value1", "value2"})
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {StringArray = new[] {"value1", "value2"}}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.StringArray))
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {StringArray = new string[0]}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.IntArray), new[] {"47", "69"})
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {IntArray = new[] {47, 69}}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.TestEnumArray), new[] {"value1", "value3"})
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {TestEnumArray = new[] {TestEnum.Value1, TestEnum.Value3}}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.IntNullableArray), new[] {"1337", "2441"})
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {IntNullableArray = new int?[] {1337, 2441}}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.TestStringConstructableArray), new[] {"value1", "value2"})
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand
{
TestStringConstructableArray = new[]
{
new TestStringConstructable("value1"),
new TestStringConstructable("value2")
}
}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.Enumerable), new[] {"value1", "value3"})
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {Enumerable = new[] {"value1", "value3"}}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.StringEnumerable), new[] {"value1", "value3"})
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {StringEnumerable = new[] {"value1", "value3"}}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.StringReadOnlyList), new[] {"value1", "value3"})
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {StringReadOnlyList = new[] {"value1", "value3"}}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.StringList), new[] {"value1", "value3"})
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {StringList = new List<string> {"value1", "value3"}}
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[]
{
new CommandOptionInput(nameof(AllSupportedTypesCommand.StringHashSet), new[] {"value1", "value3"})
}),
new Dictionary<string, string>(),
new AllSupportedTypesCommand {StringHashSet = new HashSet<string> {"value1", "value3"}}
);
yield return new TestCaseData(
new[] {typeof(DivideCommand)},
new CommandLineInput(
new[] {"div"},
new[]
{
new CommandOptionInput("dividend", "13"),
new CommandOptionInput("divisor", "8"),
}),
new Dictionary<string, string>(),
new DivideCommand {Dividend = 13, Divisor = 8}
);
yield return new TestCaseData(
new[] {typeof(DivideCommand)},
new CommandLineInput(
new[] {"div"},
new[]
{
new CommandOptionInput("D", "13"),
new CommandOptionInput("d", "8"),
}),
new Dictionary<string, string>(),
new DivideCommand {Dividend = 13, Divisor = 8}
);
yield return new TestCaseData(
new[] {typeof(DivideCommand)},
new CommandLineInput(
new[] {"div"},
new[]
{
new CommandOptionInput("dividend", "13"),
new CommandOptionInput("d", "8"),
}),
new Dictionary<string, string>(),
new DivideCommand {Dividend = 13, Divisor = 8}
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new CommandLineInput(
new[] {"concat"},
new[] {new CommandOptionInput("i", new[] {"foo", " ", "bar"}),}),
new Dictionary<string, string>(),
new ConcatCommand {Inputs = new[] {"foo", " ", "bar"}}
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new CommandLineInput(
new[] {"concat"},
new[]
{
new CommandOptionInput("i", new[] {"foo", "bar"}),
new CommandOptionInput("s", " "),
}),
new Dictionary<string, string>(),
new ConcatCommand {Inputs = new[] {"foo", "bar"}, Separator = " "}
);
yield return new TestCaseData(
new[] {typeof(EnvironmentVariableCommand)},
CommandLineInput.Empty,
new Dictionary<string, string>
{
["ENV_SINGLE_VALUE"] = "A"
},
new EnvironmentVariableCommand {Option = "A"}
);
yield return new TestCaseData(
new[] {typeof(EnvironmentVariableWithMultipleValuesCommand)},
CommandLineInput.Empty,
new Dictionary<string, string>
{
["ENV_MULTIPLE_VALUES"] = string.Join(Path.PathSeparator, "A", "B", "C")
},
new EnvironmentVariableWithMultipleValuesCommand {Option = new[] {"A", "B", "C"}}
);
yield return new TestCaseData(
new[] {typeof(EnvironmentVariableCommand)},
new CommandLineInput(new[] {new CommandOptionInput("opt", "X")}),
new Dictionary<string, string>
{
["ENV_SINGLE_VALUE"] = "A"
},
new EnvironmentVariableCommand {Option = "X"}
);
yield return new TestCaseData(
new[] {typeof(EnvironmentVariableWithoutCollectionPropertyCommand)},
CommandLineInput.Empty,
new Dictionary<string, string>
{
["ENV_MULTIPLE_VALUES"] = string.Join(Path.PathSeparator, "A", "B", "C")
},
new EnvironmentVariableWithoutCollectionPropertyCommand {Option = string.Join(Path.PathSeparator, "A", "B", "C")}
);
yield return new TestCaseData(
new[] {typeof(ParameterCommand)},
new CommandLineInput(
new[] {"param", "cmd", "abc", "123", "1", "2"},
new[] {new CommandOptionInput("o", "option value")}),
new Dictionary<string, string>(),
new ParameterCommand
{
ParameterA = "abc",
ParameterB = 123,
ParameterC = new[] {1, 2},
OptionA = "option value"
}
);
}
private static IEnumerable<TestCaseData> GetTestCases_InitializeEntryPoint_Negative()
{
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[] {new CommandOptionInput(nameof(AllSupportedTypesCommand.Int), "1234.5")}),
new Dictionary<string, string>()
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[] {new CommandOptionInput(nameof(AllSupportedTypesCommand.Int), new[] {"123", "456"})}),
new Dictionary<string, string>()
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[] {new CommandOptionInput(nameof(AllSupportedTypesCommand.Int))}),
new Dictionary<string, string>()
);
yield return new TestCaseData(
new[] {typeof(AllSupportedTypesCommand)},
new CommandLineInput(
new[] {new CommandOptionInput(nameof(AllSupportedTypesCommand.NonConvertible), "123")}),
new Dictionary<string, string>()
);
yield return new TestCaseData(
new[] {typeof(DivideCommand)},
new CommandLineInput(new[] {"div"}),
new Dictionary<string, string>()
);
yield return new TestCaseData(
new[] {typeof(DivideCommand)},
new CommandLineInput(new[] {"div", "-D", "13"}),
new Dictionary<string, string>()
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new CommandLineInput(new[] {"concat"}),
new Dictionary<string, string>()
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new CommandLineInput(
new[] {"concat"},
new[] {new CommandOptionInput("s", "_")}),
new Dictionary<string, string>()
);
yield return new TestCaseData(
new[] {typeof(ParameterCommand)},
new CommandLineInput(
new[] {"param", "cmd"},
new[] {new CommandOptionInput("o", "option value")}),
new Dictionary<string, string>()
);
yield return new TestCaseData(
new[] {typeof(ParameterCommand)},
new CommandLineInput(
new[] {"param", "cmd", "abc", "123", "invalid"},
new[] {new CommandOptionInput("o", "option value")}),
new Dictionary<string, string>()
);
yield return new TestCaseData(
new[] {typeof(DivideCommand)},
new CommandLineInput(new[] {"non-existing"}),
new Dictionary<string, string>()
);
yield return new TestCaseData(
new[] {typeof(BrokenEnumerableCommand)},
new CommandLineInput(new[] {"value1", "value2"}),
new Dictionary<string, string>()
);
}
[TestCaseSource(nameof(GetTestCases_InitializeEntryPoint))]
public void InitializeEntryPoint_Test(
IReadOnlyList<Type> commandTypes,
CommandLineInput commandLineInput,
IReadOnlyDictionary<string, string> environmentVariables,
ICommand expectedResult)
{
// Arrange
var applicationSchema = ApplicationSchema.Resolve(commandTypes);
var typeActivator = new DefaultTypeActivator();
// Act
var command = applicationSchema.InitializeEntryPoint(commandLineInput, environmentVariables, typeActivator);
// Assert
command.Should().BeEquivalentTo(expectedResult, o => o.RespectingRuntimeTypes());
}
[TestCaseSource(nameof(GetTestCases_InitializeEntryPoint_Negative))]
public void InitializeEntryPoint_Negative_Test(
IReadOnlyList<Type> commandTypes,
CommandLineInput commandLineInput,
IReadOnlyDictionary<string, string> environmentVariables)
{
// Arrange
var applicationSchema = ApplicationSchema.Resolve(commandTypes);
var typeActivator = new DefaultTypeActivator();
// Act & Assert
var ex = Assert.Throws<CliFxException>(() =>
applicationSchema.InitializeEntryPoint(commandLineInput, environmentVariables, typeActivator));
Console.WriteLine(ex.Message);
}
}
}

View File

@@ -0,0 +1,264 @@
using System.Collections.Generic;
using CliFx.Domain;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests.Domain
{
[TestFixture]
internal class CommandLineInputTests
{
private static IEnumerable<TestCaseData> GetTestCases_Parse()
{
yield return new TestCaseData(
new string[0],
CommandLineInput.Empty
);
yield return new TestCaseData(
new[] {"param"},
new CommandLineInput(
new[] {"param"})
);
yield return new TestCaseData(
new[] {"cmd", "param"},
new CommandLineInput(
new[] {"cmd", "param"})
);
yield return new TestCaseData(
new[] {"--option", "value"},
new CommandLineInput(
new[]
{
new CommandOptionInput("option", "value")
})
);
yield return new TestCaseData(
new[] {"--option1", "value1", "--option2", "value2"},
new CommandLineInput(
new[]
{
new CommandOptionInput("option1", "value1"),
new CommandOptionInput("option2", "value2")
})
);
yield return new TestCaseData(
new[] {"--option", "value1", "value2"},
new CommandLineInput(
new[]
{
new CommandOptionInput("option", new[] {"value1", "value2"})
})
);
yield return new TestCaseData(
new[] {"--option", "value1", "--option", "value2"},
new CommandLineInput(
new[]
{
new CommandOptionInput("option", new[] {"value1", "value2"})
})
);
yield return new TestCaseData(
new[] {"-a", "value"},
new CommandLineInput(
new[]
{
new CommandOptionInput("a", "value")
})
);
yield return new TestCaseData(
new[] {"-a", "value1", "-b", "value2"},
new CommandLineInput(
new[]
{
new CommandOptionInput("a", "value1"),
new CommandOptionInput("b", "value2")
})
);
yield return new TestCaseData(
new[] {"-a", "value1", "value2"},
new CommandLineInput(
new[]
{
new CommandOptionInput("a", new[] {"value1", "value2"})
})
);
yield return new TestCaseData(
new[] {"-a", "value1", "-a", "value2"},
new CommandLineInput(
new[]
{
new CommandOptionInput("a", new[] {"value1", "value2"})
})
);
yield return new TestCaseData(
new[] {"--option1", "value1", "-b", "value2"},
new CommandLineInput(
new[]
{
new CommandOptionInput("option1", "value1"),
new CommandOptionInput("b", "value2")
})
);
yield return new TestCaseData(
new[] {"--switch"},
new CommandLineInput(
new[]
{
new CommandOptionInput("switch")
})
);
yield return new TestCaseData(
new[] {"--switch1", "--switch2"},
new CommandLineInput(
new[]
{
new CommandOptionInput("switch1"),
new CommandOptionInput("switch2")
})
);
yield return new TestCaseData(
new[] {"-s"},
new CommandLineInput(
new[]
{
new CommandOptionInput("s")
})
);
yield return new TestCaseData(
new[] {"-a", "-b"},
new CommandLineInput(
new[]
{
new CommandOptionInput("a"),
new CommandOptionInput("b")
})
);
yield return new TestCaseData(
new[] {"-ab"},
new CommandLineInput(
new[]
{
new CommandOptionInput("a"),
new CommandOptionInput("b")
})
);
yield return new TestCaseData(
new[] {"-ab", "value"},
new CommandLineInput(
new[]
{
new CommandOptionInput("a"),
new CommandOptionInput("b", "value")
})
);
yield return new TestCaseData(
new[] {"cmd", "--option", "value"},
new CommandLineInput(
new[] {"cmd"},
new[]
{
new CommandOptionInput("option", "value")
})
);
yield return new TestCaseData(
new[] {"[debug]"},
new CommandLineInput(
new[] {"debug"},
new string[0],
new CommandOptionInput[0])
);
yield return new TestCaseData(
new[] {"[debug]", "[preview]"},
new CommandLineInput(
new[] {"debug", "preview"},
new string[0],
new CommandOptionInput[0])
);
yield return new TestCaseData(
new[] {"cmd", "param1", "param2", "--option", "value"},
new CommandLineInput(
new[] {"cmd", "param1", "param2"},
new[]
{
new CommandOptionInput("option", "value")
})
);
yield return new TestCaseData(
new[] {"[debug]", "[preview]", "-o", "value"},
new CommandLineInput(
new[] {"debug", "preview"},
new string[0],
new[]
{
new CommandOptionInput("o", "value")
})
);
yield return new TestCaseData(
new[] {"cmd", "[debug]", "[preview]", "-o", "value"},
new CommandLineInput(
new[] {"debug", "preview"},
new[] {"cmd"},
new[]
{
new CommandOptionInput("o", "value")
})
);
yield return new TestCaseData(
new[] {"cmd", "[debug]", "[preview]", "-o", "value"},
new CommandLineInput(
new[] {"debug", "preview"},
new[] {"cmd"},
new[]
{
new CommandOptionInput("o", "value")
})
);
yield return new TestCaseData(
new[] {"cmd", "param", "[debug]", "[preview]", "-o", "value"},
new CommandLineInput(
new[] {"debug", "preview"},
new[] {"cmd", "param"},
new[]
{
new CommandOptionInput("o", "value")
})
);
}
[Test]
[TestCaseSource(nameof(GetTestCases_Parse))]
public void Parse_Test(IReadOnlyList<string> commandLineArguments, CommandLineInput expectedResult)
{
// Act
var result = CommandLineInput.Parse(commandLineArguments);
// Assert
result.Should().BeEquivalentTo(expectedResult);
}
}
}

View File

@@ -1,6 +1,10 @@
using System.IO;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using CliWrap;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests
@@ -8,25 +12,71 @@ namespace CliFx.Tests
[TestFixture]
public class DummyTests
{
private string DummyFilePath => Path.Combine(TestContext.CurrentContext.TestDirectory, "CliFx.Tests.Dummy.exe");
private static Assembly DummyAssembly { get; } = typeof(Dummy.Program).Assembly;
[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)
private static IEnumerable<TestCaseData> GetTestCases_RunAsync()
{
yield return new TestCaseData(
new[] {"--version"},
new Dictionary<string, string>(),
$"v{DummyAssembly.GetName().Version}"
);
yield return new TestCaseData(
new string[0],
new Dictionary<string, string>(),
"Hello World!"
);
yield return new TestCaseData(
new[] {"--target", "Earth"},
new Dictionary<string, string>(),
"Hello Earth!"
);
yield return new TestCaseData(
new string[0],
new Dictionary<string, string>
{
["ENV_TARGET"] = "Mars"
},
"Hello Mars!"
);
yield return new TestCaseData(
new[] {"--target", "Earth"},
new Dictionary<string, string>
{
["ENV_TARGET"] = "Mars"
},
"Hello Earth!"
);
}
[TestCaseSource(nameof(GetTestCases_RunAsync))]
public async Task RunAsync_Test(
IReadOnlyList<string> arguments,
IReadOnlyDictionary<string, string> environmentVariables,
string expectedStdOut)
{
// Arrange
var cli = Cli.Wrap("dotnet")
.SetArguments(arguments.Prepend(DummyAssembly.Location).ToArray())
.EnableExitCodeValidation()
.EnableStandardErrorValidation()
.SetStandardOutputCallback(Console.WriteLine)
.SetStandardErrorCallback(Console.WriteLine);
foreach (var (key, value) in environmentVariables)
cli.SetEnvironmentVariable(key, value);
// Act
var result = await Cli.Wrap(DummyFilePath).SetArguments(arguments).ExecuteAsync();
var result = await cli.ExecuteAsync();
// Assert
Assert.That(result.ExitCode, Is.Zero);
Assert.That(result.StandardOutput.Trim(), Is.EqualTo(expectedOutput));
Assert.That(result.StandardError.Trim(), Is.Empty);
result.ExitCode.Should().Be(0);
result.StandardError.Should().BeNullOrWhiteSpace();
result.StandardOutput.TrimEnd().Should().Be(expectedStdOut);
}
}
}

View File

@@ -0,0 +1,39 @@
using System;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests
{
[TestFixture]
public class SystemConsoleTests
{
[TearDown]
public void TearDown()
{
// Reset console color so it doesn't carry on into the next tests
Console.ResetColor();
}
[Test(Description = "Must be in sync with system console")]
public void 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,17 @@
using System.Threading.Tasks;
using CliFx.Attributes;
namespace CliFx.Tests.TestCommands
{
[Command("allrequired", Description = "AllRequiredOptionsCommand description.")]
public class AllRequiredOptionsCommand : ICommand
{
[CommandOption("option-f", 'f', IsRequired = true, Description = "OptionF description.")]
public string? OptionF { get; set; }
[CommandOption("option-g", 'g', IsRequired = true, Description = "OptionG description.")]
public string? OptionFG { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
}

View File

@@ -0,0 +1,126 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Tests.TestCustomTypes;
namespace CliFx.Tests.TestCommands
{
[Command]
public class AllSupportedTypesCommand : ICommand
{
[CommandOption(nameof(Object))]
public object? Object { get; set; } = 42;
[CommandOption(nameof(String))]
public string? String { get; set; } = "foo bar";
[CommandOption(nameof(Bool))]
public bool Bool { get; set; }
[CommandOption(nameof(Char))]
public char Char { get; set; }
[CommandOption(nameof(Sbyte))]
public sbyte Sbyte { get; set; }
[CommandOption(nameof(Byte))]
public byte Byte { get; set; }
[CommandOption(nameof(Short))]
public short Short { get; set; }
[CommandOption(nameof(Ushort))]
public ushort Ushort { get; set; }
[CommandOption(nameof(Int))]
public int Int { get; set; }
[CommandOption(nameof(Uint))]
public uint Uint { get; set; }
[CommandOption(nameof(Long))]
public long Long { get; set; }
[CommandOption(nameof(Ulong))]
public ulong Ulong { get; set; }
[CommandOption(nameof(Float))]
public float Float { get; set; }
[CommandOption(nameof(Double))]
public double Double { get; set; }
[CommandOption(nameof(Decimal))]
public decimal Decimal { get; set; }
[CommandOption(nameof(DateTime))]
public DateTime DateTime { get; set; }
[CommandOption(nameof(DateTimeOffset))]
public DateTimeOffset DateTimeOffset { get; set; }
[CommandOption(nameof(TimeSpan))]
public TimeSpan TimeSpan { get; set; }
[CommandOption(nameof(TestEnum))]
public TestEnum TestEnum { get; set; }
[CommandOption(nameof(IntNullable))]
public int? IntNullable { get; set; }
[CommandOption(nameof(TestEnumNullable))]
public TestEnum? TestEnumNullable { get; set; }
[CommandOption(nameof(TimeSpanNullable))]
public TimeSpan? TimeSpanNullable { get; set; }
[CommandOption(nameof(TestStringConstructable))]
public TestStringConstructable? TestStringConstructable { get; set; }
[CommandOption(nameof(TestStringParseable))]
public TestStringParseable? TestStringParseable { get; set; }
[CommandOption(nameof(TestStringParseableWithFormatProvider))]
public TestStringParseableWithFormatProvider? TestStringParseableWithFormatProvider { get; set; }
[CommandOption(nameof(ObjectArray))]
public object[]? ObjectArray { get; set; }
[CommandOption(nameof(StringArray))]
public string[]? StringArray { get; set; }
[CommandOption(nameof(IntArray))]
public int[]? IntArray { get; set; }
[CommandOption(nameof(TestEnumArray))]
public TestEnum[]? TestEnumArray { get; set; }
[CommandOption(nameof(IntNullableArray))]
public int?[]? IntNullableArray { get; set; }
[CommandOption(nameof(TestStringConstructableArray))]
public TestStringConstructable[]? TestStringConstructableArray { get; set; }
[CommandOption(nameof(Enumerable))]
public IEnumerable? Enumerable { get; set; }
[CommandOption(nameof(StringEnumerable))]
public IEnumerable<string>? StringEnumerable { get; set; }
[CommandOption(nameof(StringReadOnlyList))]
public IReadOnlyList<string>? StringReadOnlyList { get; set; }
[CommandOption(nameof(StringList))]
public List<string>? StringList { get; set; }
[CommandOption(nameof(StringHashSet))]
public HashSet<string>? StringHashSet { get; set; }
[CommandOption(nameof(NonConvertible))]
public TestNonStringParseable? NonConvertible { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
}

View File

@@ -0,0 +1,15 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Tests.TestCustomTypes;
namespace CliFx.Tests.TestCommands
{
[Command]
public class BrokenEnumerableCommand : ICommand
{
[CommandParameter(0)]
public TestCustomEnumerable<string>? Test { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
}

View File

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

View File

@@ -0,0 +1,18 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Exceptions;
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 ValueTask ExecuteAsync(IConsole console) => throw new CommandException(Message, ExitCode);
}
}

View File

@@ -0,0 +1,22 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx.Attributes;
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 ValueTask ExecuteAsync(IConsole console)
{
console.Output.WriteLine(string.Join(Separator, Inputs));
return default;
}
}
}

View File

@@ -0,0 +1,24 @@
using System.Threading.Tasks;
using CliFx.Attributes;
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 ValueTask ExecuteAsync(IConsole console)
{
console.Output.WriteLine(Dividend / Divisor);
return default;
}
}
}

View File

@@ -0,0 +1,17 @@
using System.Threading.Tasks;
using CliFx.Attributes;
namespace CliFx.Tests.TestCommands
{
[Command]
public class DuplicateOptionEnvironmentVariableNamesCommand : ICommand
{
[CommandOption("option-a", EnvironmentVariableName = "ENV_VAR")]
public string? OptionA { get; set; }
[CommandOption("option-b", EnvironmentVariableName = "ENV_VAR")]
public string? OptionB { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
}

View File

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

View File

@@ -0,0 +1,17 @@
using System.Threading.Tasks;
using CliFx.Attributes;
namespace CliFx.Tests.TestCommands
{
[Command]
public class DuplicateOptionShortNamesCommand : ICommand
{
[CommandOption('x')]
public string? OptionA { get; set; }
[CommandOption('x')]
public string? OptionB { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
}

View File

@@ -0,0 +1,17 @@
using System.Threading.Tasks;
using CliFx.Attributes;
namespace CliFx.Tests.TestCommands
{
[Command]
public class DuplicateParameterNameCommand : ICommand
{
[CommandParameter(0, Name = "param")]
public string? ParameterA { get; set; }
[CommandParameter(1, Name = "param")]
public string? ParameterB { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
}

View File

@@ -0,0 +1,17 @@
using System.Threading.Tasks;
using CliFx.Attributes;
namespace CliFx.Tests.TestCommands
{
[Command]
public class DuplicateParameterOrderCommand : ICommand
{
[CommandParameter(13)]
public string? ParameterA { get; set; }
[CommandParameter(13)]
public string? ParameterB { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
}

View File

@@ -0,0 +1,14 @@
using System.Threading.Tasks;
using CliFx.Attributes;
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 ValueTask ExecuteAsync(IConsole console) => default;
}
}

View File

@@ -0,0 +1,15 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx.Attributes;
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 ValueTask ExecuteAsync(IConsole console) => default;
}
}

View File

@@ -0,0 +1,14 @@
using System.Threading.Tasks;
using CliFx.Attributes;
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 ValueTask ExecuteAsync(IConsole console) => default;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
using System.Threading.Tasks;
using CliFx.Attributes;
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 ValueTask ExecuteAsync(IConsole console) => default;
}
}

View File

@@ -0,0 +1,17 @@
using System.Threading.Tasks;
using CliFx.Attributes;
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 ValueTask ExecuteAsync(IConsole console) => default;
}
}

View File

@@ -0,0 +1,14 @@
using System.Threading.Tasks;
using CliFx.Attributes;
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 ValueTask ExecuteAsync(IConsole console) => default;
}
}

View File

@@ -0,0 +1,18 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx.Attributes;
namespace CliFx.Tests.TestCommands
{
[Command]
public class MultipleNonScalarParametersCommand : ICommand
{
[CommandParameter(0)]
public IReadOnlyList<string>? ParameterA { get; set; }
[CommandParameter(1)]
public IReadOnlyList<string>? ParameterB { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,18 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx.Attributes;
namespace CliFx.Tests.TestCommands
{
[Command]
public class NonLastNonScalarParameterCommand : ICommand
{
[CommandParameter(0)]
public IReadOnlyList<string>? ParameterA { get; set; }
[CommandParameter(1)]
public string? ParameterB { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
}

View File

@@ -0,0 +1,24 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx.Attributes;
namespace CliFx.Tests.TestCommands
{
[Command("param cmd", Description = "Command using positional parameters")]
public class ParameterCommand : ICommand
{
[CommandParameter(0, Name = "first")]
public string? ParameterA { get; set; }
[CommandParameter(10)]
public int? ParameterB { get; set; }
[CommandParameter(20, Description = "A list of numbers", Name = "third list")]
public IEnumerable<int>? ParameterC { get; set; }
[CommandOption("option", 'o')]
public string? OptionA { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
}

View File

@@ -0,0 +1,20 @@
using System.Threading.Tasks;
using CliFx.Attributes;
namespace CliFx.Tests.TestCommands
{
[Command("param cmd2", Description = "Command using positional parameters")]
public class SimpleParameterCommand : ICommand
{
[CommandParameter(0, Name = "first")]
public string? ParameterA { get; set; }
[CommandParameter(10)]
public int? ParameterB { get; set; }
[CommandOption("option", 'o')]
public string? OptionA { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
}

View File

@@ -0,0 +1,17 @@
using System.Threading.Tasks;
using CliFx.Attributes;
namespace CliFx.Tests.TestCommands
{
[Command("somerequired", Description = "SomeRequiredOptionsCommand description.")]
public class SomeRequiredOptionsCommand : ICommand
{
[CommandOption("option-f", 'f', IsRequired = true, Description = "OptionF description.")]
public string? OptionF { get; set; }
[CommandOption("option-g", 'g', Description = "OptionG description.")]
public string? OptionFG { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
}

View File

@@ -0,0 +1,14 @@
using System.Collections;
using System.Collections.Generic;
namespace CliFx.Tests.TestCustomTypes
{
public class TestCustomEnumerable<T> : IEnumerable<T>
{
private readonly T[] _arr = new T[0];
public IEnumerator<T> GetEnumerator() => ((IEnumerable<T>) _arr).GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
}

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,54 @@
using System.Globalization;
using System.IO;
using System.Linq;
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

@@ -0,0 +1,40 @@
using System;
using System.IO;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests
{
[TestFixture]
public class VirtualConsoleTests
{
[Test(Description = "Must not leak to system console")]
public void 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);
}
}
}

11
CliFx.props Normal file
View File

@@ -0,0 +1,11 @@
<Project>
<PropertyGroup>
<Version>1.0</Version>
<Company>Tyrrrz</Company>
<Copyright>Copyright (C) Alexey Golub</Copyright>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -7,15 +7,20 @@ 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
License.txt = License.txt
Readme.md = Readme.md
CliFx.props = CliFx.props
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx.Benchmarks", "CliFx.Benchmarks\CliFx.Benchmarks.csproj", "{8ACD6DC2-D768-4850-9223-5B7C83A78513}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx.Demo", "CliFx.Demo\CliFx.Demo.csproj", "{AAB6844C-BF71-448F-A11B-89AEE459AB15}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CliFx.Tests.Dummy", "CliFx.Tests.Dummy\CliFx.Tests.Dummy.csproj", "{F717347D-8656-44DA-A4A2-BE515E8C4655}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -50,18 +55,42 @@ 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
{F717347D-8656-44DA-A4A2-BE515E8C4655}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F717347D-8656-44DA-A4A2-BE515E8C4655}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F717347D-8656-44DA-A4A2-BE515E8C4655}.Debug|x64.ActiveCfg = Debug|Any CPU
{F717347D-8656-44DA-A4A2-BE515E8C4655}.Debug|x64.Build.0 = Debug|Any CPU
{F717347D-8656-44DA-A4A2-BE515E8C4655}.Debug|x86.ActiveCfg = Debug|Any CPU
{F717347D-8656-44DA-A4A2-BE515E8C4655}.Debug|x86.Build.0 = Debug|Any CPU
{F717347D-8656-44DA-A4A2-BE515E8C4655}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F717347D-8656-44DA-A4A2-BE515E8C4655}.Release|Any CPU.Build.0 = Release|Any CPU
{F717347D-8656-44DA-A4A2-BE515E8C4655}.Release|x64.ActiveCfg = Release|Any CPU
{F717347D-8656-44DA-A4A2-BE515E8C4655}.Release|x64.Build.0 = Release|Any CPU
{F717347D-8656-44DA-A4A2-BE515E8C4655}.Release|x86.ActiveCfg = Release|Any CPU
{F717347D-8656-44DA-A4A2-BE515E8C4655}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@@ -0,0 +1,38 @@
using System;
using System.Collections.Generic;
namespace CliFx
{
/// <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
{
/// <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

@@ -2,14 +2,38 @@
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.
/// If the name is not set, the command is treated as a default command, i.e. the one that gets executed when the user
/// does not specify a command name in the arguments.
/// All commands in an application must have different names. Likewise, only one command without a name is allowed.
/// </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,72 @@
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.
/// All options in a command must have different names (comparison is not case-sensitive).
/// </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.
/// All options in a command must have different short names (comparison is case-sensitive).
/// </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>
/// Environment variable that will be used as fallback 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

@@ -0,0 +1,37 @@
using System;
namespace CliFx.Attributes
{
/// <summary>
/// Annotates a property that defines a command parameter.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public class CommandParameterAttribute : Attribute
{
/// <summary>
/// Order of this parameter compared to other parameters.
/// All parameters in a command must have different order.
/// Parameter whose type is a non-scalar (e.g. array), must be the last in order and only one such parameter is allowed.
/// </summary>
public int Order { get; }
/// <summary>
/// Parameter name, which is only used in help text.
/// If this isn't specified, property name is used instead.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// Parameter description, which is used in help text.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Initializes an instance of <see cref="CommandParameterAttribute"/>.
/// </summary>
public CommandParameterAttribute(int order)
{
Order = order;
}
}
}

View File

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

View File

@@ -0,0 +1,324 @@
using System;
using System.Linq;
using CliFx.Domain;
using CliFx.Internal;
namespace CliFx
{
public partial class CliApplication
{
private void RenderHelp(ApplicationSchema applicationSchema, CommandSchema command)
{
var column = 0;
var row = 0;
var childCommands = applicationSchema.GetChildCommands(command.Name);
bool IsEmpty() => column == 0 && row == 0;
void Render(string text)
{
_console.Output.Write(text);
column += text.Length;
}
void RenderNewLine()
{
_console.Output.WriteLine();
column = 0;
row++;
}
void RenderMargin(int lines = 1)
{
if (!IsEmpty())
{
for (var i = 0; i < lines; i++)
RenderNewLine();
}
}
void RenderIndent(int spaces = 2)
{
Render(' '.Repeat(spaces));
}
void RenderColumnIndent(int spaces = 20, int margin = 2)
{
if (column + margin < spaces)
{
RenderIndent(spaces - column);
}
else
{
Render(" ");
}
}
void RenderWithColor(string text, ConsoleColor foregroundColor)
{
_console.WithForegroundColor(foregroundColor, () => Render(text));
}
void RenderHeader(string text)
{
RenderWithColor(text, ConsoleColor.Magenta);
RenderNewLine();
}
void RenderApplicationInfo()
{
if (!command.IsDefault)
return;
// Title and version
RenderWithColor(_metadata.Title, ConsoleColor.Yellow);
Render(" ");
RenderWithColor(_metadata.VersionText, ConsoleColor.Yellow);
RenderNewLine();
// Description
if (!string.IsNullOrWhiteSpace(_metadata.Description))
{
Render(_metadata.Description);
RenderNewLine();
}
}
void RenderDescription()
{
if (string.IsNullOrWhiteSpace(command.Description))
return;
RenderMargin();
RenderHeader("Description");
RenderIndent();
Render(command.Description);
RenderNewLine();
}
void RenderUsage()
{
RenderMargin();
RenderHeader("Usage");
// Exe name
RenderIndent();
Render(_metadata.ExecutableName);
// Command name
if (!string.IsNullOrWhiteSpace(command.Name))
{
Render(" ");
RenderWithColor(command.Name, ConsoleColor.Cyan);
}
// Child command placeholder
if (childCommands.Any())
{
Render(" ");
RenderWithColor("[command]", ConsoleColor.Cyan);
}
// Parameters
foreach (var parameter in command.Parameters)
{
Render(" ");
Render(parameter.IsScalar
? $"<{parameter.DisplayName}>"
: $"<{parameter.DisplayName}...>");
}
// Required options
var requiredOptionSchemas = command.Options
.Where(o => o.IsRequired)
.ToArray();
foreach (var option in requiredOptionSchemas)
{
Render(" ");
if (!string.IsNullOrWhiteSpace(option.Name))
{
RenderWithColor($"--{option.Name}", ConsoleColor.White);
Render(" ");
Render(option.IsScalar
? "<value>"
: "<values...>");
}
else
{
RenderWithColor($"-{option.ShortName}", ConsoleColor.White);
Render(" ");
Render(option.IsScalar
? "<value>"
: "<values...>");
}
}
// Options placeholder
if (command.Options.Count != requiredOptionSchemas.Length)
{
Render(" ");
RenderWithColor("[options]", ConsoleColor.White);
}
RenderNewLine();
}
void RenderParameters()
{
if (!command.Parameters.Any())
return;
RenderMargin();
RenderHeader("Parameters");
var parameters = command.Parameters
.OrderBy(p => p.Order)
.ToArray();
foreach (var parameter in parameters)
{
RenderWithColor("* ", ConsoleColor.Red);
RenderWithColor($"{parameter.DisplayName}", ConsoleColor.White);
RenderColumnIndent();
// Description
if (!string.IsNullOrWhiteSpace(parameter.Description))
{
Render(parameter.Description);
}
RenderNewLine();
}
}
void RenderOptions()
{
RenderMargin();
RenderHeader("Options");
var options = command.Options
.OrderByDescending(o => o.IsRequired)
.ToList();
// Add built-in options
options.Add(CommandOptionSchema.HelpOption);
if (command.IsDefault)
options.Add(CommandOptionSchema.VersionOption);
foreach (var option in options)
{
if (option.IsRequired)
{
RenderWithColor("* ", ConsoleColor.Red);
}
else
{
RenderIndent();
}
// Short name
if (option.ShortName != null)
{
RenderWithColor($"-{option.ShortName}", ConsoleColor.White);
}
// Delimiter
if (!string.IsNullOrWhiteSpace(option.Name) && option.ShortName != null)
{
Render("|");
}
// Name
if (!string.IsNullOrWhiteSpace(option.Name))
{
RenderWithColor($"--{option.Name}", ConsoleColor.White);
}
RenderColumnIndent();
// Description
if (!string.IsNullOrWhiteSpace(option.Description))
{
Render(option.Description);
Render(" ");
}
// Environment variable
if (!string.IsNullOrWhiteSpace(option.EnvironmentVariableName))
{
Render($"(Environment variable: {option.EnvironmentVariableName}).");
Render(" ");
}
RenderNewLine();
}
}
void RenderChildCommands()
{
if (!childCommands.Any())
return;
RenderMargin();
RenderHeader("Commands");
foreach (var childCommand in childCommands)
{
var relativeCommandName =
string.IsNullOrWhiteSpace(childCommand.Name) || string.IsNullOrWhiteSpace(command.Name)
? childCommand.Name
: childCommand.Name.Substring(command.Name.Length + 1);
// Name
RenderIndent();
RenderWithColor(relativeCommandName, ConsoleColor.Cyan);
// Description
if (!string.IsNullOrWhiteSpace(childCommand.Description))
{
RenderColumnIndent();
Render(childCommand.Description);
}
RenderNewLine();
}
RenderMargin();
// Child command help tip
Render("You can run `");
Render(_metadata.ExecutableName);
if (!string.IsNullOrWhiteSpace(command.Name))
{
Render(" ");
RenderWithColor(command.Name, ConsoleColor.Cyan);
}
Render(" ");
RenderWithColor("[command]", ConsoleColor.Cyan);
Render(" ");
RenderWithColor("--help", ConsoleColor.White);
Render("` to show help on a specific command.");
RenderNewLine();
}
_console.ResetColor();
RenderApplicationInfo();
RenderDescription();
RenderUsage();
RenderParameters();
RenderOptions();
RenderChildCommands();
}
}
}

View File

@@ -1,45 +1,205 @@
using System.Collections.Generic;
using System.Reflection;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Services;
using CliFx.Domain;
using CliFx.Exceptions;
namespace CliFx
{
public partial class CliApplication : ICliApplication
{
private readonly ICommandResolver _commandResolver;
public CliApplication(ICommandResolver commandResolver)
{
_commandResolver = commandResolver;
}
public CliApplication()
: this(GetDefaultCommandResolver(Assembly.GetCallingAssembly()))
{
}
public async Task<int> RunAsync(IReadOnlyList<string> commandLineArguments)
{
// Resolve and execute command
var command = _commandResolver.ResolveCommand(commandLineArguments);
var exitCode = await command.ExecuteAsync();
// TODO: print message if error?
return exitCode.Value;
}
}
/// <summary>
/// Command line application facade.
/// </summary>
public partial class CliApplication
{
private static ICommandResolver GetDefaultCommandResolver(Assembly assembly)
{
var typeProvider = TypeProvider.FromAssembly(assembly);
var commandOptionParser = new CommandOptionParser();
var commandOptionConverter = new CommandOptionConverter();
private readonly ApplicationMetadata _metadata;
private readonly ApplicationConfiguration _configuration;
private readonly IConsole _console;
private readonly ITypeActivator _typeActivator;
return new CommandResolver(typeProvider, commandOptionParser, commandOptionConverter);
/// <summary>
/// Initializes an instance of <see cref="CliApplication"/>.
/// </summary>
public CliApplication(
ApplicationMetadata metadata, ApplicationConfiguration configuration,
IConsole console, ITypeActivator typeActivator)
{
_metadata = metadata;
_configuration = configuration;
_console = console;
_typeActivator = typeActivator;
}
private async ValueTask<int?> HandleDebugDirectiveAsync(CommandLineInput commandLineInput)
{
var isDebugMode = _configuration.IsDebugModeAllowed && commandLineInput.IsDebugDirectiveSpecified;
if (!isDebugMode)
return null;
_console.WithForegroundColor(ConsoleColor.Green, () =>
_console.Output.WriteLine($"Attach debugger to PID {Process.GetCurrentProcess().Id} to continue."));
while (!Debugger.IsAttached)
await Task.Delay(100);
return null;
}
private int? HandlePreviewDirective(ApplicationSchema applicationSchema, CommandLineInput commandLineInput)
{
var isPreviewMode = _configuration.IsPreviewModeAllowed && commandLineInput.IsPreviewDirectiveSpecified;
if (!isPreviewMode)
return null;
var commandSchema = applicationSchema.TryFindCommand(commandLineInput, out var argumentOffset);
_console.Output.WriteLine("Parser preview:");
// Command name
if (commandSchema != null && argumentOffset > 0)
{
_console.WithForegroundColor(ConsoleColor.Cyan, () =>
_console.Output.Write(commandSchema.Name));
_console.Output.Write(' ');
}
// Parameters
foreach (var parameter in commandLineInput.Arguments.Skip(argumentOffset))
{
_console.Output.Write('<');
_console.WithForegroundColor(ConsoleColor.White, () =>
_console.Output.Write(parameter));
_console.Output.Write('>');
_console.Output.Write(' ');
}
// Options
foreach (var option in commandLineInput.Options)
{
_console.Output.Write('[');
_console.WithForegroundColor(ConsoleColor.White, () =>
_console.Output.Write(option));
_console.Output.Write(']');
_console.Output.Write(' ');
}
_console.Output.WriteLine();
return 0;
}
private int? HandleVersionOption(CommandLineInput commandLineInput)
{
// Version option is available only on the default command (i.e. when arguments are not specified)
var shouldRenderVersion = !commandLineInput.Arguments.Any() && commandLineInput.IsVersionOptionSpecified;
if (!shouldRenderVersion)
return null;
_console.Output.WriteLine(_metadata.VersionText);
return 0;
}
private int? HandleHelpOption(ApplicationSchema applicationSchema, CommandLineInput commandLineInput)
{
// Help is rendered either when it's requested or when the user provides no arguments and there is no default command
var shouldRenderHelp =
commandLineInput.IsHelpOptionSpecified ||
!applicationSchema.Commands.Any(c => c.IsDefault) && !commandLineInput.Arguments.Any() && !commandLineInput.Options.Any();
if (!shouldRenderHelp)
return null;
// Get the command schema that matches the input or use a dummy default command as a fallback
var commandSchema =
applicationSchema.TryFindCommand(commandLineInput) ??
CommandSchema.StubDefaultCommand;
RenderHelp(applicationSchema, commandSchema);
return 0;
}
private async ValueTask<int> HandleCommandExecutionAsync(
ApplicationSchema applicationSchema,
CommandLineInput commandLineInput,
IReadOnlyDictionary<string, string> environmentVariables)
{
await applicationSchema
.InitializeEntryPoint(commandLineInput, environmentVariables, _typeActivator)
.ExecuteAsync(_console);
return 0;
}
/// <summary>
/// Runs the application with specified command line arguments and environment variables, and returns the exit code.
/// </summary>
public async ValueTask<int> RunAsync(
IReadOnlyList<string> commandLineArguments,
IReadOnlyDictionary<string, string> environmentVariables)
{
try
{
var applicationSchema = ApplicationSchema.Resolve(_configuration.CommandTypes);
var commandLineInput = CommandLineInput.Parse(commandLineArguments);
return
await HandleDebugDirectiveAsync(commandLineInput) ??
HandlePreviewDirective(applicationSchema, commandLineInput) ??
HandleVersionOption(commandLineInput) ??
HandleHelpOption(applicationSchema, commandLineInput) ??
await HandleCommandExecutionAsync(applicationSchema, commandLineInput, environmentVariables);
}
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
var errorMessage = !string.IsNullOrWhiteSpace(ex.Message) && (ex is CliFxException || ex is CommandException)
? ex.Message
: ex.ToString();
_console.WithForegroundColor(ConsoleColor.Red, () => _console.Error.WriteLine(errorMessage));
return ex is CommandException commandException
? commandException.ExitCode
: ex.HResult;
}
}
/// <summary>
/// Runs the application with specified command line arguments and returns the exit code.
/// Environment variables are retrieved automatically.
/// </summary>
public async ValueTask<int> RunAsync(IReadOnlyList<string> commandLineArguments)
{
var environmentVariables = Environment.GetEnvironmentVariables()
.Cast<DictionaryEntry>()
.ToDictionary(e => (string) e.Key, e => (string) e.Value, StringComparer.OrdinalIgnoreCase);
return await RunAsync(commandLineArguments, environmentVariables);
}
/// <summary>
/// Runs the application and returns the exit code.
/// Command line arguments and environment variables are retrieved automatically.
/// </summary>
public async ValueTask<int> RunAsync()
{
var commandLineArguments = Environment.GetCommandLineArgs()
.Skip(1)
.ToArray();
return await RunAsync(commandLineArguments);
}
}
}

View File

@@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using CliFx.Domain;
namespace CliFx
{
/// <summary>
/// Builds an instance of <see cref="CliApplication"/>.
/// </summary>
public partial class CliApplicationBuilder
{
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 ITypeActivator? _typeActivator;
/// <summary>
/// Adds a command of specified type to the application.
/// </summary>
public CliApplicationBuilder AddCommand(Type commandType)
{
_commandTypes.Add(commandType);
return this;
}
/// <summary>
/// Adds multiple commands to the application.
/// </summary>
public CliApplicationBuilder AddCommands(IEnumerable<Type> commandTypes)
{
foreach (var commandType in commandTypes)
AddCommand(commandType);
return this;
}
/// <summary>
/// Adds commands from the specified assembly to the application.
/// Only the public types are added.
/// </summary>
public CliApplicationBuilder AddCommandsFrom(Assembly commandAssembly)
{
foreach (var commandType in commandAssembly.ExportedTypes.Where(CommandSchema.IsCommandType))
AddCommand(commandType);
return this;
}
/// <summary>
/// Adds commands from the specified assemblies to the application.
/// Only the public types are added.
/// </summary>
public CliApplicationBuilder AddCommandsFrom(IEnumerable<Assembly> commandAssemblies)
{
foreach (var commandAssembly in commandAssemblies)
AddCommandsFrom(commandAssembly);
return this;
}
/// <summary>
/// Adds commands from the calling assembly to the application.
/// Only the public types are added.
/// </summary>
public CliApplicationBuilder AddCommandsFromThisAssembly() => AddCommandsFrom(Assembly.GetCallingAssembly());
/// <summary>
/// Specifies whether debug mode (enabled with [debug] directive) is allowed in the application.
/// </summary>
public CliApplicationBuilder AllowDebugMode(bool isAllowed = true)
{
_isDebugModeAllowed = isAllowed;
return this;
}
/// <summary>
/// Specifies whether preview mode (enabled with [preview] directive) is allowed in the application.
/// </summary>
public CliApplicationBuilder AllowPreviewMode(bool isAllowed = true)
{
_isPreviewModeAllowed = isAllowed;
return this;
}
/// <summary>
/// Sets application title, which appears in the help text.
/// </summary>
public CliApplicationBuilder UseTitle(string title)
{
_title = title;
return this;
}
/// <summary>
/// Sets application executable name, which appears in the help text.
/// </summary>
public CliApplicationBuilder UseExecutableName(string executableName)
{
_executableName = executableName;
return this;
}
/// <summary>
/// Sets application version text, which appears in the help text and when the user requests version information.
/// </summary>
public CliApplicationBuilder UseVersionText(string versionText)
{
_versionText = versionText;
return this;
}
/// <summary>
/// Sets application description, which appears in the help text.
/// </summary>
public CliApplicationBuilder UseDescription(string? description)
{
_description = description;
return this;
}
/// <summary>
/// Configures the application to use the specified implementation of <see cref="IConsole"/>.
/// </summary>
public CliApplicationBuilder UseConsole(IConsole console)
{
_console = console;
return this;
}
/// <summary>
/// Configures the application to use the specified implementation of <see cref="ITypeActivator"/>.
/// </summary>
public CliApplicationBuilder UseTypeActivator(ITypeActivator typeActivator)
{
_typeActivator = typeActivator;
return this;
}
/// <summary>
/// Configures the application to use the specified function for activating types.
/// </summary>
public CliApplicationBuilder UseTypeActivator(Func<Type, object> typeActivator) =>
UseTypeActivator(new DelegateTypeActivator(typeActivator));
/// <summary>
/// Creates an instance of <see cref="CliApplication"/> using configured parameters.
/// Default values are used in place of parameters that were not specified.
/// </summary>
public CliApplication Build()
{
_title ??= GetDefaultTitle() ?? "App";
_executableName ??= GetDefaultExecutableName() ?? "app";
_versionText ??= GetDefaultVersionText() ?? "v1.0";
_console ??= new SystemConsole();
_typeActivator ??= new DefaultTypeActivator();
var metadata = new ApplicationMetadata(_title, _executableName, _versionText, _description);
var configuration = new ApplicationConfiguration(_commandTypes.ToArray(), _isDebugModeAllowed, _isPreviewModeAllowed);
return new CliApplication(metadata, configuration, _console, _typeActivator);
}
}
public partial class CliApplicationBuilder
{
private static readonly Lazy<Assembly> LazyEntryAssembly = new Lazy<Assembly>(Assembly.GetEntryAssembly);
// Entry assembly is null in tests
private static Assembly EntryAssembly => LazyEntryAssembly.Value;
private static string? GetDefaultTitle() => EntryAssembly?.GetName().Name;
private static string? GetDefaultExecutableName()
{
var entryAssemblyLocation = EntryAssembly?.Location;
// If it's a .dll assembly, prepend 'dotnet' and keep the file extension
if (string.Equals(Path.GetExtension(entryAssemblyLocation), ".dll", StringComparison.OrdinalIgnoreCase))
{
return "dotnet " + Path.GetFileName(entryAssemblyLocation);
}
// Otherwise just use assembly file name without extension
return Path.GetFileNameWithoutExtension(entryAssemblyLocation);
}
private static string? GetDefaultVersionText() =>
EntryAssembly != null
? $"v{EntryAssembly.GetName().Version}"
: null;
}
}

View File

@@ -1,23 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../CliFx.props" />
<PropertyGroup>
<TargetFrameworks>net45;netstandard2.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<Version>0.0.1</Version>
<Company>Tyrrrz</Company>
<TargetFrameworks>netstandard2.1;netstandard2.0;net45</TargetFrameworks>
<Authors>$(Company)</Authors>
<Copyright>Copyright (C) Alexey Golub</Copyright>
<Description>Declarative framework for CLI applications</Description>
<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>
<PackageLicenseExpression>LGPL-3.0-only</PackageLicenseExpression>
<RepositoryUrl>https://github.com/Tyrrrz/CliFx</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageIcon>favicon.png</PackageIcon>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<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>
</PropertyGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>$(AssemblyName).Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<None Include="../favicon.png" Pack="True" PackagePath="" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="all" />
<PackageReference Include="Nullable" Version="1.2.0" PrivateAssets="all" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0' or '$(TargetFramework)' == 'net45'">
<PackageReference Include="System.Threading.Tasks.Extensions" Version="4.5.3" />
</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,29 @@
using System;
using System.Text;
using CliFx.Exceptions;
namespace CliFx
{
/// <summary>
/// Type activator that uses the <see cref="Activator"/> class to instantiate objects.
/// </summary>
public class DefaultTypeActivator : ITypeActivator
{
/// <inheritdoc />
public object CreateInstance(Type type)
{
try
{
return Activator.CreateInstance(type);
}
catch (Exception ex)
{
throw new CliFxException(new StringBuilder()
.Append($"Failed to create an instance of {type.FullName}.").Append(" ")
.AppendLine("The type must have a public parameter-less constructor in order to be instantiated by the default activator.")
.Append($"To supply a custom activator (for example when using dependency injection), call {nameof(CliApplicationBuilder)}.{nameof(CliApplicationBuilder.UseTypeActivator)}(...).")
.ToString(), ex);
}
}
}
}

View File

@@ -0,0 +1,27 @@
using System;
using System.Text;
using CliFx.Exceptions;
namespace CliFx
{
/// <summary>
/// Type activator that uses the specified delegate to instantiate objects.
/// </summary>
public class DelegateTypeActivator : ITypeActivator
{
private readonly Func<Type, object> _func;
/// <summary>
/// Initializes an instance of <see cref="DelegateTypeActivator"/>.
/// </summary>
public DelegateTypeActivator(Func<Type, object> func) => _func = func;
/// <inheritdoc />
public object CreateInstance(Type type) =>
_func(type) ?? throw new CliFxException(new StringBuilder()
.Append($"Failed to create an instance of type {type.FullName}, received <null> instead.").Append(" ")
.Append("Make sure that the provided type activator was configured correctly.").Append(" ")
.Append("If you are using a dependency container, make sure that this type is registered.")
.ToString());
}
}

View File

@@ -0,0 +1,257 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Internal;
namespace CliFx.Domain
{
internal partial class ApplicationSchema
{
public IReadOnlyList<CommandSchema> Commands { get; }
public ApplicationSchema(IReadOnlyList<CommandSchema> commands)
{
Commands = commands;
}
public CommandSchema? TryFindParentCommand(string? childCommandName)
{
// Default command has no parent
if (string.IsNullOrWhiteSpace(childCommandName))
return null;
// Try to find the parent command by repeatedly biting off chunks of its name
var route = childCommandName.Split(' ');
for (var i = route.Length - 1; i >= 1; i--)
{
var potentialParentCommandName = string.Join(" ", route.Take(i));
var matchingParentCommand = Commands.FirstOrDefault(c => c.MatchesName(potentialParentCommandName));
if (matchingParentCommand != null)
return matchingParentCommand;
}
// If there's no parent - fall back to default command
return Commands.FirstOrDefault(c => c.IsDefault);
}
public IReadOnlyList<CommandSchema> GetChildCommands(string? parentCommandName) =>
!string.IsNullOrWhiteSpace(parentCommandName) || Commands.Any(c => c.IsDefault)
? Commands.Where(c => TryFindParentCommand(c.Name)?.MatchesName(parentCommandName) == true).ToArray()
: Commands.Where(c => !string.IsNullOrWhiteSpace(c.Name) && TryFindParentCommand(c.Name) == null).ToArray();
// TODO: this out parameter is not a really nice design
public CommandSchema? TryFindCommand(CommandLineInput commandLineInput, out int argumentOffset)
{
// Try to find the command that contains the most of the input arguments in its name
for (var i = commandLineInput.Arguments.Count; i >= 0; i--)
{
var potentialCommandName = string.Join(" ", commandLineInput.Arguments.Take(i));
var matchingCommand = Commands.FirstOrDefault(c => c.MatchesName(potentialCommandName));
if (matchingCommand != null)
{
argumentOffset = i;
return matchingCommand;
}
}
argumentOffset = 0;
return Commands.FirstOrDefault(c => c.IsDefault);
}
public CommandSchema? TryFindCommand(CommandLineInput commandLineInput) =>
TryFindCommand(commandLineInput, out _);
public ICommand InitializeEntryPoint(
CommandLineInput commandLineInput,
IReadOnlyDictionary<string, string> environmentVariables,
ITypeActivator activator)
{
var command = TryFindCommand(commandLineInput, out var argumentOffset);
if (command == null)
{
throw new CliFxException(
$"Can't find a command that matches arguments [{string.Join(" ", commandLineInput.Arguments)}].");
}
var parameterInputs = argumentOffset == 0
? commandLineInput.Arguments
: commandLineInput.Arguments.Skip(argumentOffset).ToArray();
return command.CreateInstance(parameterInputs, commandLineInput.Options, environmentVariables, activator);
}
}
internal partial class ApplicationSchema
{
private static void ValidateParameters(CommandSchema command)
{
var duplicateOrderGroup = command.Parameters
.GroupBy(a => a.Order)
.FirstOrDefault(g => g.Count() > 1);
if (duplicateOrderGroup != null)
{
throw new CliFxException(new StringBuilder()
.AppendLine($"Command {command.Type.FullName} contains two or more parameters that have the same order ({duplicateOrderGroup.Key}):")
.AppendBulletList(duplicateOrderGroup.Select(o => o.Property.Name))
.AppendLine()
.Append("Parameters in a command must all have unique order.")
.ToString());
}
var duplicateNameGroup = command.Parameters
.Where(a => !string.IsNullOrWhiteSpace(a.Name))
.GroupBy(a => a.Name, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(g => g.Count() > 1);
if (duplicateNameGroup != null)
{
throw new CliFxException(new StringBuilder()
.AppendLine($"Command {command.Type.FullName} contains two or more parameters that have the same name ({duplicateNameGroup.Key}):")
.AppendBulletList(duplicateNameGroup.Select(o => o.Property.Name))
.AppendLine()
.Append("Parameters in a command must all have unique names.").Append(" ")
.Append("Comparison is NOT case-sensitive.")
.ToString());
}
var nonScalarParameters = command.Parameters
.Where(p => !p.IsScalar)
.ToArray();
if (nonScalarParameters.Length > 1)
{
throw new CliFxException(new StringBuilder()
.AppendLine($"Command [{command.Type.FullName}] contains two or more parameters of an enumerable type:")
.AppendBulletList(nonScalarParameters.Select(o => o.Property.Name))
.AppendLine()
.AppendLine("There can only be one parameter of an enumerable type in a command.")
.Append("Note, the string type is not considered enumerable in this context.")
.ToString());
}
var nonLastNonScalarParameter = command.Parameters
.OrderByDescending(a => a.Order)
.Skip(1)
.LastOrDefault(p => !p.IsScalar);
if (nonLastNonScalarParameter != null)
{
throw new CliFxException(new StringBuilder()
.AppendLine($"Command {command.Type.FullName} contains a parameter of an enumerable type which doesn't appear last in order:")
.AppendLine($"- {nonLastNonScalarParameter.Property.Name}")
.AppendLine()
.Append("Parameter of an enumerable type must always come last to avoid ambiguity.")
.ToString());
}
}
private static void ValidateOptions(CommandSchema command)
{
var duplicateNameGroup = command.Options
.Where(o => !string.IsNullOrWhiteSpace(o.Name))
.GroupBy(o => o.Name, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(g => g.Count() > 1);
if (duplicateNameGroup != null)
{
throw new CliFxException(new StringBuilder()
.AppendLine($"Command {command.Type.FullName} contains two or more options that have the same name ({duplicateNameGroup.Key}):")
.AppendBulletList(duplicateNameGroup.Select(o => o.Property.Name))
.AppendLine()
.Append("Options in a command must all have unique names.").Append(" ")
.Append("Comparison is NOT case-sensitive.")
.ToString());
}
var duplicateShortNameGroup = command.Options
.Where(o => o.ShortName != null)
.GroupBy(o => o.ShortName)
.FirstOrDefault(g => g.Count() > 1);
if (duplicateShortNameGroup != null)
{
throw new CliFxException(new StringBuilder()
.AppendLine($"Command {command.Type.FullName} contains two or more options that have the same short name ({duplicateShortNameGroup.Key}):")
.AppendBulletList(duplicateShortNameGroup.Select(o => o.Property.Name))
.AppendLine()
.Append("Options in a command must all have unique short names.").Append(" ")
.Append("Comparison is case-sensitive.")
.ToString());
}
var duplicateEnvironmentVariableNameGroup = command.Options
.Where(o => !string.IsNullOrWhiteSpace(o.EnvironmentVariableName))
.GroupBy(o => o.EnvironmentVariableName, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(g => g.Count() > 1);
if (duplicateEnvironmentVariableNameGroup != null)
{
throw new CliFxException(new StringBuilder()
.AppendLine($"Command {command.Type.FullName} contains two or more options that have the same environment variable name ({duplicateEnvironmentVariableNameGroup.Key}):")
.AppendBulletList(duplicateEnvironmentVariableNameGroup.Select(o => o.Property.Name))
.AppendLine()
.Append("Options in a command must all have unique environment variable names.").Append(" ")
.Append("Comparison is NOT case-sensitive.")
.ToString());
}
}
private static void ValidateCommands(IReadOnlyList<CommandSchema> commands)
{
if (!commands.Any())
{
throw new CliFxException("There are no commands configured for this application.");
}
var duplicateNameGroup = commands
.GroupBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(g => g.Count() > 1);
if (duplicateNameGroup != null)
{
throw new CliFxException(new StringBuilder()
.AppendLine($"Application contains two or more commands that have the same name ({duplicateNameGroup.Key}):")
.AppendBulletList(duplicateNameGroup.Select(o => o.Type.FullName))
.AppendLine()
.Append("Commands must all have unique names. Likewise, there must not be more than one command without a name.").Append(" ")
.Append("Comparison is NOT case-sensitive.")
.ToString());
}
}
public static ApplicationSchema Resolve(IReadOnlyList<Type> commandTypes)
{
var commands = new List<CommandSchema>();
foreach (var commandType in commandTypes)
{
var command = CommandSchema.TryResolve(commandType);
if (command == null)
{
throw new CliFxException(new StringBuilder()
.Append($"Command {commandType.FullName} is not a valid command type.").Append(" ")
.AppendLine("In order to be a valid command type it must:")
.AppendLine($" - Be annotated with {typeof(CommandAttribute).FullName}")
.AppendLine($" - Implement {typeof(ICommand).FullName}")
.AppendLine(" - Not be an abstract class")
.ToString());
}
ValidateParameters(command);
ValidateOptions(command);
commands.Add(command);
}
ValidateCommands(commands);
return new ApplicationSchema(commands);
}
}
}

View File

@@ -0,0 +1,176 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Text;
using CliFx.Exceptions;
using CliFx.Internal;
namespace CliFx.Domain
{
internal abstract partial class CommandArgumentSchema
{
public PropertyInfo Property { get; }
public string? Description { get; }
public bool IsScalar => GetEnumerableArgumentUnderlyingType() == null;
protected CommandArgumentSchema(PropertyInfo property, string? description)
{
Property = property;
Description = description;
}
private Type? GetEnumerableArgumentUnderlyingType() =>
Property.PropertyType != typeof(string)
? Property.PropertyType.GetEnumerableUnderlyingType()
: null;
private object Convert(IReadOnlyList<string> values)
{
var targetType = Property.PropertyType;
var enumerableUnderlyingType = GetEnumerableArgumentUnderlyingType();
// Scalar
if (enumerableUnderlyingType == null)
{
if (values.Count > 1)
{
throw new CliFxException(new StringBuilder()
.AppendLine($"Can't convert a sequence of values [{string.Join(", ", values)}] to type {targetType.FullName}.")
.Append("Target type is not enumerable and can't accept more than one value.")
.ToString());
}
return ConvertScalar(values.SingleOrDefault(), targetType);
}
// Non-scalar
else
{
return ConvertNonScalar(values, targetType, enumerableUnderlyingType);
}
}
public void Inject(ICommand command, IReadOnlyList<string> values) =>
Property.SetValue(command, Convert(values));
public void Inject(ICommand command, params string[] values) =>
Inject(command, (IReadOnlyList<string>) values);
}
internal partial class CommandArgumentSchema
{
private static readonly IFormatProvider ConversionFormatProvider = CultureInfo.InvariantCulture;
private static readonly IReadOnlyDictionary<Type, Func<string, object>> PrimitiveConverters =
new Dictionary<Type, Func<string?, object>>
{
[typeof(object)] = v => v,
[typeof(string)] = v => v,
[typeof(bool)] = v => string.IsNullOrWhiteSpace(v) || bool.Parse(v),
[typeof(char)] = v => v.Single(),
[typeof(sbyte)] = v => sbyte.Parse(v, ConversionFormatProvider),
[typeof(byte)] = v => byte.Parse(v, ConversionFormatProvider),
[typeof(short)] = v => short.Parse(v, ConversionFormatProvider),
[typeof(ushort)] = v => ushort.Parse(v, ConversionFormatProvider),
[typeof(int)] = v => int.Parse(v, ConversionFormatProvider),
[typeof(uint)] = v => uint.Parse(v, ConversionFormatProvider),
[typeof(long)] = v => long.Parse(v, ConversionFormatProvider),
[typeof(ulong)] = v => ulong.Parse(v, ConversionFormatProvider),
[typeof(float)] = v => float.Parse(v, ConversionFormatProvider),
[typeof(double)] = v => double.Parse(v, ConversionFormatProvider),
[typeof(decimal)] = v => decimal.Parse(v, ConversionFormatProvider),
[typeof(DateTime)] = v => DateTime.Parse(v, ConversionFormatProvider),
[typeof(DateTimeOffset)] = v => DateTimeOffset.Parse(v, ConversionFormatProvider),
[typeof(TimeSpan)] = v => TimeSpan.Parse(v, ConversionFormatProvider),
};
private static ConstructorInfo? GetStringConstructor(Type type) =>
type.GetConstructor(new[] {typeof(string)});
private static MethodInfo? GetStaticParseMethod(Type type) =>
type.GetMethod("Parse",
BindingFlags.Public | BindingFlags.Static,
null, new[] {typeof(string)}, null);
private static MethodInfo? GetStaticParseMethodWithFormatProvider(Type type) =>
type.GetMethod("Parse",
BindingFlags.Public | BindingFlags.Static,
null, new[] {typeof(string), typeof(IFormatProvider)}, null);
private static object ConvertScalar(string? value, Type targetType)
{
try
{
// Primitive
var primitiveConverter = PrimitiveConverters.GetValueOrDefault(targetType);
if (primitiveConverter != null)
return primitiveConverter(value);
// Enum
if (targetType.IsEnum)
return Enum.Parse(targetType, value, true);
// Nullable
var nullableUnderlyingType = targetType.GetNullableUnderlyingType();
if (nullableUnderlyingType != null)
return !string.IsNullOrWhiteSpace(value)
? ConvertScalar(value, nullableUnderlyingType)
: null;
// String-constructable
var stringConstructor = GetStringConstructor(targetType);
if (stringConstructor != null)
return stringConstructor.Invoke(new object[] {value});
// String-parseable (with format provider)
var parseMethodWithFormatProvider = GetStaticParseMethodWithFormatProvider(targetType);
if (parseMethodWithFormatProvider != null)
return parseMethodWithFormatProvider.Invoke(null, new object[] {value, ConversionFormatProvider});
// String-parseable (without format provider)
var parseMethod = GetStaticParseMethod(targetType);
if (parseMethod != null)
return parseMethod.Invoke(null, new object[] {value});
}
catch (Exception ex)
{
throw new CliFxException(new StringBuilder()
.AppendLine($"Failed to convert value '{value ?? "<null>"}' to type {targetType.FullName}.")
.Append(ex.Message)
.ToString(), ex);
}
throw new CliFxException(new StringBuilder()
.AppendLine($"Can't convert value '{value ?? "<null>"}' to type {targetType.FullName}.")
.Append("Target type is not supported by CliFx.")
.ToString());
}
private static object ConvertNonScalar(IReadOnlyList<string> values, Type targetEnumerableType, Type targetElementType)
{
var array = values
.Select(v => ConvertScalar(v, targetElementType))
.ToNonGenericArray(targetElementType);
var arrayType = array.GetType();
// Assignable from an array
if (targetEnumerableType.IsAssignableFrom(arrayType))
return array;
// Constructable from an array
var arrayConstructor = targetEnumerableType.GetConstructor(new[] {arrayType});
if (arrayConstructor != null)
return arrayConstructor.Invoke(new object[] {array});
throw new CliFxException(new StringBuilder()
.AppendLine($"Can't convert a sequence of values [{string.Join(", ", values)}] to type {targetEnumerableType.FullName}.")
.AppendLine($"Underlying element type is [{targetElementType.FullName}].")
.Append("Target type must either be assignable from an array or have a public constructor that takes a single array argument.")
.ToString());
}
}
}

View File

@@ -0,0 +1,179 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using CliFx.Internal;
namespace CliFx.Domain
{
internal partial class CommandLineInput
{
public IReadOnlyList<string> Directives { get; }
public IReadOnlyList<string> Arguments { get; }
public IReadOnlyList<CommandOptionInput> Options { get; }
public bool IsDebugDirectiveSpecified => Directives.Contains("debug", StringComparer.OrdinalIgnoreCase);
public bool IsPreviewDirectiveSpecified => Directives.Contains("preview", StringComparer.OrdinalIgnoreCase);
public bool IsHelpOptionSpecified =>
Options.Any(o => CommandOptionSchema.HelpOption.MatchesNameOrShortName(o.Alias));
public bool IsVersionOptionSpecified =>
Options.Any(o => CommandOptionSchema.VersionOption.MatchesNameOrShortName(o.Alias));
public CommandLineInput(
IReadOnlyList<string> directives,
IReadOnlyList<string> arguments,
IReadOnlyList<CommandOptionInput> options)
{
Directives = directives;
Arguments = arguments;
Options = options;
}
public CommandLineInput(
IReadOnlyList<string> arguments,
IReadOnlyList<CommandOptionInput> options)
: this(new string[0], arguments, options)
{
}
public CommandLineInput(IReadOnlyList<string> arguments)
: this(arguments, new CommandOptionInput[0])
{
}
public CommandLineInput(IReadOnlyList<CommandOptionInput> options)
: this(new string[0], options)
{
}
public override string ToString()
{
var buffer = new StringBuilder();
foreach (var directive in Directives)
{
buffer.AppendIfNotEmpty(' ');
buffer
.Append('[')
.Append(directive)
.Append(']');
}
foreach (var argument in Arguments)
{
buffer.AppendIfNotEmpty(' ');
buffer.Append(argument);
}
foreach (var option in Options)
{
buffer.AppendIfNotEmpty(' ');
buffer.Append(option);
}
return buffer.ToString();
}
}
internal partial class CommandLineInput
{
public static CommandLineInput Parse(IReadOnlyList<string> commandLineArguments)
{
var directives = new List<string>();
var arguments = new List<string>();
var optionsDic = new Dictionary<string, List<string>>();
// Option aliases and values are parsed in pairs so we need to keep track of last alias
var lastOptionAlias = "";
bool TryParseDirective(string argument)
{
if (!string.IsNullOrWhiteSpace(lastOptionAlias))
return false;
if (!argument.StartsWith("[", StringComparison.OrdinalIgnoreCase) ||
!argument.EndsWith("]", StringComparison.OrdinalIgnoreCase))
return false;
var directive = argument.Substring(1, argument.Length - 2);
directives.Add(directive);
return true;
}
bool TryParseArgument(string argument)
{
if (!string.IsNullOrWhiteSpace(lastOptionAlias))
return false;
arguments.Add(argument);
return true;
}
bool TryParseOptionName(string argument)
{
if (!argument.StartsWith("--", StringComparison.OrdinalIgnoreCase))
return false;
lastOptionAlias = argument.Substring(2);
if (!optionsDic.ContainsKey(lastOptionAlias))
optionsDic[lastOptionAlias] = new List<string>();
return true;
}
bool TryParseOptionShortName(string argument)
{
if (!argument.StartsWith("-", StringComparison.OrdinalIgnoreCase))
return false;
foreach (var c in argument.Substring(1))
{
lastOptionAlias = c.AsString();
if (!optionsDic.ContainsKey(lastOptionAlias))
optionsDic[lastOptionAlias] = new List<string>();
}
return true;
}
bool TryParseOptionValue(string argument)
{
if (string.IsNullOrWhiteSpace(lastOptionAlias))
return false;
optionsDic[lastOptionAlias].Add(argument);
return true;
}
foreach (var argument in commandLineArguments)
{
var _ =
TryParseOptionName(argument) ||
TryParseOptionShortName(argument) ||
TryParseDirective(argument) ||
TryParseArgument(argument) ||
TryParseOptionValue(argument);
}
var options = optionsDic.Select(p => new CommandOptionInput(p.Key, p.Value)).ToArray();
return new CommandLineInput(directives, arguments, options);
}
}
internal partial class CommandLineInput
{
public static CommandLineInput Empty { get; } =
new CommandLineInput(new string[0], new string[0], new CommandOptionInput[0]);
}
}

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