43 Commits
0.0.2 ... 0.0.8

Author SHA1 Message Date
Alexey Golub
70bfe0bf91 Update version 2019-11-13 20:34:11 +02:00
Alexey Golub
9690c380d3 Use C#8 features and cleanup 2019-11-13 20:31:48 +02:00
Alexey Golub
85caa275ae Add source link 2019-11-12 22:26:29 +02:00
Federico Paolillo
32026e59c0 Use Path.Separator in environment variables tests (#31) 2019-11-09 13:06:00 +02:00
Alexey Golub
486ccb9685 Update csproj 2019-11-08 13:21:53 +02:00
Alexey Golub
7b766f70f3 Use GitHub actions 2019-11-06 15:08:51 +02:00
Alexey Golub
f73e96488f Update version 2019-10-31 14:42:30 +02:00
Moophic
af63fa5a1f Refactor cancellation (#30) 2019-10-31 14:39:56 +02:00
Moophic
e8f53c9463 Updated readme with cancellation info (#29) 2019-10-30 19:49:43 +02:00
Alexey Golub
9564cd5d30 Update version 2019-10-30 18:41:24 +02:00
Moophic
ed458c3980 Cancellation support (#28) 2019-10-30 18:37:32 +02:00
Alexey Golub
25538f99db Migrate from PackageIconUrl to PackageIcon 2019-10-08 16:59:13 +03:00
Federico Paolillo
36436e7a4b Environment variables (#27) 2019-09-29 20:44:24 +03:00
Alexey Golub
a6070332c9 Migrate to .NET Core 3 where applicable 2019-09-25 22:52:33 +03:00
Alexey Golub
25cbfdb4b8 Move screenshots to repository 2019-09-06 20:24:28 +03:00
Alexey Golub
d1b5107c2c Update version 2019-08-26 20:48:43 +03:00
Alexey Golub
03873d63cd Fix exception when converting option values to array when there's only one value 2019-08-26 20:47:23 +03:00
Alexey Golub
89aba39964 Add extensibility point for injecting custom option converters
Closes #19
2019-08-26 20:10:37 +03:00
Alexey Golub
ab57a103d1 Update benchmarks 2019-08-26 17:20:14 +03:00
Alexey Golub
d0b2ebc061 Update readme 2019-08-25 23:27:19 +03:00
Alexey Golub
857257ca73 Update version 2019-08-25 23:19:10 +03:00
Alexey Golub
3587155c7e Update readme 2019-08-25 23:17:58 +03:00
Alexey Golub
ae05e0db96 Refactor 2019-08-25 22:08:34 +03:00
Alexey Golub
41c0493e66 Refactor tests again 2019-08-25 18:26:40 +03:00
Alexey Golub
43a304bb26 Refactor tests 2019-08-25 17:28:54 +03:00
Alexey Golub
cd3892bf83 Refactor CliApplication.RunAsync using chain of responsibility 2019-08-25 14:54:29 +03:00
Alexey Golub
3f7c02342d Add smoke tests for VirtualConsole 2019-08-25 11:30:06 +03:00
Alexey Golub
c65cdf465e Remove dummy tests 2019-08-24 23:25:41 +03:00
Alexey Golub
b5d67ecf24 Fix not printing version when requested if used with stub default command 2019-08-24 22:46:10 +03:00
Alexey Golub
a94b2296e1 Add tests for CommandInitializer that verify that short name comparison is case sensitive 2019-08-24 22:44:11 +03:00
Alexey Golub
fa05e4df3f Rework schema validation in CommandSchemaResolver 2019-08-24 22:23:12 +03:00
Alexey Golub
b70b25076e Add smoke tests for CliApplicationBuilder 2019-08-24 18:31:17 +03:00
Alexey Golub
0662f341e6 Rename some methods 2019-08-24 18:25:56 +03:00
Alexey Golub
80bf477f3b Add support for directives (debug and preview)
Closes #7
Closes #8
2019-08-24 18:22:54 +03:00
Alexey Golub
e4a502d9d6 Rename ProgressReporter to ProgressTicker 2019-08-24 13:00:13 +03:00
Alexey Golub
13b15b98ed Add ProgressReporter
Closes #14
2019-08-23 22:50:43 +03:00
Alexey Golub
80465e0e51 Move tests into corresponding namespaces 2019-08-23 17:01:49 +03:00
Alexey Golub
9a1ce7e7e5 Add 1 more negative test for CommandSchemaResolver 2019-08-22 12:08:08 +03:00
Alexey Golub
b45da64664 Make CommandAttribute non-optional on command types 2019-08-21 21:04:42 +03:00
Alexey Golub
df01dc055e Prepend 'v' to default version text 2019-08-21 15:55:05 +03:00
Alexey Golub
31dd24d189 Sort options when rendering help 2019-08-21 14:37:53 +03:00
Alexey Golub
2a76dfe1c8 Update version 2019-08-20 18:12:33 +03:00
Alexey Golub
59ee2e34d8 Don't add abstract and interface types that implement ICommand 2019-08-20 18:12:22 +03:00
111 changed files with 2476 additions and 1662 deletions

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

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

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

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

1
.gitignore vendored
View File

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

BIN
.screenshots/help.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -8,7 +8,7 @@ namespace CliFx.Benchmarks
[RankColumn]
public class Benchmark
{
private static readonly string[] Arguments = { "--str", "hello world", "-i", "13", "-b" };
private static readonly string[] Arguments = {"--str", "hello world", "-i", "13", "-b"};
[Benchmark(Description = "CliFx", Baseline = true)]
public Task<int> ExecuteWithCliFx() => new CliApplicationBuilder().AddCommand(typeof(CliFxCommand)).Build().RunAsync(Arguments);
@@ -19,16 +19,17 @@ namespace CliFx.Benchmarks
[Benchmark(Description = "McMaster.Extensions.CommandLineUtils")]
public int ExecuteWithMcMaster() => McMaster.Extensions.CommandLineUtils.CommandLineApplication.Execute<McMasterCommand>(Arguments);
// Skipped because this benchmark freezes after a couple of iterations
// Probably wasn't designed to run multiple times in single process execution
//[Benchmark(Description = "CommandLineParser")]
[Benchmark(Description = "CommandLineParser")]
public void ExecuteWithCommandLineParser()
{
var parsed = CommandLine.Parser.Default.ParseArguments(Arguments, typeof(CommandLineParserCommand));
var parsed = new CommandLine.Parser().ParseArguments(Arguments, typeof(CommandLineParserCommand));
CommandLine.ParserResultExtensions.WithParsed<CommandLineParserCommand>(parsed, c => c.Execute());
}
[Benchmark(Description = "PowerArgs")]
public void ExecuteWithPowerArgs() => PowerArgs.Args.InvokeMain<PowerArgsCommand>(Arguments);
[Benchmark(Description = "Clipr")]
public void ExecuteWithClipr() => clipr.CliParser.Parse<CliprCommand>(Arguments).Execute();
}
}

View File

@@ -2,12 +2,13 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp2.2</TargetFramework>
<LangVersion>latest</LangVersion>
<TargetFramework>netcoreapp3.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.11.5" />
<PackageReference Include="clipr" Version="1.6.1" />
<PackageReference Include="CommandLineParser" Version="2.6.0" />
<PackageReference Include="McMaster.Extensions.CommandLineUtils" Version="2.3.4" />
<PackageReference Include="PowerArgs" Version="3.6.0" />

View File

@@ -8,7 +8,7 @@ namespace CliFx.Benchmarks.Commands
public class CliFxCommand : ICommand
{
[CommandOption("str", 's')]
public string StrOption { get; set; }
public string? StrOption { get; set; }
[CommandOption("int", 'i')]
public int IntOption { get; set; }

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

@@ -5,7 +5,7 @@ namespace CliFx.Benchmarks.Commands
public class CommandLineParserCommand
{
[Option('s', "str")]
public string StrOption { get; set; }
public string? StrOption { get; set; }
[Option('i', "int")]
public int IntOption { get; set; }

View File

@@ -5,7 +5,7 @@ namespace CliFx.Benchmarks.Commands
public class McMasterCommand
{
[Option("--str|-s")]
public string StrOption { get; set; }
public string? StrOption { get; set; }
[Option("--int|-i")]
public int IntOption { get; set; }

View File

@@ -5,7 +5,7 @@ namespace CliFx.Benchmarks.Commands
public class PowerArgsCommand
{
[ArgShortcut("--str"), ArgShortcut("-s")]
public string StrOption { get; set; }
public string? StrOption { get; set; }
[ArgShortcut("--int"), ArgShortcut("-i")]
public int IntOption { get; set; }

View File

@@ -14,7 +14,7 @@ namespace CliFx.Benchmarks.Commands
{
new Option(new[] {"--str", "-s"})
{
Argument = new Argument<string>()
Argument = new Argument<string?>()
},
new Option(new[] {"--int", "-i"})
{

View File

@@ -2,8 +2,8 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp2.2</TargetFramework>
<LangVersion>latest</LangVersion>
<TargetFramework>netcoreapp3.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>

View File

@@ -24,7 +24,7 @@ namespace CliFx.Demo.Commands
public DateTimeOffset Published { get; set; }
[CommandOption("isbn", 'n', Description = "Book ISBN.")]
public Isbn Isbn { get; set; }
public Isbn? Isbn { get; set; }
public BookAddCommand(LibraryService libraryService)
{

View File

@@ -1,4 +1,5 @@
using System.Threading.Tasks;
using System;
using System.Threading.Tasks;
using CliFx.Demo.Commands;
using CliFx.Demo.Services;
using Microsoft.Extensions.DependencyInjection;
@@ -7,7 +8,7 @@ namespace CliFx.Demo
{
public static class Program
{
public static Task<int> Main(string[] args)
private static IServiceProvider ConfigureServices()
{
// We use Microsoft.Extensions.DependencyInjection for injecting dependencies in commands
var services = new ServiceCollection();
@@ -21,7 +22,12 @@ namespace CliFx.Demo
services.AddTransient<BookRemoveCommand>();
services.AddTransient<BookListCommand>();
var serviceProvider = services.BuildServiceProvider();
return services.BuildServiceProvider();
}
public static Task<int> Main(string[] args)
{
var serviceProvider = ConfigureServices();
return new CliApplicationBuilder()
.AddCommandsFromThisAssembly()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,76 +1,123 @@
using System;
using FluentAssertions;
using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using CliFx.Services;
using FluentAssertions;
using NUnit.Framework;
using CliFx.Tests.Stubs;
using CliFx.Tests.TestCommands;
namespace CliFx.Tests
{
[TestFixture]
public partial class CliApplicationTests
public class CliApplicationTests
{
private const string TestVersionText = "v1.0";
private static IEnumerable<TestCaseData> GetTestCases_RunAsync()
{
yield return new TestCaseData(
new[] {typeof(DefaultCommand)},
new[] {typeof(HelloWorldDefaultCommand)},
new string[0],
"DefaultCommand executed."
"Hello world."
);
yield return new TestCaseData(
new[] {typeof(NamedCommand)},
new[] {"cmd"},
"NamedCommand executed."
);
}
private static IEnumerable<TestCaseData> GetTestCases_HelpAndVersion_RunAsync()
{
yield return new TestCaseData(
new[] {typeof(DefaultCommand)},
new string[0]
new[] {typeof(ConcatCommand)},
new[] {"concat", "-i", "foo", "-i", "bar", "-s", " "},
"foo bar"
);
yield return new TestCaseData(
new[] {typeof(DefaultCommand)},
new[] {"-h"}
new[] {typeof(ConcatCommand)},
new[] {"concat", "-i", "one", "two", "three", "-s", ", "},
"one, two, three"
);
yield return new TestCaseData(
new[] {typeof(DefaultCommand)},
new[] {"--help"}
new[] {typeof(DivideCommand)},
new[] {"div", "-D", "24", "-d", "8"},
"3"
);
yield return new TestCaseData(
new[] {typeof(DefaultCommand)},
new[] {"--version"}
new[] {typeof(HelloWorldDefaultCommand)},
new[] {"--version"},
TestVersionText
);
yield return new TestCaseData(
new[] {typeof(NamedCommand)},
new string[0]
new[] {typeof(ConcatCommand)},
new[] {"--version"},
TestVersionText
);
yield return new TestCaseData(
new[] {typeof(NamedCommand)},
new[] {"cmd", "-h"}
new[] {typeof(HelloWorldDefaultCommand)},
new[] {"-h"},
null
);
yield return new TestCaseData(
new[] {typeof(FaultyCommand1)},
new[] {"faulty1", "-h"}
new[] {typeof(HelloWorldDefaultCommand)},
new[] {"--help"},
null
);
yield return new TestCaseData(
new[] {typeof(FaultyCommand2)},
new[] {"faulty2", "-h"}
new[] {typeof(ConcatCommand)},
new string[0],
null
);
yield return new TestCaseData(
new[] {typeof(FaultyCommand3)},
new[] {"faulty3", "-h"}
new[] {typeof(ConcatCommand)},
new[] {"-h"},
null
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"--help"},
null
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"concat", "-h"},
null
);
yield return new TestCaseData(
new[] {typeof(ExceptionCommand)},
new[] {"exc", "-h"},
null
);
yield return new TestCaseData(
new[] {typeof(CommandExceptionCommand)},
new[] {"exc", "-h"},
null
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"[preview]"},
null
);
yield return new TestCaseData(
new[] {typeof(ExceptionCommand)},
new[] {"exc", "[preview]"},
null
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"concat", "[preview]", "-o", "value"},
null
);
}
@@ -78,97 +125,136 @@ namespace CliFx.Tests
{
yield return new TestCaseData(
new Type[0],
new string[0]
new string[0],
null, null
);
yield return new TestCaseData(
new[] {typeof(DefaultCommand)},
new[] {"non-existing"}
new[] {typeof(ConcatCommand)},
new[] {"non-existing"},
null, null
);
yield return new TestCaseData(
new[] {typeof(FaultyCommand1)},
new[] {"faulty1"}
new[] {typeof(ExceptionCommand)},
new[] {"exc"},
null, null
);
yield return new TestCaseData(
new[] {typeof(FaultyCommand2)},
new[] {"faulty2"}
new[] {typeof(CommandExceptionCommand)},
new[] {"exc"},
null, null
);
yield return new TestCaseData(
new[] {typeof(FaultyCommand3)},
new[] {"faulty3"}
new[] {typeof(CommandExceptionCommand)},
new[] {"exc"},
null, null
);
yield return new TestCaseData(
new[] {typeof(CommandExceptionCommand)},
new[] {"exc", "-m", "foo bar"},
"foo bar", null
);
yield return new TestCaseData(
new[] {typeof(CommandExceptionCommand)},
new[] {"exc", "-m", "foo bar", "-c", "666"},
"foo bar", 666
);
}
[Test]
[TestCaseSource(nameof(GetTestCases_RunAsync))]
public async Task RunAsync_Test(IReadOnlyList<Type> commandTypes, IReadOnlyList<string> commandLineArguments, string expectedStdOut)
public async Task RunAsync_Test(IReadOnlyList<Type> commandTypes, IReadOnlyList<string> commandLineArguments,
string? expectedStdOut = null)
{
// Arrange
using (var stdout = new StringWriter())
{
var console = new VirtualConsole(stdout);
await using var stdoutStream = new StringWriter();
var console = new VirtualConsole(stdoutStream);
var environmentVariablesProvider = new EnvironmentVariablesProviderStub();
var application = new CliApplicationBuilder()
.AddCommands(commandTypes)
.UseVersionText(TestVersionText)
.UseConsole(console)
.UseEnvironmentVariablesProvider(environmentVariablesProvider)
.Build();
// Act
var exitCode = await application.RunAsync(commandLineArguments);
var stdOut = stdoutStream.ToString().Trim();
// Assert
exitCode.Should().Be(0);
stdout.ToString().Trim().Should().Be(expectedStdOut);
}
}
[Test]
[TestCaseSource(nameof(GetTestCases_HelpAndVersion_RunAsync))]
public async Task RunAsync_HelpAndVersion_Test(IReadOnlyList<Type> commandTypes, IReadOnlyList<string> commandLineArguments)
{
// Arrange
using (var stdout = new StringWriter())
{
var console = new VirtualConsole(stdout);
var application = new CliApplicationBuilder()
.AddCommands(commandTypes)
.UseConsole(console)
.Build();
// Act
var exitCode = await application.RunAsync(commandLineArguments);
// Assert
exitCode.Should().Be(0);
stdout.ToString().Should().NotBeNullOrWhiteSpace();
}
if (expectedStdOut != null)
stdOut.Should().Be(expectedStdOut);
else
stdOut.Should().NotBeNullOrWhiteSpace();
}
[Test]
[TestCaseSource(nameof(GetTestCases_RunAsync_Negative))]
public async Task RunAsync_Negative_Test(IReadOnlyList<Type> commandTypes, IReadOnlyList<string> commandLineArguments)
public async Task RunAsync_Negative_Test(IReadOnlyList<Type> commandTypes, IReadOnlyList<string> commandLineArguments,
string? expectedStdErr = null, int? expectedExitCode = null)
{
// Arrange
using (var stderr = new StringWriter())
{
var console = new VirtualConsole(TextWriter.Null, stderr);
await using var stderrStream = new StringWriter();
var console = new VirtualConsole(TextWriter.Null, stderrStream);
var environmentVariablesProvider = new EnvironmentVariablesProviderStub();
var application = new CliApplicationBuilder()
.AddCommands(commandTypes)
.UseVersionText(TestVersionText)
.UseEnvironmentVariablesProvider(environmentVariablesProvider)
.UseConsole(console)
.Build();
// Act
var exitCode = await application.RunAsync(commandLineArguments);
var stderr = stderrStream.ToString().Trim();
// Assert
if (expectedExitCode != null)
exitCode.Should().Be(expectedExitCode);
else
exitCode.Should().NotBe(0);
stderr.ToString().Should().NotBeNullOrWhiteSpace();
if (expectedStdErr != null)
stderr.Should().Be(expectedStdErr);
else
stderr.Should().NotBeNullOrWhiteSpace();
}
[Test]
public async Task RunAsync_Cancellation_Test()
{
// Arrange
using var cancellationTokenSource = new CancellationTokenSource();
await using var stdoutStream = new StringWriter();
var console = new VirtualConsole(stdoutStream, cancellationTokenSource.Token);
var application = new CliApplicationBuilder()
.AddCommand(typeof(CancellableCommand))
.UseConsole(console)
.Build();
var args = new[] {"cancel"};
// Act
var runTask = application.RunAsync(args);
cancellationTokenSource.Cancel();
var exitCode = await runTask.ConfigureAwait(false);
var stdOut = stdoutStream.ToString().Trim();
// Assert
exitCode.Should().Be(-2146233029);
stdOut.Should().Be("Printed");
}
}
}

View File

@@ -1,29 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net46</TargetFramework>
<TargetFramework>netcoreapp3.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<CollectCoverage>true</CollectCoverage>
<CoverletOutputFormat>opencover</CoverletOutputFormat>
<CoverletOutput>bin/$(Configuration)/Coverage.xml</CoverletOutput>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="5.8.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.2.0" />
<PackageReference Include="FluentAssertions" Version="5.9.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.4.0" />
<PackageReference Include="NUnit" Version="3.12.0" />
<PackageReference Include="NUnit3TestAdapter" Version="3.14.0" />
<PackageReference Include="CliWrap" Version="2.3.1" />
<PackageReference Include="coverlet.msbuild" Version="2.6.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NUnit3TestAdapter" Version="3.15.1" />
<PackageReference Include="coverlet.msbuild" Version="2.7.0" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CliFx.Tests.Dummy\CliFx.Tests.Dummy.csproj" />
<ProjectReference Include="..\CliFx\CliFx.csproj" />
</ItemGroup>

View File

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

View File

@@ -1,21 +0,0 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
namespace CliFx.Tests
{
public partial class CommandInitializerTests
{
[Command]
private class TestCommand : ICommand
{
[CommandOption("int", 'i', IsRequired = true)]
public int IntOption { get; set; } = 24;
[CommandOption("str", 's')]
public string StringOption { get; set; } = "foo bar";
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
}
}
}

View File

@@ -1,96 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CliFx.Exceptions;
using CliFx.Models;
using CliFx.Services;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests
{
[TestFixture]
public partial class CommandInitializerTests
{
private static CommandSchema GetCommandSchema(Type commandType) =>
new CommandSchemaResolver().GetCommandSchemas(new[] {commandType}).Single();
private static IEnumerable<TestCaseData> GetTestCases_InitializeCommand()
{
yield return new TestCaseData(
new TestCommand(),
GetCommandSchema(typeof(TestCommand)),
new CommandInput(new[]
{
new CommandOptionInput("int", "13")
}),
new TestCommand {IntOption = 13}
);
yield return new TestCaseData(
new TestCommand(),
GetCommandSchema(typeof(TestCommand)),
new CommandInput(new[]
{
new CommandOptionInput("int", "13"),
new CommandOptionInput("str", "hello world")
}),
new TestCommand {IntOption = 13, StringOption = "hello world"}
);
yield return new TestCaseData(
new TestCommand(),
GetCommandSchema(typeof(TestCommand)),
new CommandInput(new[]
{
new CommandOptionInput("i", "13")
}),
new TestCommand {IntOption = 13}
);
}
private static IEnumerable<TestCaseData> GetTestCases_InitializeCommand_Negative()
{
yield return new TestCaseData(
new TestCommand(),
GetCommandSchema(typeof(TestCommand)),
CommandInput.Empty
);
yield return new TestCaseData(
new TestCommand(),
GetCommandSchema(typeof(TestCommand)),
new CommandInput(new[]
{
new CommandOptionInput("str", "hello world")
})
);
}
[Test]
[TestCaseSource(nameof(GetTestCases_InitializeCommand))]
public void InitializeCommand_Test(ICommand command, CommandSchema commandSchema, CommandInput commandInput, ICommand expectedCommand)
{
// Arrange
var initializer = new CommandInitializer();
// Act
initializer.InitializeCommand(command, commandSchema, commandInput);
// Assert
command.Should().BeEquivalentTo(expectedCommand, o => o.RespectingRuntimeTypes());
}
[Test]
[TestCaseSource(nameof(GetTestCases_InitializeCommand_Negative))]
public void InitializeCommand_Negative_Test(ICommand command, CommandSchema commandSchema, CommandInput commandInput)
{
// Arrange
var initializer = new CommandInitializer();
// Act & Assert
initializer.Invoking(i => i.InitializeCommand(command, commandSchema, commandInput))
.Should().ThrowExactly<MissingCommandOptionInputException>();
}
}
}

View File

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

View File

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

View File

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

View File

@@ -1,94 +0,0 @@
using System;
using System.Collections.Generic;
using CliFx.Exceptions;
using CliFx.Models;
using CliFx.Services;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests
{
[TestFixture]
public partial class CommandSchemaResolverTests
{
private static IEnumerable<TestCaseData> GetTestCases_GetCommandSchemas()
{
yield return new TestCaseData(
new[] {typeof(NormalCommand1), typeof(NormalCommand2)},
new[]
{
new CommandSchema(typeof(NormalCommand1), "cmd", "NormalCommand1 description.",
new[]
{
new CommandOptionSchema(typeof(NormalCommand1).GetProperty(nameof(NormalCommand1.OptionA)),
"option-a", 'a', false, null),
new CommandOptionSchema(typeof(NormalCommand1).GetProperty(nameof(NormalCommand1.OptionB)),
"option-b", null, true, null)
}),
new CommandSchema(typeof(NormalCommand2), null, "NormalCommand2 description.",
new[]
{
new CommandOptionSchema(typeof(NormalCommand2).GetProperty(nameof(NormalCommand2.OptionC)),
"option-c", null, false, "OptionC description."),
new CommandOptionSchema(typeof(NormalCommand2).GetProperty(nameof(NormalCommand2.OptionD)),
"option-d", 'd', false, null)
})
}
);
}
private static IEnumerable<TestCaseData> GetTestCases_GetCommandSchemas_Negative()
{
yield return new TestCaseData(new object[]
{
new Type[0]
});
yield return new TestCaseData(new object[]
{
new[] {typeof(ConflictingCommand1), typeof(ConflictingCommand2)}
});
yield return new TestCaseData(new object[]
{
new[] {typeof(InvalidCommand1)}
});
yield return new TestCaseData(new object[]
{
new[] {typeof(InvalidCommand2)}
});
yield return new TestCaseData(new object[]
{
new[] {typeof(InvalidCommand3)}
});
}
[Test]
[TestCaseSource(nameof(GetTestCases_GetCommandSchemas))]
public void GetCommandSchemas_Test(IReadOnlyList<Type> commandTypes, IReadOnlyList<CommandSchema> expectedCommandSchemas)
{
// Arrange
var commandSchemaResolver = new CommandSchemaResolver();
// Act
var commandSchemas = commandSchemaResolver.GetCommandSchemas(commandTypes);
// Assert
commandSchemas.Should().BeEquivalentTo(expectedCommandSchemas);
}
[Test]
[TestCaseSource(nameof(GetTestCases_GetCommandSchemas_Negative))]
public void GetCommandSchemas_Negative_Test(IReadOnlyList<Type> commandTypes)
{
// Arrange
var resolver = new CommandSchemaResolver();
// Act & Assert
resolver.Invoking(r => r.GetCommandSchemas(commandTypes))
.Should().ThrowExactly<InvalidCommandSchemaException>();
}
}
}

View File

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

View File

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

View File

@@ -1,42 +0,0 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;
namespace CliFx.Tests
{
public partial class HelpTextRendererTests
{
[Command(Description = "DefaultCommand description.")]
private class DefaultCommand : ICommand
{
[CommandOption("option-a", 'a', Description = "OptionA description.")]
public string OptionA { get; set; }
[CommandOption("option-b", 'b', Description = "OptionB description.")]
public string OptionB { get; set; }
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
}
[Command("cmd", Description = "NamedCommand description.")]
private class NamedCommand : ICommand
{
[CommandOption("option-c", 'c', Description = "OptionC description.")]
public string OptionC { get; set; }
[CommandOption("option-d", 'd', Description = "OptionD description.")]
public string OptionD { get; set; }
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
}
[Command("cmd sub", Description = "NamedSubCommand description.")]
private class NamedSubCommand : ICommand
{
[CommandOption("option-e", 'e', Description = "OptionE description.")]
public string OptionE { get; set; }
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
}
}
}

View File

@@ -3,20 +3,21 @@ using System.Collections.Generic;
using System.Linq;
using CliFx.Models;
using CliFx.Services;
using CliFx.Tests.TestCommands;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests
namespace CliFx.Tests.Services
{
[TestFixture]
public partial class CommandFactoryTests
public class CommandFactoryTests
{
private static CommandSchema GetCommandSchema(Type commandType) =>
new CommandSchemaResolver().GetCommandSchemas(new[] {commandType}).Single();
private static IEnumerable<TestCaseData> GetTestCases_CreateCommand()
{
yield return new TestCaseData(GetCommandSchema(typeof(TestCommand)));
yield return new TestCaseData(GetCommandSchema(typeof(HelloWorldDefaultCommand)));
}
[Test]

View File

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

View File

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

View File

@@ -5,13 +5,14 @@ using System.Globalization;
using CliFx.Exceptions;
using CliFx.Models;
using CliFx.Services;
using CliFx.Tests.TestCustomTypes;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests
namespace CliFx.Tests.Services
{
[TestFixture]
public partial class CommandOptionInputConverterTests
public class CommandOptionInputConverterTests
{
private static IEnumerable<TestCaseData> GetTestCases_ConvertOptionInput()
{
@@ -213,6 +214,12 @@ namespace CliFx.Tests
new[] {47, 69}
);
yield return new TestCaseData(
new CommandOptionInput("option", new[] {"47"}),
typeof(int[]),
new[] {47}
);
yield return new TestCaseData(
new CommandOptionInput("option", new[] {"value1", "value3"}),
typeof(TestEnum[]),
@@ -269,15 +276,26 @@ namespace CliFx.Tests
typeof(int)
);
yield return new TestCaseData(
new CommandOptionInput("option", new[] {"123", "456"}),
typeof(int)
);
yield return new TestCaseData(
new CommandOptionInput("option"),
typeof(int)
);
yield return new TestCaseData(
new CommandOptionInput("option", "123"),
typeof(NonStringParseable)
typeof(TestNonStringParseable)
);
}
[Test]
[TestCaseSource(nameof(GetTestCases_ConvertOptionInput))]
public void ConvertOptionInput_Test(CommandOptionInput optionInput, Type targetType, object expectedConvertedValue)
public void ConvertOptionInput_Test(CommandOptionInput optionInput, Type targetType,
object expectedConvertedValue)
{
// Arrange
var converter = new CommandOptionInputConverter();
@@ -299,7 +317,7 @@ namespace CliFx.Tests
// Act & Assert
converter.Invoking(c => c.ConvertOptionInput(optionInput, targetType))
.Should().ThrowExactly<InvalidCommandOptionInputException>();
.Should().ThrowExactly<CliFxException>();
}
}
}

View File

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

View File

@@ -3,13 +3,14 @@ using System.Collections.Generic;
using System.Linq;
using CliFx.Models;
using CliFx.Services;
using CliFx.Tests.TestCommands;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests
namespace CliFx.Tests.Services
{
[TestFixture]
public partial class DelegateCommandFactoryTests
public class DelegateCommandFactoryTests
{
private static CommandSchema GetCommandSchema(Type commandType) =>
new CommandSchemaResolver().GetCommandSchemas(new[] {commandType}).Single();
@@ -17,8 +18,8 @@ namespace CliFx.Tests
private static IEnumerable<TestCaseData> GetTestCases_CreateCommand()
{
yield return new TestCaseData(
new Func<CommandSchema, ICommand>(schema => (ICommand) Activator.CreateInstance(schema.Type)),
GetCommandSchema(typeof(TestCommand))
new Func<CommandSchema, ICommand>(schema => (ICommand) Activator.CreateInstance(schema.Type!)!),
GetCommandSchema(typeof(HelloWorldDefaultCommand))
);
}

View File

@@ -4,13 +4,14 @@ using System.IO;
using System.Linq;
using CliFx.Models;
using CliFx.Services;
using CliFx.Tests.TestCommands;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests
namespace CliFx.Tests.Services
{
[TestFixture]
public partial class HelpTextRendererTests
public class HelpTextRendererTests
{
private static HelpTextSource CreateHelpTextSource(IReadOnlyList<Type> availableCommandTypes, Type targetCommandType)
{
@@ -27,11 +28,13 @@ namespace CliFx.Tests
{
yield return new TestCaseData(
CreateHelpTextSource(
new[] {typeof(DefaultCommand), typeof(NamedCommand), typeof(NamedSubCommand)},
typeof(DefaultCommand)),
new[] {typeof(HelpDefaultCommand), typeof(HelpNamedCommand), typeof(HelpSubCommand)},
typeof(HelpDefaultCommand)),
new[]
{
"Description",
"HelpDefaultCommand description.",
"Usage",
"[command]", "[options]",
"Options",
@@ -40,20 +43,20 @@ namespace CliFx.Tests
"-h|--help", "Shows help text.",
"--version", "Shows version information.",
"Commands",
"cmd", "NamedCommand description.",
"cmd", "HelpNamedCommand description.",
"You can run", "to show help on a specific command."
}
);
yield return new TestCaseData(
CreateHelpTextSource(
new[] {typeof(DefaultCommand), typeof(NamedCommand), typeof(NamedSubCommand)},
typeof(NamedCommand)),
new[] {typeof(HelpDefaultCommand), typeof(HelpNamedCommand), typeof(HelpSubCommand)},
typeof(HelpNamedCommand)),
new[]
{
"Description",
"NamedCommand description.",
"HelpNamedCommand description.",
"Usage",
"cmd", "[command]", "[options]",
"Options",
@@ -61,20 +64,20 @@ namespace CliFx.Tests
"-d|--option-d", "OptionD description.",
"-h|--help", "Shows help text.",
"Commands",
"sub", "NamedSubCommand description.",
"sub", "HelpSubCommand description.",
"You can run", "to show help on a specific command."
}
);
yield return new TestCaseData(
CreateHelpTextSource(
new[] {typeof(DefaultCommand), typeof(NamedCommand), typeof(NamedSubCommand)},
typeof(NamedSubCommand)),
new[] {typeof(HelpDefaultCommand), typeof(HelpNamedCommand), typeof(HelpSubCommand)},
typeof(HelpSubCommand)),
new[]
{
"Description",
"NamedSubCommand description.",
"HelpSubCommand description.",
"Usage",
"cmd sub", "[options]",
"Options",
@@ -86,13 +89,14 @@ namespace CliFx.Tests
[Test]
[TestCaseSource(nameof(GetTestCases_RenderHelpText))]
public void RenderHelpText_Test(HelpTextSource source, IReadOnlyList<string> expectedSubstrings)
public void RenderHelpText_Test(HelpTextSource source,
IReadOnlyList<string> expectedSubstrings)
{
// Arrange
using (var stdout = new StringWriter())
{
var renderer = new HelpTextRenderer();
using var stdout = new StringWriter();
var console = new VirtualConsole(stdout);
var renderer = new HelpTextRenderer();
// Act
renderer.RenderHelpText(console, source);
@@ -101,5 +105,4 @@ namespace CliFx.Tests
stdout.ToString().Should().ContainAll(expectedSubstrings);
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -7,8 +7,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx", "CliFx\CliFx.csproj
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx.Tests", "CliFx.Tests\CliFx.Tests.csproj", "{268CF863-65A5-49BB-93CF-08972B7756DC}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx.Tests.Dummy", "CliFx.Tests.Dummy\CliFx.Tests.Dummy.csproj", "{4904B3EB-3286-4F1B-8B74-6FF051C8E787}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{3AAE8166-BB8E-49DA-844C-3A0EE6BD40A0}"
ProjectSection(SolutionItems) = preProject
Changelog.md = Changelog.md
@@ -18,7 +16,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx.Benchmarks", "CliFx.Benchmarks\CliFx.Benchmarks.csproj", "{8ACD6DC2-D768-4850-9223-5B7C83A78513}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CliFx.Demo", "CliFx.Demo\CliFx.Demo.csproj", "{AAB6844C-BF71-448F-A11B-89AEE459AB15}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx.Demo", "CliFx.Demo\CliFx.Demo.csproj", "{AAB6844C-BF71-448F-A11B-89AEE459AB15}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -54,18 +52,6 @@ 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

View File

@@ -10,27 +10,27 @@ namespace CliFx.Attributes
{
/// <summary>
/// Command name.
/// This can be null if this is the default command.
/// </summary>
public string Name { get; }
public string? Name { get; }
/// <summary>
/// Command description, which is used in help text.
/// </summary>
public string Description { get; set; }
public string? Description { get; set; }
/// <summary>
/// Initializes an instance of <see cref="CommandAttribute"/>.
/// </summary>
public CommandAttribute(string name)
{
Name = name; // can be null
Name = name;
}
/// <summary>
/// Initializes an instance of <see cref="CommandAttribute"/>.
/// </summary>
public CommandAttribute()
: this(null)
{
}
}

View File

@@ -10,11 +10,13 @@ namespace CliFx.Attributes
{
/// <summary>
/// Option name.
/// Either <see cref="Name"/> or <see cref="ShortName"/> must be set.
/// </summary>
public string Name { get; }
public string? Name { get; }
/// <summary>
/// Option short name.
/// Either <see cref="Name"/> or <see cref="ShortName"/> must be set.
/// </summary>
public char? ShortName { get; }
@@ -26,15 +28,20 @@ namespace CliFx.Attributes
/// <summary>
/// Option description, which is used in help text.
/// </summary>
public string Description { get; set; }
public string? Description { get; set; }
/// <summary>
/// Optional environment variable name that will be used as fallback value if no option value is specified.
/// </summary>
public string? EnvironmentVariableName { get; set; }
/// <summary>
/// Initializes an instance of <see cref="CommandOptionAttribute"/>.
/// </summary>
public CommandOptionAttribute(string name, char? shortName)
private CommandOptionAttribute(string? name, char? shortName)
{
Name = name; // can be null
ShortName = shortName; // can be null
Name = name;
ShortName = shortName;
}
/// <summary>
@@ -57,7 +64,7 @@ namespace CliFx.Attributes
/// Initializes an instance of <see cref="CommandOptionAttribute"/>.
/// </summary>
public CommandOptionAttribute(char shortName)
: this(null, shortName)
: this(null, (char?) shortName)
{
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Exceptions;
@@ -31,86 +32,138 @@ namespace CliFx
IConsole console, ICommandInputParser commandInputParser, ICommandSchemaResolver commandSchemaResolver,
ICommandFactory commandFactory, ICommandInitializer commandInitializer, IHelpTextRenderer helpTextRenderer)
{
_metadata = metadata.GuardNotNull(nameof(metadata));
_configuration = configuration.GuardNotNull(nameof(configuration));
_metadata = metadata;
_configuration = configuration;
_console = console.GuardNotNull(nameof(console));
_commandInputParser = commandInputParser.GuardNotNull(nameof(commandInputParser));
_commandSchemaResolver = commandSchemaResolver.GuardNotNull(nameof(commandSchemaResolver));
_commandFactory = commandFactory.GuardNotNull(nameof(commandFactory));
_commandInitializer = commandInitializer.GuardNotNull(nameof(commandInitializer));
_helpTextRenderer = helpTextRenderer.GuardNotNull(nameof(helpTextRenderer));
_console = console;
_commandInputParser = commandInputParser;
_commandSchemaResolver = commandSchemaResolver;
_commandFactory = commandFactory;
_commandInitializer = commandInitializer;
_helpTextRenderer = helpTextRenderer;
}
/// <inheritdoc />
public async Task<int> RunAsync(IReadOnlyList<string> commandLineArguments)
private async Task<int?> HandleDebugDirectiveAsync(CommandInput commandInput)
{
commandLineArguments.GuardNotNull(nameof(commandLineArguments));
// Debug mode is enabled if it's allowed in the application and it was requested via corresponding directive
var isDebugMode = _configuration.IsDebugModeAllowed && commandInput.IsDebugDirectiveSpecified();
try
// If not in debug mode, pass execution to the next handler
if (!isDebugMode)
return null;
// Inform user which process they need to attach debugger to
_console.WithForegroundColor(ConsoleColor.Green,
() => _console.Output.WriteLine($"Attach debugger to PID {Process.GetCurrentProcess().Id} to continue."));
// Wait until debugger is attached
while (!Debugger.IsAttached)
await Task.Delay(100);
// Debug directive never short-circuits
return null;
}
private int? HandlePreviewDirective(CommandInput commandInput)
{
// Get schemas for all available command types
var availableCommandSchemas = _commandSchemaResolver.GetCommandSchemas(_configuration.CommandTypes);
// Preview mode is enabled if it's allowed in the application and it was requested via corresponding directive
var isPreviewMode = _configuration.IsPreviewModeAllowed && commandInput.IsPreviewDirectiveSpecified();
// Parse command input from arguments
var commandInput = _commandInputParser.ParseCommandInput(commandLineArguments);
// If not in preview mode, pass execution to the next handler
if (!isPreviewMode)
return null;
// Find command schema matching the name specified in the input
var targetCommandSchema = availableCommandSchemas.FindByName(commandInput.CommandName);
// Render command name
_console.Output.WriteLine($"Command name: {commandInput.CommandName}");
_console.Output.WriteLine();
// Handle cases where requested command is not defined
if (targetCommandSchema == null)
// Render directives
_console.Output.WriteLine("Directives:");
foreach (var directive in commandInput.Directives)
{
_console.Output.Write(" ");
_console.Output.WriteLine(directive);
}
// Margin
_console.Output.WriteLine();
// Render options
_console.Output.WriteLine("Options:");
foreach (var option in commandInput.Options)
{
_console.Output.Write(" ");
_console.Output.WriteLine(option);
}
// Short-circuit with exit code 0
return 0;
}
private int? HandleVersionOption(CommandInput commandInput)
{
// Version should be rendered if it was requested on a default command
var shouldRenderVersion = !commandInput.IsCommandSpecified() && commandInput.IsVersionOptionSpecified();
// If shouldn't render version, pass execution to the next handler
if (!shouldRenderVersion)
return null;
// Render version text
_console.Output.WriteLine(_metadata.VersionText);
// Short-circuit with exit code 0
return 0;
}
private int? HandleHelpOption(CommandInput commandInput,
IReadOnlyList<CommandSchema> availableCommandSchemas, CommandSchema? targetCommandSchema)
{
// Help should be rendered if it was requested, or when executing a command which isn't defined
var shouldRenderHelp = commandInput.IsHelpOptionSpecified() || targetCommandSchema == null;
// If shouldn't render help, pass execution to the next handler
if (!shouldRenderHelp)
return null;
// Keep track whether there was an error in the input
var isError = false;
// If specified a command - show error
// If target command isn't defined, find its contextual replacement
if (targetCommandSchema == null)
{
// If command was specified, inform the user that it's not defined
if (commandInput.IsCommandSpecified())
{
isError = true;
_console.WithForegroundColor(ConsoleColor.Red,
() => _console.Error.WriteLine($"Specified command [{commandInput.CommandName}] is not defined."));
isError = true;
}
// Get parent command schema
var parentCommandSchema = availableCommandSchemas.FindParent(commandInput.CommandName);
// Replace target command with closest parent of specified command
targetCommandSchema = availableCommandSchemas.FindParent(commandInput.CommandName);
// Show help for parent command if it's defined
if (parentCommandSchema != null)
// If there's no parent, replace with stub default command
if (targetCommandSchema == null)
{
var helpTextSource = new HelpTextSource(_metadata, availableCommandSchemas, parentCommandSchema);
_helpTextRenderer.RenderHelpText(_console, helpTextSource);
targetCommandSchema = CommandSchema.StubDefaultCommand;
availableCommandSchemas = availableCommandSchemas.Concat(CommandSchema.StubDefaultCommand).ToArray();
}
// Otherwise show help for a stub default command
else
{
var helpTextSource = new HelpTextSource(_metadata,
availableCommandSchemas.Concat(CommandSchema.StubDefaultCommand).ToArray(),
CommandSchema.StubDefaultCommand);
_helpTextRenderer.RenderHelpText(_console, helpTextSource);
}
// Build help text source
var helpTextSource = new HelpTextSource(_metadata, availableCommandSchemas, targetCommandSchema);
// Render help text
_helpTextRenderer.RenderHelpText(_console, helpTextSource);
// Short-circuit with appropriate exit code
return isError ? -1 : 0;
}
// Show version if it was requested without specifying a command
if (commandInput.IsVersionRequested() && !commandInput.IsCommandSpecified())
private async Task<int> HandleCommandExecutionAsync(CommandInput commandInput, CommandSchema targetCommandSchema)
{
_console.Output.WriteLine(_metadata.VersionText);
return 0;
}
// Show help if it was requested
if (commandInput.IsHelpRequested())
{
var helpTextSource = new HelpTextSource(_metadata, availableCommandSchemas, targetCommandSchema);
_helpTextRenderer.RenderHelpText(_console, helpTextSource);
return 0;
}
// Create an instance of the command
var command = _commandFactory.CreateCommand(targetCommandSchema);
@@ -120,24 +173,56 @@ namespace CliFx
// Execute command
await command.ExecuteAsync(_console);
// Finish the chain with exit code 0
return 0;
}
/// <inheritdoc />
public async Task<int> RunAsync(IReadOnlyList<string> commandLineArguments)
{
try
{
// Parse command input from arguments
var commandInput = _commandInputParser.ParseCommandInput(commandLineArguments);
// Get schemas for all available command types
var availableCommandSchemas = _commandSchemaResolver.GetCommandSchemas(_configuration.CommandTypes);
// Find command schema matching the name specified in the input
var targetCommandSchema = availableCommandSchemas.FindByName(commandInput.CommandName);
// Chain handlers until the first one that produces an exit code
return
await HandleDebugDirectiveAsync(commandInput) ??
HandlePreviewDirective(commandInput) ??
HandleVersionOption(commandInput) ??
HandleHelpOption(commandInput, availableCommandSchemas, targetCommandSchema) ??
await HandleCommandExecutionAsync(commandInput, targetCommandSchema!);
}
catch (Exception ex)
{
// We want to catch exceptions in order to print errors and return correct exit codes.
// Also, by doing this we get rid of the annoying Windows troubleshooting dialog that shows up on unhandled exceptions.
// Doing this also gets rid of the annoying Windows troubleshooting dialog that shows up on unhandled exceptions.
// In case we catch a CliFx-specific exception, we want to just show the error message, not the stack trace.
// Stack trace isn't very useful to the user if the exception is not really coming from their code.
// Prefer showing message without stack trace on exceptions coming from CliFx or on CommandException
if (!string.IsNullOrWhiteSpace(ex.Message) && (ex is CliFxException || ex is CommandException))
{
_console.WithForegroundColor(ConsoleColor.Red, () => _console.Error.WriteLine(ex.Message));
}
else
{
_console.WithForegroundColor(ConsoleColor.Red, () => _console.Error.WriteLine(ex));
}
// CommandException is the same, but it also lets users specify exit code so we want to return that instead of default.
var message = ex is CliFxException && !ex.Message.IsNullOrWhiteSpace() ? ex.Message : ex.ToString();
var exitCode = ex is CommandException commandEx ? commandEx.ExitCode : ex.HResult;
_console.WithForegroundColor(ConsoleColor.Red, () => _console.Error.WriteLine(message));
return exitCode;
// Return exit code if it was specified via CommandException
if (ex is CommandException commandException)
{
return commandException.ExitCode;
}
else
{
return ex.HResult;
}
}
}
}

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using CliFx.Attributes;
using CliFx.Internal;
using CliFx.Models;
using CliFx.Services;
@@ -16,18 +17,20 @@ namespace CliFx
{
private readonly HashSet<Type> _commandTypes = new HashSet<Type>();
private string _title;
private string _executableName;
private string _versionText;
private string _description;
private IConsole _console;
private ICommandFactory _commandFactory;
private bool _isDebugModeAllowed = true;
private bool _isPreviewModeAllowed = true;
private string? _title;
private string? _executableName;
private string? _versionText;
private string? _description;
private IConsole? _console;
private ICommandFactory? _commandFactory;
private ICommandOptionInputConverter? _commandOptionInputConverter;
private IEnvironmentVariablesProvider? _environmentVariablesProvider;
/// <inheritdoc />
public ICliApplicationBuilder AddCommand(Type commandType)
{
commandType.GuardNotNull(nameof(commandType));
_commandTypes.Add(commandType);
return this;
@@ -36,9 +39,10 @@ namespace CliFx
/// <inheritdoc />
public ICliApplicationBuilder AddCommandsFrom(Assembly commandAssembly)
{
commandAssembly.GuardNotNull(nameof(commandAssembly));
var commandTypes = commandAssembly.ExportedTypes.Where(t => t.Implements(typeof(ICommand)));
var commandTypes = commandAssembly.ExportedTypes
.Where(t => t.Implements(typeof(ICommand)))
.Where(t => t.IsDefined(typeof(CommandAttribute)))
.Where(t => !t.IsAbstract && !t.IsInterface);
foreach (var commandType in commandTypes)
AddCommand(commandType);
@@ -46,112 +50,95 @@ namespace CliFx
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder AllowDebugMode(bool isAllowed = true)
{
_isDebugModeAllowed = isAllowed;
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder AllowPreviewMode(bool isAllowed = true)
{
_isPreviewModeAllowed = isAllowed;
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder UseTitle(string title)
{
_title = title.GuardNotNull(nameof(title));
_title = title;
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder UseExecutableName(string executableName)
{
_executableName = executableName.GuardNotNull(nameof(executableName));
_executableName = executableName;
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder UseVersionText(string versionText)
{
_versionText = versionText.GuardNotNull(nameof(versionText));
_versionText = versionText;
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder UseDescription(string description)
public ICliApplicationBuilder UseDescription(string? description)
{
_description = description; // can be null
_description = description;
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder UseConsole(IConsole console)
{
_console = console.GuardNotNull(nameof(console));
_console = console;
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder UseCommandFactory(ICommandFactory factory)
{
_commandFactory = factory.GuardNotNull(nameof(factory));
_commandFactory = factory;
return this;
}
private void SetFallbackValues()
/// <inheritdoc />
public ICliApplicationBuilder UseCommandOptionInputConverter(ICommandOptionInputConverter converter)
{
if (_title.IsNullOrWhiteSpace())
{
// Entry assembly is null in tests
UseTitle(EntryAssembly?.GetName().Name ?? "App");
_commandOptionInputConverter = converter;
return this;
}
if (_executableName.IsNullOrWhiteSpace())
/// <inheritdoc />
public ICliApplicationBuilder UseEnvironmentVariablesProvider(IEnvironmentVariablesProvider environmentVariablesProvider)
{
// Entry assembly is null in tests
var entryAssemblyLocation = EntryAssembly?.Location;
// Set different executable name depending on location
if (!entryAssemblyLocation.IsNullOrWhiteSpace())
{
// Prepend 'dotnet' to assembly file name if the entry assembly is a dll file (extension needs to be kept)
if (string.Equals(Path.GetExtension(entryAssemblyLocation), ".dll", StringComparison.OrdinalIgnoreCase))
{
UseExecutableName("dotnet " + Path.GetFileName(entryAssemblyLocation));
}
// Otherwise just use assembly file name without extension
else
{
UseExecutableName(Path.GetFileNameWithoutExtension(entryAssemblyLocation));
}
}
// If location is null then just use a stub
else
{
UseExecutableName("app");
}
}
if (_versionText.IsNullOrWhiteSpace())
{
// Entry assembly is null in tests
UseVersionText(EntryAssembly?.GetName().Version.ToString() ?? "1.0");
}
if (_console == null)
{
UseConsole(new SystemConsole());
}
if (_commandFactory == null)
{
UseCommandFactory(new CommandFactory());
}
_environmentVariablesProvider = environmentVariablesProvider;
return this;
}
/// <inheritdoc />
public ICliApplication Build()
{
// Use defaults for required parameters that were not configured
SetFallbackValues();
_title ??= GetDefaultTitle() ?? "App";
_executableName ??= GetDefaultExecutableName() ?? "app";
_versionText ??= GetDefaultVersionText() ?? "v1.0";
_console ??= new SystemConsole();
_commandFactory ??= new CommandFactory();
_commandOptionInputConverter ??= new CommandOptionInputConverter();
_environmentVariablesProvider ??= new EnvironmentVariablesProvider();
// Project parameters to expected types
var metadata = new ApplicationMetadata(_title, _executableName, _versionText, _description);
var configuration = new ApplicationConfiguration(_commandTypes.ToArray());
var configuration = new ApplicationConfiguration(_commandTypes.ToArray(), _isDebugModeAllowed, _isPreviewModeAllowed);
return new CliApplication(metadata, configuration,
_console, new CommandInputParser(), new CommandSchemaResolver(),
_commandFactory, new CommandInitializer(), new HelpTextRenderer());
_console, new CommandInputParser(_environmentVariablesProvider), new CommandSchemaResolver(),
_commandFactory, new CommandInitializer(_commandOptionInputConverter, new EnvironmentVariablesParser()), new HelpTextRenderer());
}
}
@@ -159,6 +146,25 @@ namespace CliFx
{
private static readonly Lazy<Assembly> LazyEntryAssembly = new Lazy<Assembly>(Assembly.GetEntryAssembly);
// Entry assembly is null in tests
private static Assembly EntryAssembly => LazyEntryAssembly.Value;
private static string GetDefaultTitle() => EntryAssembly?.GetName().Name ?? "";
private static string GetDefaultExecutableName()
{
var entryAssemblyLocation = EntryAssembly?.Location;
// If it's a .dll assembly, prepend 'dotnet' and keep the file extension
if (string.Equals(Path.GetExtension(entryAssemblyLocation), ".dll", StringComparison.OrdinalIgnoreCase))
{
return "dotnet " + Path.GetFileName(entryAssemblyLocation);
}
// Otherwise just use assembly file name without extension
return Path.GetFileNameWithoutExtension(entryAssemblyLocation);
}
private static string GetDefaultVersionText() => EntryAssembly != null ? $"v{EntryAssembly.GetName().Version}" : "";
}
}

View File

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

View File

@@ -5,12 +5,12 @@ namespace CliFx.Exceptions
/// <summary>
/// Domain exception thrown within CliFx.
/// </summary>
public abstract class CliFxException : Exception
public class CliFxException : Exception
{
/// <summary>
/// Initializes an instance of <see cref="CliFxException"/>.
/// </summary>
protected CliFxException(string message)
public CliFxException(string? message)
: base(message)
{
}
@@ -18,7 +18,7 @@ namespace CliFx.Exceptions
/// <summary>
/// Initializes an instance of <see cref="CliFxException"/>.
/// </summary>
protected CliFxException(string message, Exception innerException)
public CliFxException(string? message, Exception? innerException)
: base(message, innerException)
{
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using CliFx.Internal;
using CliFx.Models;
using CliFx.Services;
@@ -17,9 +16,6 @@ namespace CliFx
/// </summary>
public static ICliApplicationBuilder AddCommands(this ICliApplicationBuilder builder, IReadOnlyList<Type> commandTypes)
{
builder.GuardNotNull(nameof(builder));
commandTypes.GuardNotNull(nameof(commandTypes));
foreach (var commandType in commandTypes)
builder.AddCommand(commandType);
@@ -31,9 +27,6 @@ namespace CliFx
/// </summary>
public static ICliApplicationBuilder AddCommandsFrom(this ICliApplicationBuilder builder, IReadOnlyList<Assembly> commandAssemblies)
{
builder.GuardNotNull(nameof(builder));
commandAssemblies.GuardNotNull(nameof(commandAssemblies));
foreach (var commandAssembly in commandAssemblies)
builder.AddCommandsFrom(commandAssembly);
@@ -43,21 +36,13 @@ namespace CliFx
/// <summary>
/// Adds commands from calling assembly to the application.
/// </summary>
public static ICliApplicationBuilder AddCommandsFromThisAssembly(this ICliApplicationBuilder builder)
{
builder.GuardNotNull(nameof(builder));
return builder.AddCommandsFrom(Assembly.GetCallingAssembly());
}
public static ICliApplicationBuilder AddCommandsFromThisAssembly(this ICliApplicationBuilder builder) =>
builder.AddCommandsFrom(Assembly.GetCallingAssembly());
/// <summary>
/// Configures application to use specified factory method for creating new instances of <see cref="ICommand"/>.
/// </summary>
public static ICliApplicationBuilder UseCommandFactory(this ICliApplicationBuilder builder, Func<CommandSchema, ICommand> factoryMethod)
{
builder.GuardNotNull(nameof(builder));
factoryMethod.GuardNotNull(nameof(factoryMethod));
return builder.UseCommandFactory(new DelegateCommandFactory(factoryMethod));
}
public static ICliApplicationBuilder UseCommandFactory(this ICliApplicationBuilder builder, Func<CommandSchema, ICommand> factoryMethod) =>
builder.UseCommandFactory(new DelegateCommandFactory(factoryMethod));
}
}

View File

@@ -19,6 +19,16 @@ namespace CliFx
/// </summary>
ICliApplicationBuilder AddCommandsFrom(Assembly commandAssembly);
/// <summary>
/// Specifies whether debug mode (enabled with [debug] directive) is allowed in the application.
/// </summary>
ICliApplicationBuilder AllowDebugMode(bool isAllowed = true);
/// <summary>
/// Specifies whether preview mode (enabled with [preview] directive) is allowed in the application.
/// </summary>
ICliApplicationBuilder AllowPreviewMode(bool isAllowed = true);
/// <summary>
/// Sets application title, which appears in the help text.
/// </summary>
@@ -37,7 +47,7 @@ namespace CliFx
/// <summary>
/// Sets application description, which appears in the help text.
/// </summary>
ICliApplicationBuilder UseDescription(string description);
ICliApplicationBuilder UseDescription(string? description);
/// <summary>
/// Configures application to use specified implementation of <see cref="IConsole"/>.
@@ -49,6 +59,16 @@ namespace CliFx
/// </summary>
ICliApplicationBuilder UseCommandFactory(ICommandFactory factory);
/// <summary>
/// Configures application to use specified implementation of <see cref="ICommandOptionInputConverter"/>.
/// </summary>
ICliApplicationBuilder UseCommandOptionInputConverter(ICommandOptionInputConverter converter);
/// <summary>
/// Configures application to use specified implementation of <see cref="IEnvironmentVariablesProvider"/>.
/// </summary>
ICliApplicationBuilder UseEnvironmentVariablesProvider(IEnvironmentVariablesProvider environmentVariablesProvider);
/// <summary>
/// Creates an instance of <see cref="ICliApplication"/> using configured parameters.
/// Default values are used in place of parameters that were not specified.

View File

@@ -8,8 +8,6 @@ namespace CliFx.Internal
{
internal static class Extensions
{
public static bool IsNullOrWhiteSpace(this string s) => string.IsNullOrWhiteSpace(s);
public static string Repeat(this char c, int count) => new string(c, count);
public static string AsString(this char c) => c.Repeat(1);
@@ -26,11 +24,6 @@ namespace CliFx.Internal
public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) =>
builder.Length > 0 ? builder.Append(value) : builder;
public static TValue GetValueOrDefault<TKey, TValue>(this IReadOnlyDictionary<TKey, TValue> dic, TKey key) =>
dic.TryGetValue(key, out var result) ? result : default;
public static IEnumerable<T> ExceptNull<T>(this IEnumerable<T> source) where T : class => source.Where(i => i != null);
public static IEnumerable<T> Concat<T>(this IEnumerable<T> source, T value)
{
foreach (var i in source)
@@ -41,8 +34,13 @@ namespace CliFx.Internal
public static bool Implements(this Type type, Type interfaceType) => type.GetInterfaces().Contains(interfaceType);
public static Type GetEnumerableUnderlyingType(this Type type)
public static Type? GetNullableUnderlyingType(this Type type) => Nullable.GetUnderlyingType(type);
public static Type? GetEnumerableUnderlyingType(this Type type)
{
if (type.IsPrimitive)
return null;
if (type == typeof(IEnumerable))
return typeof(object);
@@ -51,7 +49,7 @@ namespace CliFx.Internal
return type.GetInterfaces()
.Select(GetEnumerableUnderlyingType)
.ExceptNull()
.Where(t => t != default)
.OrderByDescending(t => t != typeof(object)) // prioritize more specific types
.FirstOrDefault();
}
@@ -65,5 +63,8 @@ namespace CliFx.Internal
return array;
}
public static bool IsCollection(this Type type) =>
type != typeof(string) && type.GetEnumerableUnderlyingType() != null;
}
}

View File

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

View File

@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using CliFx.Internal;
namespace CliFx.Models
{
@@ -10,16 +9,29 @@ namespace CliFx.Models
public class ApplicationConfiguration
{
/// <summary>
/// Command types defined in the application.
/// 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)
public ApplicationConfiguration(IReadOnlyList<Type> commandTypes,
bool isDebugModeAllowed, bool isPreviewModeAllowed)
{
CommandTypes = commandTypes.GuardNotNull(nameof(commandTypes));
CommandTypes = commandTypes;
IsDebugModeAllowed = isDebugModeAllowed;
IsPreviewModeAllowed = isPreviewModeAllowed;
}
}
}

View File

@@ -1,6 +1,4 @@
using CliFx.Internal;
namespace CliFx.Models
namespace CliFx.Models
{
/// <summary>
/// Metadata associated with an application.
@@ -25,17 +23,17 @@ namespace CliFx.Models
/// <summary>
/// Application description.
/// </summary>
public string Description { get; }
public string? Description { get; }
/// <summary>
/// Initializes an instance of <see cref="ApplicationMetadata"/>.
/// </summary>
public ApplicationMetadata(string title, string executableName, string versionText, string description)
public ApplicationMetadata(string title, string executableName, string versionText, string? description)
{
Title = title.GuardNotNull(nameof(title));
ExecutableName = executableName.GuardNotNull(nameof(executableName));
VersionText = versionText.GuardNotNull(nameof(versionText));
Description = description; // can be null
Title = title;
ExecutableName = executableName;
VersionText = versionText;
Description = description;
}
}
}

View File

@@ -13,20 +13,57 @@ namespace CliFx.Models
/// Specified command name.
/// Can be null if command was not specified.
/// </summary>
public string CommandName { get; }
public string? CommandName { get; }
/// <summary>
/// Specified directives.
/// </summary>
public IReadOnlyList<string> Directives { get; }
/// <summary>
/// Specified options.
/// </summary>
public IReadOnlyList<CommandOptionInput> Options { get; }
/// <summary>
/// Environment variables available when the command was parsed
/// </summary>
public IReadOnlyDictionary<string, string> EnvironmentVariables { get; }
/// <summary>
/// Initializes an instance of <see cref="CommandInput"/>.
/// </summary>
public CommandInput(string commandName, IReadOnlyList<CommandOptionInput> options)
public CommandInput(string? commandName, IReadOnlyList<string> directives, IReadOnlyList<CommandOptionInput> options,
IReadOnlyDictionary<string, string> environmentVariables)
{
CommandName = commandName;
Directives = directives;
Options = options;
EnvironmentVariables = environmentVariables;
}
/// <summary>
/// Initializes an instance of <see cref="CommandInput"/>.
/// </summary>
public CommandInput(string? commandName, IReadOnlyList<string> directives, IReadOnlyList<CommandOptionInput> options)
: this(commandName, directives, options, EmptyEnvironmentVariables)
{
}
/// <summary>
/// Initializes an instance of <see cref="CommandInput"/>.
/// </summary>
public CommandInput(string? commandName, IReadOnlyList<CommandOptionInput> options, IReadOnlyDictionary<string, string> environmentVariables)
: this(commandName, EmptyDirectives, options, environmentVariables)
{
}
/// <summary>
/// Initializes an instance of <see cref="CommandInput"/>.
/// </summary>
public CommandInput(string? commandName, IReadOnlyList<CommandOptionInput> options)
: this(commandName, EmptyDirectives, options)
{
CommandName = commandName; // can be null
Options = options.GuardNotNull(nameof(options));
}
/// <summary>
@@ -40,16 +77,8 @@ namespace CliFx.Models
/// <summary>
/// Initializes an instance of <see cref="CommandInput"/>.
/// </summary>
public CommandInput(string commandName)
: this(commandName, new CommandOptionInput[0])
{
}
/// <summary>
/// Initializes an instance of <see cref="CommandInput"/>.
/// </summary>
public CommandInput()
: this(null, new CommandOptionInput[0])
public CommandInput(string? commandName)
: this(commandName, EmptyOptions)
{
}
@@ -58,9 +87,15 @@ namespace CliFx.Models
{
var buffer = new StringBuilder();
if (!CommandName.IsNullOrWhiteSpace())
if (!string.IsNullOrWhiteSpace(CommandName))
buffer.Append(CommandName);
foreach (var directive in Directives)
{
buffer.AppendIfNotEmpty(' ');
buffer.Append(directive);
}
foreach (var option in Options)
{
buffer.AppendIfNotEmpty(' ');
@@ -73,9 +108,13 @@ namespace CliFx.Models
public partial class CommandInput
{
private static readonly IReadOnlyList<string> EmptyDirectives = new string[0];
private static readonly IReadOnlyList<CommandOptionInput> EmptyOptions = new CommandOptionInput[0];
private static readonly IReadOnlyDictionary<string, string> EmptyEnvironmentVariables = new Dictionary<string, string>();
/// <summary>
/// Empty input.
/// </summary>
public static CommandInput Empty { get; } = new CommandInput();
public static CommandInput Empty { get; } = new CommandInput(EmptyOptions);
}
}

View File

@@ -7,7 +7,7 @@ namespace CliFx.Models
/// <summary>
/// Parsed option from command line input.
/// </summary>
public class CommandOptionInput
public partial class CommandOptionInput
{
/// <summary>
/// Specified option alias.
@@ -24,8 +24,8 @@ namespace CliFx.Models
/// </summary>
public CommandOptionInput(string alias, IReadOnlyList<string> values)
{
Alias = alias.GuardNotNull(nameof(alias));
Values = values.GuardNotNull(nameof(values));
Alias = alias;
Values = values;
}
/// <summary>
@@ -40,7 +40,7 @@ namespace CliFx.Models
/// Initializes an instance of <see cref="CommandOptionInput"/>.
/// </summary>
public CommandOptionInput(string alias)
: this(alias, new string[0])
: this(alias, EmptyValues)
{
}
@@ -70,4 +70,9 @@ namespace CliFx.Models
return buffer.ToString();
}
}
public partial class CommandOptionInput
{
private static readonly IReadOnlyList<string> EmptyValues = new string[0];
}
}

View File

@@ -1,6 +1,5 @@
using System.Reflection;
using System.Text;
using CliFx.Internal;
namespace CliFx.Models
{
@@ -12,12 +11,12 @@ namespace CliFx.Models
/// <summary>
/// Underlying property.
/// </summary>
public PropertyInfo Property { get; }
public PropertyInfo? Property { get; }
/// <summary>
/// Option name.
/// </summary>
public string Name { get; }
public string? Name { get; }
/// <summary>
/// Option short name.
@@ -32,18 +31,24 @@ namespace CliFx.Models
/// <summary>
/// Option description.
/// </summary>
public string Description { get; }
public string? Description { get; }
/// <summary>
/// Optional environment variable name that will be used as fallback value if no option value is specified.
/// </summary>
public string? EnvironmentVariableName { get; }
/// <summary>
/// Initializes an instance of <see cref="CommandOptionSchema"/>.
/// </summary>
public CommandOptionSchema(PropertyInfo property, string name, char? shortName, bool isRequired, string description)
public CommandOptionSchema(PropertyInfo? property, string? name, char? shortName, bool isRequired, string? description, string? environmentVariableName)
{
Property = property; // can be null
Name = name; // can be null
ShortName = shortName; // can be null
Property = property;
Name = name;
ShortName = shortName;
IsRequired = isRequired;
Description = description; // can be null
Description = description;
EnvironmentVariableName = environmentVariableName;
}
/// <inheritdoc />
@@ -54,10 +59,10 @@ namespace CliFx.Models
if (IsRequired)
buffer.Append('*');
if (!Name.IsNullOrWhiteSpace())
if (!string.IsNullOrWhiteSpace(Name))
buffer.Append(Name);
if (!Name.IsNullOrWhiteSpace() && ShortName != null)
if (!string.IsNullOrWhiteSpace(Name) && ShortName != null)
buffer.Append('|');
if (ShortName != null)
@@ -75,9 +80,9 @@ namespace CliFx.Models
// ...in CliApplication (when reading) and HelpTextRenderer (when writing).
internal static CommandOptionSchema HelpOption { get; } =
new CommandOptionSchema(null, "help", 'h', false, "Shows help text.");
new CommandOptionSchema(null, "help", 'h', false, "Shows help text.", null);
internal static CommandOptionSchema VersionOption { get; } =
new CommandOptionSchema(null, "version", null, false, "Shows version information.");
new CommandOptionSchema(null, "version", null, false, "Shows version information.", null);
}
}

View File

@@ -13,17 +13,17 @@ namespace CliFx.Models
/// <summary>
/// Underlying type.
/// </summary>
public Type Type { get; }
public Type? Type { get; }
/// <summary>
/// Command name.
/// </summary>
public string Name { get; }
public string? Name { get; }
/// <summary>
/// Command description.
/// </summary>
public string Description { get; }
public string? Description { get; }
/// <summary>
/// Command options.
@@ -33,12 +33,12 @@ namespace CliFx.Models
/// <summary>
/// Initializes an instance of <see cref="CommandSchema"/>.
/// </summary>
public CommandSchema(Type type, string name, string description, IReadOnlyList<CommandOptionSchema> options)
public CommandSchema(Type? type, string? name, string? description, IReadOnlyList<CommandOptionSchema> options)
{
Type = type; // can be null
Name = name; // can be null
Description = description; // can be null
Options = options.GuardNotNull(nameof(options));
Type = type;
Name = name;
Description = description;
Options = options;
}
/// <inheritdoc />
@@ -46,7 +46,7 @@ namespace CliFx.Models
{
var buffer = new StringBuilder();
if (!Name.IsNullOrWhiteSpace())
if (!string.IsNullOrWhiteSpace(Name))
buffer.Append(Name);
foreach (var option in Options)

View File

@@ -1,7 +1,7 @@
using System;
using CliFx.Internal;
using System;
using System.Collections.Generic;
using System.Linq;
using CliFx.Internal;
namespace CliFx.Models
{
@@ -13,13 +13,11 @@ namespace CliFx.Models
/// <summary>
/// Finds a command that has specified name, or null if not found.
/// </summary>
public static CommandSchema FindByName(this IReadOnlyList<CommandSchema> commandSchemas, string commandName)
public static CommandSchema? FindByName(this IReadOnlyList<CommandSchema> commandSchemas, string? commandName)
{
commandSchemas.GuardNotNull(nameof(commandSchemas));
// If looking for default command, don't compare names directly
// ...because null and empty are both valid names for default command
if (commandName.IsNullOrWhiteSpace())
if (string.IsNullOrWhiteSpace(commandName))
return commandSchemas.FirstOrDefault(c => c.IsDefault());
return commandSchemas.FirstOrDefault(c => string.Equals(c.Name, commandName, StringComparison.OrdinalIgnoreCase));
@@ -28,12 +26,10 @@ namespace CliFx.Models
/// <summary>
/// Finds parent command to the command that has specified name, or null if not found.
/// </summary>
public static CommandSchema FindParent(this IReadOnlyList<CommandSchema> commandSchemas, string commandName)
public static CommandSchema? FindParent(this IReadOnlyList<CommandSchema> commandSchemas, string? commandName)
{
commandSchemas.GuardNotNull(nameof(commandSchemas));
// If command has no name, it's the default command so it doesn't have a parent
if (commandName.IsNullOrWhiteSpace())
if (string.IsNullOrWhiteSpace(commandName))
return null;
// Repeatedly cut off individual words from the name until we find a command with that name
@@ -56,12 +52,9 @@ namespace CliFx.Models
/// </summary>
public static bool MatchesAlias(this CommandOptionSchema optionSchema, string alias)
{
optionSchema.GuardNotNull(nameof(optionSchema));
alias.GuardNotNull(nameof(alias));
// Compare against name. Case is ignored.
var matchesByName =
!optionSchema.Name.IsNullOrWhiteSpace() &&
!string.IsNullOrWhiteSpace(optionSchema.Name) &&
string.Equals(optionSchema.Name, alias, StringComparison.OrdinalIgnoreCase);
// Compare against short name. Case is NOT ignored.
@@ -73,15 +66,10 @@ namespace CliFx.Models
}
/// <summary>
/// Finds an option that matches specified alias, or null if not found.
/// Finds an option input that matches the option schema specified, or null if not found.
/// </summary>
public static CommandOptionSchema FindByAlias(this IReadOnlyList<CommandOptionSchema> optionSchemas, string alias)
{
optionSchemas.GuardNotNull(nameof(optionSchemas));
alias.GuardNotNull(nameof(alias));
return optionSchemas.FirstOrDefault(o => o.MatchesAlias(alias));
}
public static CommandOptionInput? FindByOptionSchema(this IReadOnlyList<CommandOptionInput> optionInputs, CommandOptionSchema optionSchema) =>
optionInputs.FirstOrDefault(o => optionSchema.MatchesAlias(o.Alias));
/// <summary>
/// Gets valid aliases for the option.
@@ -90,8 +78,8 @@ namespace CliFx.Models
{
var result = new List<string>(2);
if (!optionSchema.Name.IsNullOrWhiteSpace())
result.Add(optionSchema.Name);
if (!string.IsNullOrWhiteSpace(optionSchema.Name))
result.Add(optionSchema.Name!);
if (optionSchema.ShortName != null)
result.Add(optionSchema.ShortName.Value.AsString());
@@ -102,43 +90,41 @@ namespace CliFx.Models
/// <summary>
/// Gets whether a command was specified in the input.
/// </summary>
public static bool IsCommandSpecified(this CommandInput commandInput)
{
commandInput.GuardNotNull(nameof(commandInput));
return !commandInput.CommandName.IsNullOrWhiteSpace();
}
public static bool IsCommandSpecified(this CommandInput commandInput) => !string.IsNullOrWhiteSpace(commandInput.CommandName);
/// <summary>
/// Gets whether help was requested in the input.
/// Gets whether debug directive was specified in the input.
/// </summary>
public static bool IsHelpRequested(this CommandInput commandInput)
public static bool IsDebugDirectiveSpecified(this CommandInput commandInput) =>
commandInput.Directives.Contains("debug", StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets whether preview directive was specified in the input.
/// </summary>
public static bool IsPreviewDirectiveSpecified(this CommandInput commandInput) =>
commandInput.Directives.Contains("preview", StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets whether help option was specified in the input.
/// </summary>
public static bool IsHelpOptionSpecified(this CommandInput commandInput)
{
commandInput.GuardNotNull(nameof(commandInput));
var firstOption = commandInput.Options.FirstOrDefault();
return firstOption != null && CommandOptionSchema.HelpOption.MatchesAlias(firstOption.Alias);
}
/// <summary>
/// Gets whether version information was requested in the input.
/// Gets whether version option was specified in the input.
/// </summary>
public static bool IsVersionRequested(this CommandInput commandInput)
public static bool IsVersionOptionSpecified(this CommandInput commandInput)
{
commandInput.GuardNotNull(nameof(commandInput));
var firstOption = commandInput.Options.FirstOrDefault();
return firstOption != null && CommandOptionSchema.VersionOption.MatchesAlias(firstOption.Alias);
}
/// <summary>
/// Gets whether this command is the default command, i.e. without a name.
/// </summary>
public static bool IsDefault(this CommandSchema commandSchema)
{
commandSchema.GuardNotNull(nameof(commandSchema));
return commandSchema.Name.IsNullOrWhiteSpace();
}
public static bool IsDefault(this CommandSchema commandSchema) => string.IsNullOrWhiteSpace(commandSchema.Name);
}
}

View File

@@ -1,5 +1,4 @@
using System.Collections.Generic;
using CliFx.Internal;
namespace CliFx.Models
{
@@ -30,9 +29,9 @@ namespace CliFx.Models
IReadOnlyList<CommandSchema> availableCommandSchemas,
CommandSchema targetCommandSchema)
{
ApplicationMetadata = applicationMetadata.GuardNotNull(nameof(applicationMetadata));
AvailableCommandSchemas = availableCommandSchemas.GuardNotNull(nameof(availableCommandSchemas));
TargetCommandSchema = targetCommandSchema.GuardNotNull(nameof(targetCommandSchema));
ApplicationMetadata = applicationMetadata;
AvailableCommandSchemas = availableCommandSchemas;
TargetCommandSchema = targetCommandSchema;
}
}
}

View File

@@ -1,5 +1,4 @@
using System;
using CliFx.Internal;
using CliFx.Models;
namespace CliFx.Services
@@ -10,10 +9,6 @@ namespace CliFx.Services
public class CommandFactory : ICommandFactory
{
/// <inheritdoc />
public ICommand CreateCommand(CommandSchema commandSchema)
{
commandSchema.GuardNotNull(nameof(commandSchema));
return (ICommand) Activator.CreateInstance(commandSchema.Type);
}
public ICommand CreateCommand(CommandSchema commandSchema) => (ICommand) Activator.CreateInstance(commandSchema.Type);
}
}

View File

@@ -11,42 +11,65 @@ namespace CliFx.Services
public class CommandInitializer : ICommandInitializer
{
private readonly ICommandOptionInputConverter _commandOptionInputConverter;
private readonly IEnvironmentVariablesParser _environmentVariablesParser;
/// <summary>
/// Initializes an instance of <see cref="CommandInitializer"/>.
/// </summary>
public CommandInitializer(ICommandOptionInputConverter commandOptionInputConverter)
public CommandInitializer(ICommandOptionInputConverter commandOptionInputConverter, IEnvironmentVariablesParser environmentVariablesParser)
{
_commandOptionInputConverter = commandOptionInputConverter;
_environmentVariablesParser = environmentVariablesParser;
}
/// <summary>
/// Initializes an instance of <see cref="CommandInitializer"/>.
/// </summary>
public CommandInitializer(IEnvironmentVariablesParser environmentVariablesParser)
: this(new CommandOptionInputConverter(), environmentVariablesParser)
{
_commandOptionInputConverter = commandOptionInputConverter.GuardNotNull(nameof(commandOptionInputConverter));
}
/// <summary>
/// Initializes an instance of <see cref="CommandInitializer"/>.
/// </summary>
public CommandInitializer()
: this(new CommandOptionInputConverter())
: this(new CommandOptionInputConverter(), new EnvironmentVariablesParser())
{
}
/// <inheritdoc />
public void InitializeCommand(ICommand command, CommandSchema commandSchema, CommandInput commandInput)
{
command.GuardNotNull(nameof(command));
commandSchema.GuardNotNull(nameof(commandSchema));
commandInput.GuardNotNull(nameof(commandInput));
// Keep track of unset required options to report an error at a later stage
var unsetRequiredOptions = commandSchema.Options.Where(o => o.IsRequired).ToList();
// Set command options
foreach (var optionInput in commandInput.Options)
//Set command options
foreach (var optionSchema in commandSchema.Options)
{
// Find matching option schema for this option input
var optionSchema = commandSchema.Options.FindByAlias(optionInput.Alias);
if (optionSchema == null)
// Ignore special options that are not backed by a property
if (optionSchema.Property == null)
continue;
//Find matching option input
var optionInput = commandInput.Options.FindByOptionSchema(optionSchema);
//If no option input is available fall back to environment variable values
if (optionInput == null && !string.IsNullOrWhiteSpace(optionSchema.EnvironmentVariableName))
{
var fallbackEnvironmentVariableExists = commandInput.EnvironmentVariables.ContainsKey(optionSchema.EnvironmentVariableName!);
//If no environment variable is found or there is no valid value for this option skip it
if (!fallbackEnvironmentVariableExists || string.IsNullOrWhiteSpace(commandInput.EnvironmentVariables[optionSchema.EnvironmentVariableName!]))
continue;
optionInput = _environmentVariablesParser.GetCommandOptionInputFromEnvironmentVariable(commandInput.EnvironmentVariables[optionSchema.EnvironmentVariableName!], optionSchema);
}
//No fallback available and no option input was specified, skip option
if (optionInput == null)
continue;
// Convert option to the type of the underlying property
var convertedValue = _commandOptionInputConverter.ConvertOptionInput(optionInput, optionSchema.Property.PropertyType);
// Set value of the underlying property
@@ -61,7 +84,7 @@ namespace CliFx.Services
if (unsetRequiredOptions.Any())
{
var unsetRequiredOptionNames = unsetRequiredOptions.Select(o => o.GetAliases().FirstOrDefault()).JoinToString(", ");
throw new MissingCommandOptionInputException($"One or more required options were not set: {unsetRequiredOptionNames}.");
throw new CliFxException($"One or more required options were not set: {unsetRequiredOptionNames}.");
}
}
}

View File

@@ -12,14 +12,32 @@ namespace CliFx.Services
/// </summary>
public class CommandInputParser : ICommandInputParser
{
private readonly IEnvironmentVariablesProvider _environmentVariablesProvider;
/// <summary>
/// Initializes an instance of <see cref="CommandInputParser"/>
/// </summary>
public CommandInputParser(IEnvironmentVariablesProvider environmentVariablesProvider)
{
_environmentVariablesProvider = environmentVariablesProvider;
}
/// <summary>
/// Initializes an instance of <see cref="CommandInputParser"/>
/// </summary>
public CommandInputParser()
: this(new EnvironmentVariablesProvider())
{
}
/// <inheritdoc />
public CommandInput ParseCommandInput(IReadOnlyList<string> commandLineArguments)
{
commandLineArguments.GuardNotNull(nameof(commandLineArguments));
var commandNameBuilder = new StringBuilder();
var directives = new List<string>();
var optionsDic = new Dictionary<string, List<string>>();
// Option aliases and values are parsed in pairs so we need to keep track of last alias
var lastOptionAlias = "";
foreach (var commandLineArgument in commandLineArguments)
@@ -34,7 +52,7 @@ namespace CliFx.Services
optionsDic[lastOptionAlias] = new List<string>();
}
// Encountered short option name or multiple thereof
// Encountered short option name or multiple short option names
else if (commandLineArgument.StartsWith("-", StringComparison.OrdinalIgnoreCase))
{
// Handle stacked options
@@ -48,15 +66,26 @@ namespace CliFx.Services
}
}
// Encountered command name or part thereof
else if (lastOptionAlias.IsNullOrWhiteSpace())
// Encountered directive or (part of) command name
else if (string.IsNullOrWhiteSpace(lastOptionAlias))
{
if (commandLineArgument.StartsWith("[", StringComparison.OrdinalIgnoreCase) &&
commandLineArgument.EndsWith("]", StringComparison.OrdinalIgnoreCase))
{
// Extract directive
var directive = commandLineArgument.Substring(1, commandLineArgument.Length - 2);
directives.Add(directive);
}
else
{
commandNameBuilder.AppendIfNotEmpty(' ');
commandNameBuilder.Append(commandLineArgument);
}
}
// Encountered option value
else if (!lastOptionAlias.IsNullOrWhiteSpace())
else if (!string.IsNullOrWhiteSpace(lastOptionAlias))
{
optionsDic[lastOptionAlias].Add(commandLineArgument);
}
@@ -65,7 +94,9 @@ namespace CliFx.Services
var commandName = commandNameBuilder.Length > 0 ? commandNameBuilder.ToString() : null;
var options = optionsDic.Select(p => new CommandOptionInput(p.Key, p.Value)).ToArray();
return new CommandInput(commandName, options);
var environmentVariables = _environmentVariablesProvider.GetEnvironmentVariables();
return new CommandInput(commandName, directives, options, environmentVariables);
}
}
}

View File

@@ -20,7 +20,7 @@ namespace CliFx.Services
/// </summary>
public CommandOptionInputConverter(IFormatProvider formatProvider)
{
_formatProvider = formatProvider.GuardNotNull(nameof(formatProvider));
_formatProvider = formatProvider;
}
/// <summary>
@@ -31,7 +31,10 @@ namespace CliFx.Services
{
}
private object ConvertValue(string value, Type targetType)
/// <summary>
/// Converts a single string value to specified target type.
/// </summary>
protected virtual object? ConvertValue(string value, Type targetType)
{
try
{
@@ -41,7 +44,7 @@ namespace CliFx.Services
// Bool
if (targetType == typeof(bool))
return value.IsNullOrWhiteSpace() || bool.Parse(value);
return string.IsNullOrWhiteSpace(value) || bool.Parse(value);
// Char
if (targetType == typeof(char))
@@ -108,9 +111,9 @@ namespace CliFx.Services
return Enum.Parse(targetType, value, true);
// Nullable
var nullableUnderlyingType = Nullable.GetUnderlyingType(targetType);
var nullableUnderlyingType = targetType.GetNullableUnderlyingType();
if (nullableUnderlyingType != null)
return !value.IsNullOrWhiteSpace() ? ConvertValue(value, nullableUnderlyingType) : null;
return !string.IsNullOrWhiteSpace(value) ? ConvertValue(value, nullableUnderlyingType) : null;
// Has a constructor that accepts a single string
var stringConstructor = GetStringConstructor(targetType);
@@ -126,48 +129,63 @@ namespace CliFx.Services
var parseMethod = GetStaticParseMethod(targetType);
if (parseMethod != null)
return parseMethod.Invoke(null, new object[] {value});
throw new InvalidCommandOptionInputException($"Can't convert value [{value}] to type [{targetType}].");
}
catch (Exception ex)
{
throw new InvalidCommandOptionInputException($"Can't convert value [{value}] to type [{targetType}].", ex);
// Wrap and rethrow exceptions that occur when trying to convert the value
throw new CliFxException($"Can't convert value [{value}] to type [{targetType}].", ex);
}
// Throw if we can't find a way to convert the value
throw new CliFxException($"Can't convert value [{value}] to type [{targetType}].");
}
/// <inheritdoc />
public object ConvertOptionInput(CommandOptionInput optionInput, Type targetType)
public virtual object? ConvertOptionInput(CommandOptionInput optionInput, Type targetType)
{
optionInput.GuardNotNull(nameof(optionInput));
targetType.GuardNotNull(nameof(targetType));
// Get the underlying type of IEnumerable<T> if it's implemented by the target type.
// Ignore string type because it's IEnumerable<T> but we don't treat it as such.
var enumerableUnderlyingType = targetType != typeof(string) ? targetType.GetEnumerableUnderlyingType() : null;
// Single value
if (optionInput.Values.Count <= 1)
// Convert to a non-enumerable type
if (enumerableUnderlyingType == null)
{
// Throw if provided with more than 1 value
if (optionInput.Values.Count > 1)
{
throw new CliFxException(
$"Can't convert a sequence of values [{optionInput.Values.JoinToString(", ")}] " +
$"to non-enumerable type [{targetType}].");
}
// Retrieve a single value and convert
var value = optionInput.Values.SingleOrDefault();
return ConvertValue(value, targetType);
}
// Multiple values
// Convert to an enumerable type
else
{
// Determine underlying type of elements inside the target collection type
var underlyingType = targetType.GetEnumerableUnderlyingType() ?? typeof(object);
// Convert values to the underlying enumerable type and cast it to dynamic array
var convertedValues = optionInput.Values
.Select(v => ConvertValue(v, enumerableUnderlyingType))
.ToNonGenericArray(enumerableUnderlyingType);
// Convert values to that type
var convertedValues = optionInput.Values.Select(v => ConvertValue(v, underlyingType)).ToNonGenericArray(underlyingType);
// Get the type of produced array
var convertedValuesType = convertedValues.GetType();
// Assignable from array of values (e.g. T[], IReadOnlyList<T>, IEnumerable<T>)
// Try to assign the array (works for T[], IReadOnlyList<T>, IEnumerable<T>, etc)
if (targetType.IsAssignableFrom(convertedValuesType))
return convertedValues;
// Has a constructor that accepts an array of values (e.g. HashSet<T>, List<T>)
// Try to inject the array into the constructor (works for HashSet<T>, List<T>, etc)
var arrayConstructor = targetType.GetConstructor(new[] {convertedValuesType});
if (arrayConstructor != null)
return arrayConstructor.Invoke(new object[] {convertedValues});
throw new InvalidCommandOptionInputException(
$"Can't convert sequence of values [{optionInput.Values.JoinToString(", ")}] to type [{targetType}].");
// Throw if we can't find a way to convert the values
throw new CliFxException(
$"Can't convert a sequence of values [{optionInput.Values.JoinToString(", ")}] " +
$"to type [{targetType}].");
}
}
}

View File

@@ -14,114 +14,109 @@ namespace CliFx.Services
/// </summary>
public class CommandSchemaResolver : ICommandSchemaResolver
{
private CommandOptionSchema GetCommandOptionSchema(PropertyInfo optionProperty)
private IReadOnlyList<CommandOptionSchema> GetCommandOptionSchemas(Type commandType)
{
var attribute = optionProperty.GetCustomAttribute<CommandOptionAttribute>();
var result = new List<CommandOptionSchema>();
foreach (var property in commandType.GetProperties())
{
var attribute = property.GetCustomAttribute<CommandOptionAttribute>();
// If an attribute is not set, then it's not an option so we just skip it
if (attribute == null)
return null;
continue;
return new CommandOptionSchema(optionProperty,
// Build option schema
var optionSchema = new CommandOptionSchema(property,
attribute.Name,
attribute.ShortName,
attribute.IsRequired,
attribute.Description);
attribute.Description,
attribute.EnvironmentVariableName);
// Make sure there are no other options with the same name
var existingOptionWithSameName = result
.Where(o => !string.IsNullOrWhiteSpace(o.Name))
.FirstOrDefault(o => string.Equals(o.Name, optionSchema.Name, StringComparison.OrdinalIgnoreCase));
if (existingOptionWithSameName != null)
{
throw new CliFxException(
$"Command type [{commandType}] has options defined with the same name: " +
$"[{existingOptionWithSameName.Property}] and [{optionSchema.Property}].");
}
private CommandSchema GetCommandSchema(Type commandType)
// Make sure there are no other options with the same short name
var existingOptionWithSameShortName = result
.Where(o => o.ShortName != null)
.FirstOrDefault(o => o.ShortName == optionSchema.ShortName);
if (existingOptionWithSameShortName != null)
{
// Attribute is optional for commands in order to reduce runtime rule complexity
var attribute = commandType.GetCustomAttribute<CommandAttribute>();
throw new CliFxException(
$"Command type [{commandType}] has options defined with the same short name: " +
$"[{existingOptionWithSameShortName.Property}] and [{optionSchema.Property}].");
}
var options = commandType.GetProperties().Select(GetCommandOptionSchema).ExceptNull().ToArray();
// Add schema to list
result.Add(optionSchema);
}
return new CommandSchema(commandType,
attribute?.Name,
attribute?.Description,
options);
return result;
}
/// <inheritdoc />
public IReadOnlyList<CommandSchema> GetCommandSchemas(IReadOnlyList<Type> commandTypes)
{
commandTypes.GuardNotNull(nameof(commandTypes));
// Get command schemas
var commandSchemas = commandTypes.Select(GetCommandSchema).ToArray();
// Throw if there are no commands defined
if (!commandSchemas.Any())
// Make sure there's at least one command defined
if (!commandTypes.Any())
{
throw new InvalidCommandSchemaException("There are no commands defined.");
throw new CliFxException("There are no commands defined.");
}
// Throw if there are multiple commands with the same name
var nonUniqueCommandNames = commandSchemas
.Select(c => c.Name)
.GroupBy(i => i, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() >= 2)
.SelectMany(g => g)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var result = new List<CommandSchema>();
foreach (var commandName in nonUniqueCommandNames)
foreach (var commandType in commandTypes)
{
throw new InvalidCommandSchemaException(!commandName.IsNullOrWhiteSpace()
? $"There are multiple commands defined with name [{commandName}]."
: "There are multiple default commands defined.");
// Make sure command type implements ICommand.
if (!commandType.Implements(typeof(ICommand)))
{
throw new CliFxException($"Command type [{commandType}] must implement {typeof(ICommand)}.");
}
// Throw if there are commands that don't implement ICommand
var nonImplementedCommandNames = commandSchemas
.Where(c => !c.Type.Implements(typeof(ICommand)))
.Select(c => c.Name)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
// Get attribute
var attribute = commandType.GetCustomAttribute<CommandAttribute>();
foreach (var commandName in nonImplementedCommandNames)
// Make sure attribute is set
if (attribute == null)
{
throw new InvalidCommandSchemaException(!commandName.IsNullOrWhiteSpace()
? $"Command [{commandName}] doesn't implement ICommand."
: "Default command doesn't implement ICommand.");
throw new CliFxException($"Command type [{commandType}] must be annotated with [{typeof(CommandAttribute)}].");
}
// Throw if there are multiple options with the same name inside the same command
foreach (var commandSchema in commandSchemas)
{
var nonUniqueOptionNames = commandSchema.Options
.Where(o => !o.Name.IsNullOrWhiteSpace())
.Select(o => o.Name)
.GroupBy(i => i, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() >= 2)
.SelectMany(g => g)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
// Get option schemas
var optionSchemas = GetCommandOptionSchemas(commandType);
foreach (var optionName in nonUniqueOptionNames)
// Build command schema
var commandSchema = new CommandSchema(commandType,
attribute.Name,
attribute.Description,
optionSchemas);
// Make sure there are no other commands with the same name
var existingCommandWithSameName = result
.FirstOrDefault(c => string.Equals(c.Name, commandSchema.Name, StringComparison.OrdinalIgnoreCase));
if (existingCommandWithSameName != null)
{
throw new InvalidCommandSchemaException(!commandSchema.Name.IsNullOrWhiteSpace()
? $"There are multiple options defined with name [{optionName}] on command [{commandSchema.Name}]."
: $"There are multiple options defined with name [{optionName}] on default command.");
throw new CliFxException(
$"Command type [{existingCommandWithSameName.Type}] has the same name as another command type [{commandType}].");
}
var nonUniqueOptionShortNames = commandSchema.Options
.Where(o => o.ShortName != null)
.Select(o => o.ShortName.Value)
.GroupBy(i => i)
.Where(g => g.Count() >= 2)
.SelectMany(g => g)
.Distinct()
.ToArray();
foreach (var optionShortName in nonUniqueOptionShortNames)
{
throw new InvalidCommandSchemaException(!commandSchema.Name.IsNullOrWhiteSpace()
? $"There are multiple options defined with short name [{optionShortName}] on command [{commandSchema.Name}]."
: $"There are multiple options defined with short name [{optionShortName}] on default command.");
}
// Add schema to list
result.Add(commandSchema);
}
return commandSchemas;
return result;
}
}
}

View File

@@ -1,5 +1,4 @@
using System;
using CliFx.Internal;
using CliFx.Models;
namespace CliFx.Services
@@ -16,14 +15,10 @@ namespace CliFx.Services
/// </summary>
public DelegateCommandFactory(Func<CommandSchema, ICommand> factoryMethod)
{
_factoryMethod = factoryMethod.GuardNotNull(nameof(factoryMethod));
_factoryMethod = factoryMethod;
}
/// <inheritdoc />
public ICommand CreateCommand(CommandSchema commandSchema)
{
commandSchema.GuardNotNull(nameof(commandSchema));
return _factoryMethod(commandSchema);
}
public ICommand CreateCommand(CommandSchema commandSchema) => _factoryMethod(commandSchema);
}
}

View File

@@ -0,0 +1,27 @@
using System.IO;
using System.Linq;
using CliFx.Internal;
using CliFx.Models;
namespace CliFx.Services
{
/// <inheritdoct />
public class EnvironmentVariablesParser : IEnvironmentVariablesParser
{
/// <inheritdoct />
public CommandOptionInput GetCommandOptionInputFromEnvironmentVariable(string environmentVariableValue, CommandOptionSchema targetOptionSchema)
{
//If the option is not a collection do not split environment variable values
var optionIsCollection = targetOptionSchema.Property != null && targetOptionSchema.Property.PropertyType.IsCollection();
if (!optionIsCollection) return new CommandOptionInput(targetOptionSchema.EnvironmentVariableName, environmentVariableValue);
//If the option is a collection split the values using System separator, empty values are discarded
var environmentVariableValues = environmentVariableValue.Split(Path.PathSeparator)
.Where(v => !string.IsNullOrWhiteSpace(v))
.ToList();
return new CommandOptionInput(targetOptionSchema.EnvironmentVariableName, environmentVariableValues);
}
}
}

View File

@@ -0,0 +1,36 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Security;
namespace CliFx.Services
{
/// <inheritdoc />
public class EnvironmentVariablesProvider : IEnvironmentVariablesProvider
{
/// <inheritdoc />
public IReadOnlyDictionary<string, string> GetEnvironmentVariables()
{
try
{
var environmentVariables = Environment.GetEnvironmentVariables();
//Constructing the dictionary manually allows to specify a key comparer that ignores case
//This allows to ignore casing when looking for a fallback environment variable of an option
var environmentVariablesAsDictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
//Type DictionaryEntry must be explicitly used otherwise it will enumerate as a collection of objects
foreach (DictionaryEntry environmentVariable in environmentVariables)
{
environmentVariablesAsDictionary.Add(environmentVariable.Key.ToString(), environmentVariable.Value.ToString());
}
return environmentVariablesAsDictionary;
}
catch (SecurityException)
{
return new Dictionary<string, string>();
}
}
}
}

View File

@@ -1,5 +1,4 @@
using System;
using CliFx.Internal;
namespace CliFx.Services
{
@@ -13,9 +12,6 @@ namespace CliFx.Services
/// </summary>
public static void WithForegroundColor(this IConsole console, ConsoleColor foregroundColor, Action action)
{
console.GuardNotNull(nameof(console));
action.GuardNotNull(nameof(action));
var lastColor = console.ForegroundColor;
console.ForegroundColor = foregroundColor;
@@ -29,9 +25,6 @@ namespace CliFx.Services
/// </summary>
public static void WithBackgroundColor(this IConsole console, ConsoleColor backgroundColor, Action action)
{
console.GuardNotNull(nameof(console));
action.GuardNotNull(nameof(action));
var lastColor = console.BackgroundColor;
console.BackgroundColor = backgroundColor;
@@ -43,12 +36,7 @@ namespace CliFx.Services
/// <summary>
/// Sets console foreground and background colors, executes specified action, and sets the colors back to the original values.
/// </summary>
public static void WithColors(this IConsole console, ConsoleColor foregroundColor, ConsoleColor backgroundColor, Action action)
{
console.GuardNotNull(nameof(console));
action.GuardNotNull(nameof(action));
public static void WithColors(this IConsole console, ConsoleColor foregroundColor, ConsoleColor backgroundColor, Action action) =>
console.WithForegroundColor(foregroundColor, () => console.WithBackgroundColor(backgroundColor, action));
}
}
}

View File

@@ -14,9 +14,6 @@ namespace CliFx.Services
/// <inheritdoc />
public void RenderHelpText(IConsole console, HelpTextSource source)
{
console.GuardNotNull(nameof(console));
source.GuardNotNull(nameof(source));
// Track position
var column = 0;
var row = 0;
@@ -105,7 +102,7 @@ namespace CliFx.Services
RenderNewLine();
// Description
if (!source.ApplicationMetadata.Description.IsNullOrWhiteSpace())
if (!string.IsNullOrWhiteSpace(source.ApplicationMetadata.Description))
{
Render(source.ApplicationMetadata.Description);
RenderNewLine();
@@ -114,7 +111,7 @@ namespace CliFx.Services
void RenderDescription()
{
if (source.TargetCommandSchema.Description.IsNullOrWhiteSpace())
if (string.IsNullOrWhiteSpace(source.TargetCommandSchema.Description))
return;
// Margin
@@ -142,7 +139,7 @@ namespace CliFx.Services
Render(source.ApplicationMetadata.ExecutableName);
// Command name
if (!source.TargetCommandSchema.IsDefault())
if (!string.IsNullOrWhiteSpace(source.TargetCommandSchema.Name))
{
Render(" ");
RenderWithColor(source.TargetCommandSchema.Name, ConsoleColor.Cyan);
@@ -169,8 +166,14 @@ namespace CliFx.Services
// Header
RenderHeader("Options");
// Order options and append built-in options
var allOptionSchemas = source.TargetCommandSchema.Options
.OrderByDescending(o => o.IsRequired)
.Concat(builtInOptionSchemas)
.ToArray();
// Options
foreach (var optionSchema in source.TargetCommandSchema.Options.Concat(builtInOptionSchemas))
foreach (var optionSchema in allOptionSchemas)
{
// Is required
if (optionSchema.IsRequired)
@@ -189,19 +192,19 @@ namespace CliFx.Services
}
// Delimiter
if (!optionSchema.Name.IsNullOrWhiteSpace() && optionSchema.ShortName != null)
if (!string.IsNullOrWhiteSpace(optionSchema.Name) && optionSchema.ShortName != null)
{
Render("|");
}
// Name
if (!optionSchema.Name.IsNullOrWhiteSpace())
if (!string.IsNullOrWhiteSpace(optionSchema.Name))
{
RenderWithColor($"--{optionSchema.Name}", ConsoleColor.White);
}
// Description
if (!optionSchema.Description.IsNullOrWhiteSpace())
if (!string.IsNullOrWhiteSpace(optionSchema.Description))
{
RenderColumnIndent();
Render(optionSchema.Description);
@@ -225,14 +228,14 @@ namespace CliFx.Services
// Child commands
foreach (var childCommandSchema in childCommandSchemas)
{
var relativeCommandName = GetRelativeCommandName(childCommandSchema, source.TargetCommandSchema);
var relativeCommandName = GetRelativeCommandName(childCommandSchema, source.TargetCommandSchema)!;
// Name
RenderIndent();
RenderWithColor(relativeCommandName, ConsoleColor.Cyan);
// Description
if (!childCommandSchema.Description.IsNullOrWhiteSpace())
if (!string.IsNullOrWhiteSpace(childCommandSchema.Description))
{
RenderColumnIndent();
Render(childCommandSchema.Description);
@@ -248,7 +251,7 @@ namespace CliFx.Services
Render("You can run `");
Render(source.ApplicationMetadata.ExecutableName);
if (!source.TargetCommandSchema.IsDefault())
if (!string.IsNullOrWhiteSpace(source.TargetCommandSchema.Name))
{
Render(" ");
RenderWithColor(source.TargetCommandSchema.Name, ConsoleColor.Cyan);
@@ -279,8 +282,8 @@ namespace CliFx.Services
public partial class HelpTextRenderer
{
private static string GetRelativeCommandName(CommandSchema commandSchema, CommandSchema parentCommandSchema) =>
parentCommandSchema.Name.IsNullOrWhiteSpace()
private static string? GetRelativeCommandName(CommandSchema commandSchema, CommandSchema parentCommandSchema) =>
string.IsNullOrWhiteSpace(parentCommandSchema.Name) || string.IsNullOrWhiteSpace(commandSchema.Name)
? commandSchema.Name
: commandSchema.Name.Substring(parentCommandSchema.Name.Length + 1);
}

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