14 Commits
0.0.4 ... 0.0.7

Author SHA1 Message Date
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
58 changed files with 856 additions and 232 deletions

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,12 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp2.2</TargetFramework>
<LangVersion>latest</LangVersion>
<TargetFramework>netcoreapp3.0</TargetFramework>
</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

@@ -1,4 +1,5 @@
using System.Threading.Tasks;
using System.Threading;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;

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

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

View File

@@ -1,4 +1,5 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Demo.Internal;

View File

@@ -1,4 +1,5 @@
using System.Threading.Tasks;
using System.Threading;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Demo.Internal;
using CliFx.Demo.Services;

View File

@@ -1,4 +1,5 @@
using System.Threading.Tasks;
using System.Threading;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Demo.Internal;
using CliFx.Demo.Services;

View File

@@ -1,4 +1,5 @@
using System.Threading.Tasks;
using System.Threading;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Demo.Services;
using CliFx.Exceptions;

View File

@@ -1,8 +1,9 @@
using System;
using NUnit.Framework;
using System;
using System.IO;
using CliFx.Services;
using CliFx.Tests.Stubs;
using CliFx.Tests.TestCommands;
using NUnit.Framework;
namespace CliFx.Tests
{
@@ -20,8 +21,8 @@ namespace CliFx.Tests
builder
.AddCommand(typeof(HelloWorldDefaultCommand))
.AddCommandsFrom(typeof(HelloWorldDefaultCommand).Assembly)
.AddCommands(new[] {typeof(HelloWorldDefaultCommand)})
.AddCommandsFrom(new[] {typeof(HelloWorldDefaultCommand).Assembly})
.AddCommands(new[] { typeof(HelloWorldDefaultCommand) })
.AddCommandsFrom(new[] { typeof(HelloWorldDefaultCommand).Assembly })
.AddCommandsFromThisAssembly()
.AllowDebugMode()
.AllowPreviewMode()
@@ -30,7 +31,9 @@ namespace CliFx.Tests
.UseVersionText("test")
.UseDescription("test")
.UseConsole(new VirtualConsole(TextWriter.Null))
.UseCommandFactory(schema => (ICommand) Activator.CreateInstance(schema.Type))
.UseCommandFactory(schema => (ICommand)Activator.CreateInstance(schema.Type))
.UseCommandOptionInputConverter(new CommandOptionInputConverter())
.UseEnvironmentVariablesProvider(new EnvironmentVariablesProviderStub())
.Build();
}

View File

@@ -1,11 +1,13 @@
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 CliFx.Tests.Stubs;
using CliFx.Tests.TestCommands;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests
{
@@ -17,104 +19,104 @@ namespace CliFx.Tests
private static IEnumerable<TestCaseData> GetTestCases_RunAsync()
{
yield return new TestCaseData(
new[] {typeof(HelloWorldDefaultCommand)},
new[] { typeof(HelloWorldDefaultCommand) },
new string[0],
"Hello world."
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"concat", "-i", "foo", "-i", "bar", "-s", " "},
new[] { typeof(ConcatCommand) },
new[] { "concat", "-i", "foo", "-i", "bar", "-s", " " },
"foo bar"
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"concat", "-i", "one", "two", "three", "-s", ", "},
new[] { typeof(ConcatCommand) },
new[] { "concat", "-i", "one", "two", "three", "-s", ", " },
"one, two, three"
);
yield return new TestCaseData(
new[] {typeof(DivideCommand)},
new[] {"div", "-D", "24", "-d", "8"},
new[] { typeof(DivideCommand) },
new[] { "div", "-D", "24", "-d", "8" },
"3"
);
yield return new TestCaseData(
new[] {typeof(HelloWorldDefaultCommand)},
new[] {"--version"},
new[] { typeof(HelloWorldDefaultCommand) },
new[] { "--version" },
TestVersionText
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"--version"},
new[] { typeof(ConcatCommand) },
new[] { "--version" },
TestVersionText
);
yield return new TestCaseData(
new[] {typeof(HelloWorldDefaultCommand)},
new[] {"-h"},
new[] { typeof(HelloWorldDefaultCommand) },
new[] { "-h" },
null
);
yield return new TestCaseData(
new[] {typeof(HelloWorldDefaultCommand)},
new[] {"--help"},
new[] { typeof(HelloWorldDefaultCommand) },
new[] { "--help" },
null
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] { typeof(ConcatCommand) },
new string[0],
null
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"-h"},
new[] { typeof(ConcatCommand) },
new[] { "-h" },
null
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"--help"},
new[] { typeof(ConcatCommand) },
new[] { "--help" },
null
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"concat", "-h"},
new[] { typeof(ConcatCommand) },
new[] { "concat", "-h" },
null
);
yield return new TestCaseData(
new[] {typeof(ExceptionCommand)},
new[] {"exc", "-h"},
new[] { typeof(ExceptionCommand) },
new[] { "exc", "-h" },
null
);
yield return new TestCaseData(
new[] {typeof(CommandExceptionCommand)},
new[] {"exc", "-h"},
new[] { typeof(CommandExceptionCommand) },
new[] { "exc", "-h" },
null
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"[preview]"},
new[] { typeof(ConcatCommand) },
new[] { "[preview]" },
null
);
yield return new TestCaseData(
new[] {typeof(ExceptionCommand)},
new[] {"exc", "[preview]"},
new[] { typeof(ExceptionCommand) },
new[] { "exc", "[preview]" },
null
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"concat", "[preview]", "-o", "value"},
new[] { typeof(ConcatCommand) },
new[] { "concat", "[preview]", "-o", "value" },
null
);
}
@@ -128,38 +130,38 @@ namespace CliFx.Tests
);
yield return new TestCaseData(
new[] {typeof(ConcatCommand)},
new[] {"non-existing"},
new[] { typeof(ConcatCommand) },
new[] { "non-existing" },
null, null
);
yield return new TestCaseData(
new[] {typeof(ExceptionCommand)},
new[] {"exc"},
new[] { typeof(ExceptionCommand) },
new[] { "exc" },
null, null
);
yield return new TestCaseData(
new[] {typeof(CommandExceptionCommand)},
new[] {"exc"},
new[] { typeof(CommandExceptionCommand) },
new[] { "exc" },
null, null
);
yield return new TestCaseData(
new[] {typeof(CommandExceptionCommand)},
new[] {"exc"},
new[] { typeof(CommandExceptionCommand) },
new[] { "exc" },
null, null
);
yield return new TestCaseData(
new[] {typeof(CommandExceptionCommand)},
new[] {"exc", "-m", "foo bar"},
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"},
new[] { typeof(CommandExceptionCommand) },
new[] { "exc", "-m", "foo bar", "-c", "666" },
"foo bar", 666
);
}
@@ -173,11 +175,13 @@ namespace CliFx.Tests
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
@@ -203,10 +207,12 @@ namespace CliFx.Tests
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();
@@ -226,5 +232,32 @@ namespace CliFx.Tests
stderr.Should().NotBeNullOrWhiteSpace();
}
}
[Test]
public async Task RunAsync_Cancellation_Test()
{
// Arrange
using (var stdoutStream = new StringWriter())
using (var cancellationTokenSource = new CancellationTokenSource())
{
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

@@ -7,7 +7,6 @@
<CollectCoverage>true</CollectCoverage>
<CoverletOutputFormat>opencover</CoverletOutputFormat>
<CoverletOutput>bin/$(Configuration)/Coverage.xml</CoverletOutput>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>

View File

@@ -1,12 +1,13 @@
using System;
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 FluentAssertions;
using NUnit.Framework;
using CliFx.Tests.Stubs;
namespace CliFx.Tests.Services
{
@@ -14,7 +15,7 @@ namespace CliFx.Tests.Services
public class CommandInitializerTests
{
private static CommandSchema GetCommandSchema(Type commandType) =>
new CommandSchemaResolver().GetCommandSchemas(new[] {commandType}).Single();
new CommandSchemaResolver().GetCommandSchemas(new[] { commandType }).Single();
private static IEnumerable<TestCaseData> GetTestCases_InitializeCommand()
{
@@ -26,7 +27,7 @@ namespace CliFx.Tests.Services
new CommandOptionInput("dividend", "13"),
new CommandOptionInput("divisor", "8")
}),
new DivideCommand {Dividend = 13, Divisor = 8}
new DivideCommand { Dividend = 13, Divisor = 8 }
);
yield return new TestCaseData(
@@ -37,7 +38,7 @@ namespace CliFx.Tests.Services
new CommandOptionInput("dividend", "13"),
new CommandOptionInput("d", "8")
}),
new DivideCommand {Dividend = 13, Divisor = 8}
new DivideCommand { Dividend = 13, Divisor = 8 }
);
yield return new TestCaseData(
@@ -48,7 +49,7 @@ namespace CliFx.Tests.Services
new CommandOptionInput("D", "13"),
new CommandOptionInput("d", "8")
}),
new DivideCommand {Dividend = 13, Divisor = 8}
new DivideCommand { Dividend = 13, Divisor = 8 }
);
yield return new TestCaseData(
@@ -58,7 +59,7 @@ namespace CliFx.Tests.Services
{
new CommandOptionInput("i", new[] {"foo", " ", "bar"})
}),
new ConcatCommand {Inputs = new[] {"foo", " ", "bar"}}
new ConcatCommand { Inputs = new[] { "foo", " ", "bar" } }
);
yield return new TestCaseData(
@@ -69,7 +70,43 @@ namespace CliFx.Tests.Services
new CommandOptionInput("i", new[] {"foo", "bar"}),
new CommandOptionInput("s", " ")
}),
new ConcatCommand {Inputs = new[] {"foo", "bar"}, Separator = " "}
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;B;C;" }
);
}

View File

@@ -1,8 +1,9 @@
using System.Collections.Generic;
using FluentAssertions;
using NUnit.Framework;
using System.Collections.Generic;
using CliFx.Models;
using CliFx.Services;
using FluentAssertions;
using NUnit.Framework;
using CliFx.Tests.Stubs;
namespace CliFx.Tests.Services
{
@@ -11,203 +12,238 @@ namespace CliFx.Tests.Services
{
private static IEnumerable<TestCaseData> GetTestCases_ParseCommandInput()
{
yield return new TestCaseData(new string[0], CommandInput.Empty);
yield return new TestCaseData(new string[0], CommandInput.Empty, new EmptyEnvironmentVariablesProviderStub());
yield return new TestCaseData(
new[] {"--option", "value"},
new[] { "--option", "value" },
new CommandInput(new[]
{
new CommandOptionInput("option", "value")
})
}),
new EmptyEnvironmentVariablesProviderStub()
);
yield return new TestCaseData(
new[] {"--option1", "value1", "--option2", "value2"},
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[] { "--option", "value1", "value2" },
new CommandInput(new[]
{
new CommandOptionInput("option", new[] {"value1", "value2"})
})
}),
new EmptyEnvironmentVariablesProviderStub()
);
yield return new TestCaseData(
new[] {"--option", "value1", "--option", "value2"},
new[] { "--option", "value1", "--option", "value2" },
new CommandInput(new[]
{
new CommandOptionInput("option", new[] {"value1", "value2"})
})
}),
new EmptyEnvironmentVariablesProviderStub()
);
yield return new TestCaseData(
new[] {"-a", "value"},
new[] { "-a", "value" },
new CommandInput(new[]
{
new CommandOptionInput("a", "value")
})
}),
new EmptyEnvironmentVariablesProviderStub()
);
yield return new TestCaseData(
new[] {"-a", "value1", "-b", "value2"},
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[] { "-a", "value1", "value2" },
new CommandInput(new[]
{
new CommandOptionInput("a", new[] {"value1", "value2"})
})
}),
new EmptyEnvironmentVariablesProviderStub()
);
yield return new TestCaseData(
new[] {"-a", "value1", "-a", "value2"},
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[] { "--option1", "value1", "-b", "value2" },
new CommandInput(new[]
{
new CommandOptionInput("option1", "value1"),
new CommandOptionInput("b", "value2")
})
}),
new EmptyEnvironmentVariablesProviderStub()
);
yield return new TestCaseData(
new[] {"--switch"},
new[] { "--switch" },
new CommandInput(new[]
{
new CommandOptionInput("switch")
})
}),
new EmptyEnvironmentVariablesProviderStub()
);
yield return new TestCaseData(
new[] {"--switch1", "--switch2"},
new[] { "--switch1", "--switch2" },
new CommandInput(new[]
{
new CommandOptionInput("switch1"),
new CommandOptionInput("switch2")
})
}),
new EmptyEnvironmentVariablesProviderStub()
);
yield return new TestCaseData(
new[] {"-s"},
new[] { "-s" },
new CommandInput(new[]
{
new CommandOptionInput("s")
})
}),
new EmptyEnvironmentVariablesProviderStub()
);
yield return new TestCaseData(
new[] {"-a", "-b"},
new[] { "-a", "-b" },
new CommandInput(new[]
{
new CommandOptionInput("a"),
new CommandOptionInput("b")
})
}),
new EmptyEnvironmentVariablesProviderStub()
);
yield return new TestCaseData(
new[] {"-ab"},
new[] { "-ab" },
new CommandInput(new[]
{
new CommandOptionInput("a"),
new CommandOptionInput("b")
})
}),
new EmptyEnvironmentVariablesProviderStub()
);
yield return new TestCaseData(
new[] {"-ab", "value"},
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[] { "command" },
new CommandInput("command"),
new EmptyEnvironmentVariablesProviderStub()
);
yield return new TestCaseData(
new[] {"command", "--option", "value"},
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[] { "long", "command", "name" },
new CommandInput("long command name"),
new EmptyEnvironmentVariablesProviderStub()
);
yield return new TestCaseData(
new[] {"long", "command", "name", "--option", "value"},
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[] { "[debug]" },
new CommandInput(null,
new[] {"debug"},
new CommandOptionInput[0])
new[] { "debug" },
new CommandOptionInput[0]),
new EmptyEnvironmentVariablesProviderStub()
);
yield return new TestCaseData(
new[] {"[debug]", "[preview]"},
new[] { "[debug]", "[preview]" },
new CommandInput(null,
new[] {"debug", "preview"},
new CommandOptionInput[0])
new[] { "debug", "preview" },
new CommandOptionInput[0]),
new EmptyEnvironmentVariablesProviderStub()
);
yield return new TestCaseData(
new[] {"[debug]", "[preview]", "-o", "value"},
new[] { "[debug]", "[preview]", "-o", "value" },
new CommandInput(null,
new[] {"debug", "preview"},
new[] { "debug", "preview" },
new[]
{
new CommandOptionInput("o", "value")
})
}),
new EmptyEnvironmentVariablesProviderStub()
);
yield return new TestCaseData(
new[] {"command", "[debug]", "[preview]", "-o", "value"},
new[] { "command", "[debug]", "[preview]", "-o", "value" },
new CommandInput("command",
new[] {"debug", "preview"},
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)
CommandInput expectedCommandInput, IEnvironmentVariablesProvider environmentVariablesProvider)
{
// Arrange
var parser = new CommandInputParser();
var parser = new CommandInputParser(environmentVariablesProvider);
// Act
var commandInput = parser.ParseCommandInput(commandLineArguments);

View File

@@ -214,6 +214,12 @@ namespace CliFx.Tests.Services
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[]),
@@ -270,6 +276,16 @@ namespace CliFx.Tests.Services
typeof(int)
);
yield return new TestCaseData(
new CommandOptionInput("option", new[] {"123", "456"}),
typeof(int)
);
yield return new TestCaseData(
new CommandOptionInput("option"),
typeof(int)
);
yield return new TestCaseData(
new CommandOptionInput("option", "123"),
typeof(TestNonStringParseable)

View File

@@ -1,11 +1,11 @@
using System;
using FluentAssertions;
using NUnit.Framework;
using System;
using System.Collections.Generic;
using CliFx.Exceptions;
using CliFx.Models;
using CliFx.Services;
using CliFx.Tests.TestCommands;
using FluentAssertions;
using NUnit.Framework;
namespace CliFx.Tests.Services
{
@@ -15,30 +15,37 @@ namespace CliFx.Tests.Services
private static IEnumerable<TestCaseData> GetTestCases_GetCommandSchemas()
{
yield return new TestCaseData(
new[] {typeof(DivideCommand), typeof(ConcatCommand)},
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."),
"dividend", 'D', true, "The number to divide.", null),
new CommandOptionSchema(typeof(DivideCommand).GetProperty(nameof(DivideCommand.Divisor)),
"divisor", 'd', true, "The number to divide by.")
"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, 'i', true, "Input strings.", null),
new CommandOptionSchema(typeof(ConcatCommand).GetProperty(nameof(ConcatCommand.Separator)),
null, 's', false, "String 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[] { typeof(HelloWorldDefaultCommand) },
new[]
{
new CommandSchema(typeof(HelloWorldDefaultCommand), null, null, new CommandOptionSchema[0])

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

@@ -1,4 +1,5 @@
using System.Threading.Tasks;
using System.Threading;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Services;

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;

View File

@@ -1,4 +1,5 @@
using System.Threading.Tasks;
using System.Threading;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;

View File

@@ -1,4 +1,5 @@
using System.Threading.Tasks;
using System.Threading;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;

View File

@@ -1,4 +1,5 @@
using System.Threading.Tasks;
using System.Threading;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;

View File

@@ -0,0 +1,16 @@
using System.Threading;
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,17 @@
using System.Collections.Generic;
using System.Threading;
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,17 @@
using System.Collections.Generic;
using System.Threading;
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

@@ -1,4 +1,5 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;

View File

@@ -1,4 +1,5 @@
using System.Threading.Tasks;
using System.Threading;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;

View File

@@ -1,4 +1,5 @@
using System.Threading.Tasks;
using System.Threading;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;

View File

@@ -1,4 +1,5 @@
using System.Threading.Tasks;
using System.Threading;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;

View File

@@ -1,4 +1,5 @@
using System.Threading.Tasks;
using System.Threading;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Services;

View File

@@ -1,4 +1,5 @@
using System.Threading.Tasks;
using System.Threading;
using System.Threading.Tasks;
using CliFx.Services;
namespace CliFx.Tests.TestCommands

View File

@@ -28,6 +28,11 @@ namespace CliFx.Attributes
/// </summary>
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>
@@ -41,7 +46,7 @@ namespace CliFx.Attributes
/// Initializes an instance of <see cref="CommandOptionAttribute"/>.
/// </summary>
public CommandOptionAttribute(string name, char shortName)
: this(name, (char?) shortName)
: this(name, (char?)shortName)
{
}

View File

@@ -25,6 +25,8 @@ namespace CliFx
private string _description;
private IConsole _console;
private ICommandFactory _commandFactory;
private ICommandOptionInputConverter _commandOptionInputConverter;
private IEnvironmentVariablesProvider _environmentVariablesProvider;
/// <inheritdoc />
public ICliApplicationBuilder AddCommand(Type commandType)
@@ -108,6 +110,20 @@ namespace CliFx
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder UseCommandOptionInputConverter(ICommandOptionInputConverter converter)
{
_commandOptionInputConverter = converter.GuardNotNull(nameof(converter));
return this;
}
/// <inheritdoc />
public ICliApplicationBuilder UseEnvironmentVariablesProvider(IEnvironmentVariablesProvider environmentVariablesProvider)
{
_environmentVariablesProvider = environmentVariablesProvider.GuardNotNull(nameof(environmentVariablesProvider));
return this;
}
/// <inheritdoc />
public ICliApplication Build()
{
@@ -117,14 +133,16 @@ namespace CliFx
_versionText = _versionText ?? GetDefaultVersionText() ?? "v1.0";
_console = _console ?? new SystemConsole();
_commandFactory = _commandFactory ?? new CommandFactory();
_commandOptionInputConverter = _commandOptionInputConverter ?? new CommandOptionInputConverter();
_environmentVariablesProvider = _environmentVariablesProvider ?? new EnvironmentVariablesProvider();
// Project parameters to expected types
var metadata = new ApplicationMetadata(_title, _executableName, _versionText, _description);
var configuration = new ApplicationConfiguration(_commandTypes.ToArray(), _isDebugModeAllowed, _isPreviewModeAllowed);
return new CliApplication(metadata, configuration,
_console, new CommandInputParser(), new CommandSchemaResolver(),
_commandFactory, new CommandInitializer(), new HelpTextRenderer());
_console, new CommandInputParser(_environmentVariablesProvider), new CommandSchemaResolver(),
_commandFactory, new CommandInitializer(_commandOptionInputConverter, new EnvironmentVariablesParser()), new HelpTextRenderer());
}
}

View File

@@ -2,8 +2,7 @@
<PropertyGroup>
<TargetFrameworks>net45;netstandard2.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<Version>0.0.4</Version>
<Version>0.0.7</Version>
<Company>Tyrrrz</Company>
<Authors>$(Company)</Authors>
<Copyright>Copyright (C) Alexey Golub</Copyright>
@@ -11,7 +10,7 @@
<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>
@@ -20,4 +19,8 @@
<DocumentationFile>bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<None Include="../favicon.png" Pack="True" PackagePath="" />
</ItemGroup>
</Project>

View File

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

@@ -1,4 +1,5 @@
using System.Threading.Tasks;
using System.Threading;
using System.Threading.Tasks;
using CliFx.Services;
namespace CliFx

View File

@@ -36,8 +36,13 @@ namespace CliFx.Internal
public static bool Implements(this Type type, Type interfaceType) => type.GetInterfaces().Contains(interfaceType);
public static Type GetNullableUnderlyingType(this Type type) => Nullable.GetUnderlyingType(type);
public static Type GetEnumerableUnderlyingType(this Type type)
{
if (type.IsPrimitive)
return null;
if (type == typeof(IEnumerable))
return typeof(object);

View File

@@ -25,14 +25,36 @@ namespace CliFx.Models
/// </summary>
public IReadOnlyList<CommandOptionInput> Options { get; }
/// <summary>
/// Environment variables available when the command was parsed
/// </summary>
public IReadOnlyDictionary<string, string> EnvironmentVariables { get; }
/// <summary>
/// Initializes an instance of <see cref="CommandInput"/>.
/// </summary>
public CommandInput(string commandName, IReadOnlyList<string> directives, IReadOnlyList<CommandOptionInput> options)
public CommandInput(string commandName, IReadOnlyList<string> directives, IReadOnlyList<CommandOptionInput> options, IReadOnlyDictionary<string, string> environmentVariables)
{
CommandName = commandName; // can be null
Directives = directives.GuardNotNull(nameof(directives));
Options = options.GuardNotNull(nameof(options));
EnvironmentVariables = environmentVariables.GuardNotNull(nameof(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>
@@ -87,6 +109,7 @@ namespace CliFx.Models
{
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.

View File

@@ -34,16 +34,22 @@ namespace CliFx.Models
/// </summary>
public string Description { get; }
/// <summary>
/// Optional environment variable name that will be used as fallback value if no option value is specified.
/// </summary>
public string EnvironmentVariableName { get; }
/// <summary>
/// Initializes an instance of <see cref="CommandOptionSchema"/>.
/// </summary>
public CommandOptionSchema(PropertyInfo property, string name, char? shortName, bool isRequired, string description)
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
IsRequired = isRequired;
Description = description; // can be null
EnvironmentVariableName = environmentVariableName; //can be null
}
/// <inheritdoc />
@@ -75,9 +81,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

@@ -1,7 +1,7 @@
using System;
using CliFx.Internal;
using System;
using System.Collections.Generic;
using System.Linq;
using CliFx.Internal;
namespace CliFx.Models
{
@@ -73,14 +73,14 @@ 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)
public static CommandOptionInput FindByOptionSchema(this IReadOnlyList<CommandOptionInput> optionInputs, CommandOptionSchema optionSchema)
{
optionSchemas.GuardNotNull(nameof(optionSchemas));
alias.GuardNotNull(nameof(alias));
optionInputs.GuardNotNull(nameof(optionInputs));
optionSchema.GuardNotNull(nameof(optionSchema));
return optionSchemas.FirstOrDefault(o => o.MatchesAlias(alias));
return optionInputs.FirstOrDefault(o => optionSchema.MatchesAlias(o.Alias));
}
/// <summary>

View File

@@ -11,20 +11,30 @@ 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.GuardNotNull(nameof(commandOptionInputConverter));
_environmentVariablesParser = environmentVariablesParser.GuardNotNull(nameof(environmentVariablesParser));
}
/// <summary>
/// Initializes an instance of <see cref="CommandInitializer"/>.
/// </summary>
public CommandInitializer(IEnvironmentVariablesParser environmentVariablesParser)
: this(new CommandOptionInputConverter(), environmentVariablesParser)
{
}
/// <summary>
/// Initializes an instance of <see cref="CommandInitializer"/>.
/// </summary>
public CommandInitializer()
: this(new CommandOptionInputConverter())
: this(new CommandOptionInputConverter(), new EnvironmentVariablesParser())
{
}
@@ -38,15 +48,28 @@ namespace CliFx.Services
// 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)
//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 && !optionSchema.EnvironmentVariableName.IsNullOrWhiteSpace())
{
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 || commandInput.EnvironmentVariables[optionSchema.EnvironmentVariableName].IsNullOrWhiteSpace())
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

View File

@@ -12,6 +12,26 @@ 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.GuardNotNull(nameof(environmentVariablesProvider));
_environmentVariablesProvider = environmentVariablesProvider;
}
/// <summary>
/// Initializes an instance of <see cref="CommandInputParser"/>
/// </summary>
public CommandInputParser()
: this(new EnvironmentVariablesProvider())
{
}
/// <inheritdoc />
public CommandInput ParseCommandInput(IReadOnlyList<string> commandLineArguments)
{
@@ -78,7 +98,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, directives, options);
var environmentVariables = _environmentVariablesProvider.GetEnvironmentVariables();
return new CommandInput(commandName, directives, options, environmentVariables);
}
}
}

View File

@@ -31,8 +31,13 @@ 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)
{
targetType.GuardNotNull(nameof(targetType));
try
{
// String or object
@@ -108,7 +113,7 @@ 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;
@@ -126,48 +131,66 @@ namespace CliFx.Services
var parseMethod = GetStaticParseMethod(targetType);
if (parseMethod != null)
return parseMethod.Invoke(null, new object[] {value});
throw new CliFxException($"Can't convert value [{value}] to type [{targetType}].");
}
catch (Exception ex)
{
// 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));
// Single value
if (optionInput.Values.Count <= 1)
// 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;
// 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 if we can't find a way to convert the values
throw new CliFxException(
$"Can't convert sequence of values [{optionInput.Values.JoinToString(", ")}] to type [{targetType}].");
$"Can't convert a sequence of values [{optionInput.Values.JoinToString(", ")}] " +
$"to type [{targetType}].");
}
}
}

View File

@@ -31,7 +31,8 @@ namespace CliFx.Services
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

View File

@@ -0,0 +1,30 @@
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)
{
environmentVariableValue.GuardNotNull(nameof(environmentVariableValue));
targetOptionSchema.GuardNotNull(nameof(targetOptionSchema));
//If the option is not a collection do not split environment variable values
var optionIsCollection = 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 => !v.IsNullOrWhiteSpace())
.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,4 +1,5 @@
using System;
using System.Collections.Generic;
using CliFx.Internal;
namespace CliFx.Services
@@ -50,5 +51,25 @@ namespace CliFx.Services
console.WithForegroundColor(foregroundColor, () => console.WithBackgroundColor(backgroundColor, action));
}
/// <summary>
/// Gets wether a string representing an environment variable value is escaped (i.e.: surrounded by double quotation marks)
/// </summary>
public static bool IsEnvironmentVariableEscaped(this string environmentVariableValue)
{
environmentVariableValue.GuardNotNull(nameof(environmentVariableValue));
return environmentVariableValue.StartsWith("\"") && environmentVariableValue.EndsWith("\"");
}
/// <summary>
/// Gets wether the <see cref="Type"/> supplied is a collection implementing <see cref="IEnumerable{T}"/>
/// </summary>
public static bool IsCollection(this Type type)
{
type.GuardNotNull(nameof(type));
return type != typeof(string) && type.GetEnumerableUnderlyingType() != null;
}
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.IO;
using System.Threading;
namespace CliFx.Services
{
@@ -52,5 +53,11 @@ namespace CliFx.Services
/// Resets foreground and background color to default values.
/// </summary>
void ResetColor();
/// <summary>
/// Provides token that cancels when application cancellation is requested.
/// Subsequent calls return the same token.
/// </summary>
CancellationToken GetCancellationToken();
}
}

View File

@@ -0,0 +1,15 @@
using CliFx.Models;
namespace CliFx.Services
{
/// <summary>
/// Parses environment variable values
/// </summary>
public interface IEnvironmentVariablesParser
{
/// <summary>
/// Parse an environment variable value and converts it to a <see cref="CommandOptionInput"/>
/// </summary>
CommandOptionInput GetCommandOptionInputFromEnvironmentVariable(string environmentVariableValue, CommandOptionSchema targetOptionSchema);
}
}

View File

@@ -0,0 +1,16 @@
using System.Collections.Generic;
namespace CliFx.Services
{
/// <summary>
/// Provides environment variable values
/// </summary>
public interface IEnvironmentVariablesProvider
{
/// <summary>
/// Returns all the environment variables available.
/// </summary>
/// <remarks>If the User is not allowed to read environment variables it will return an empty dictionary.</remarks>
IReadOnlyDictionary<string, string> GetEnvironmentVariables();
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.IO;
using System.Threading;
namespace CliFx.Services
{
@@ -8,6 +9,8 @@ namespace CliFx.Services
/// </summary>
public class SystemConsole : IConsole
{
private CancellationTokenSource _cancellationTokenSource;
/// <inheritdoc />
public TextReader Input => Console.In;
@@ -42,5 +45,26 @@ namespace CliFx.Services
/// <inheritdoc />
public void ResetColor() => Console.ResetColor();
/// <inheritdoc />
public CancellationToken GetCancellationToken()
{
if (_cancellationTokenSource is null)
{
_cancellationTokenSource = new CancellationTokenSource();
// Subscribe to CancelKeyPress event with cancellation token source
// Kills app on second cancellation (hard cancellation)
Console.CancelKeyPress += (_, args) =>
{
if (_cancellationTokenSource.IsCancellationRequested)
return;
args.Cancel = true;
_cancellationTokenSource.Cancel();
};
}
return _cancellationTokenSource.Token;
}
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.IO;
using System.Threading;
using CliFx.Internal;
namespace CliFx.Services
@@ -11,6 +12,8 @@ namespace CliFx.Services
/// </summary>
public class VirtualConsole : IConsole
{
private readonly CancellationToken _cancellationToken;
/// <inheritdoc />
public TextReader Input { get; }
@@ -40,7 +43,8 @@ namespace CliFx.Services
/// </summary>
public VirtualConsole(TextReader input, bool isInputRedirected,
TextWriter output, bool isOutputRedirected,
TextWriter error, bool isErrorRedirected)
TextWriter error, bool isErrorRedirected,
CancellationToken cancellationToken = default)
{
Input = input.GuardNotNull(nameof(input));
IsInputRedirected = isInputRedirected;
@@ -48,13 +52,15 @@ namespace CliFx.Services
IsOutputRedirected = isOutputRedirected;
Error = error.GuardNotNull(nameof(error));
IsErrorRedirected = isErrorRedirected;
_cancellationToken = cancellationToken;
}
/// <summary>
/// Initializes an instance of <see cref="VirtualConsole"/>.
/// </summary>
public VirtualConsole(TextReader input, TextWriter output, TextWriter error)
: this(input, true, output, true, error, true)
public VirtualConsole(TextReader input, TextWriter output, TextWriter error,
CancellationToken cancellationToken = default)
: this(input, true, output, true, error, true, cancellationToken)
{
}
@@ -62,8 +68,8 @@ namespace CliFx.Services
/// Initializes an instance of <see cref="VirtualConsole"/> using output stream (stdout) and error stream (stderr).
/// Input stream (stdin) is replaced with a no-op stub.
/// </summary>
public VirtualConsole(TextWriter output, TextWriter error)
: this(TextReader.Null, output, error)
public VirtualConsole(TextWriter output, TextWriter error, CancellationToken cancellationToken = default)
: this(TextReader.Null, output, error, cancellationToken)
{
}
@@ -71,8 +77,8 @@ namespace CliFx.Services
/// Initializes an instance of <see cref="VirtualConsole"/> using output stream (stdout).
/// Input stream (stdin) and error stream (stderr) are replaced with no-op stubs.
/// </summary>
public VirtualConsole(TextWriter output)
: this(output, TextWriter.Null)
public VirtualConsole(TextWriter output, CancellationToken cancellationToken = default)
: this(output, TextWriter.Null, cancellationToken)
{
}
@@ -82,5 +88,8 @@ namespace CliFx.Services
ForegroundColor = ConsoleColor.Gray;
BackgroundColor = ConsoleColor.Black;
}
/// <inheritdoc />
public CancellationToken GetCancellationToken() => _cancellationToken;
}
}

View File

@@ -24,12 +24,17 @@ _CliFx is to command line interfaces what ASP.NET Core is to web applications._
- Resolves commands and options using attributes
- Handles options of various types, including custom types
- Supports multi-level command hierarchies
- Allows cancellation
- Generates contextual help text
- Prints errors and routes exit codes on exceptions
- Highly testable and easy to debug
- Targets .NET Framework 4.5+ and .NET Standard 2.0+
- No external dependencies
## Screenshots
![help screen](.screenshots/help.png)
## Argument syntax
This library employs a variation of the GNU command line argument syntax. Because CliFx uses a context-unaware parser, the syntax rules are generally more consistent and intuitive.
@@ -95,7 +100,7 @@ public class LogCommand : ICommand
By implementing `ICommand` this class also provides `ExecuteAsync` method. This is the method that gets called when the user invokes the command. Its return type is `Task` in order to facilitate asynchronous execution, but if your command runs synchronously you can simply return `Task.CompletedTask`.
The `ExecuteAsync` method also takes an instance of `IConsole` as a parameter. You should use this abstraction to interact with the console instead of calling `System.Console` so that your commands are testable.
The `ExecuteAsync` method also takes an instance of `IConsole` as a parameter. You should use the `console` parameter in places where you would normally use `System.Console`, in order to make your command testable.
Finally, the command defined above can be executed from the command line in one of the following ways:
@@ -123,6 +128,34 @@ When resolving options, CliFx can convert string values obtained from the comman
If you want to define an option of your own type, the easiest way to do it is to make sure that your type is string-initializable, as explained above.
It is also possible to configure the application to use your own converter, by calling `UseCommandOptionInputConverter` method on `CliApplicationBuilder`.
```c#
var app = new CliApplicationBuilder()
.AddCommandsFromThisAssembly()
.UseCommandOptionInputConverter(new MyConverter())
.Build();
```
The converter class must implement `ICommandOptionInputConverter` but you can also derive from `CommandOptionInputConverter` to extend the default behavior.
```c#
public class MyConverter : CommandOptionInputConverter
{
protected override object ConvertValue(string value, Type targetType)
{
// Custom conversion for MyType
if (targetType == typeof(MyType))
{
// ...
}
// Default behavior for other types
return base.ConvertValue(value, targetType);
}
}
```
### Reporting errors
You may have noticed that commands in CliFx don't return exit codes. This is by design as exit codes are considered a higher-level concern and thus handled by `CliApplication`, not by individual commands.
@@ -183,6 +216,30 @@ public class SecondSubCommand : ICommand
}
```
### Cancellation
It is possible to gracefully cancel execution of a command and preform any necessary cleanup. By default an app gets forcefully killed when it receives an interrupt signal (Ctrl+C or Ctrl+Break). You can call `console.GetCancellationToken()` to override the default behavior and get `CancellationToken` that represents the first interrupt signal. Second interrupt signal terminates an app immediately. Note that the code that executes before the first call to `GetCancellationToken` will not be cancellation aware.
You can pass `CancellationToken` around and check its state.
Cancelled or terminated app returns non-zero exit code.
```c#
[Command("cancel")]
public class CancellableCommand : ICommand
{
public async Task ExecuteAsync(IConsole console)
{
console.Output.WriteLine("Printed");
// Long-running cancellable operation that throws when canceled
await Task.Delay(Timeout.InfiniteTimeSpan, console.GetCancellationToken());
console.Output.WriteLine("Never printed");
}
}
```
### Dependency injection
CliFx uses an implementation of `ICommandFactory` to initialize commands and by default it only works with types that have parameterless constructors.
@@ -388,13 +445,12 @@ var app = new CliApplicationBuilder()
## Benchmarks
CliFx has the smallest performance overhead compared to other command line parsers and frameworks.
Below you can see a table comparing execution times of a simple command across different libraries.
Here's how CliFx's execution overhead compares to that of other libraries.
```ini
BenchmarkDotNet=v0.11.5, OS=Windows 10.0.14393.0 (1607/AnniversaryUpdate/Redstone1)
BenchmarkDotNet=v0.11.5, OS=Windows 10.0.14393.3144 (1607/AnniversaryUpdate/Redstone1)
Intel Core i5-4460 CPU 3.20GHz (Haswell), 1 CPU, 4 logical and 4 physical cores
Frequency=3125008 Hz, Resolution=319.9992 ns, Timer=TSC
Frequency=3125011 Hz, Resolution=319.9989 ns, Timer=TSC
.NET Core SDK=2.2.401
[Host] : .NET Core 2.2.6 (CoreCLR 4.6.27817.03, CoreFX 4.6.27818.02), 64bit RyuJIT
Core : .NET Core 2.2.6 (CoreCLR 4.6.27817.03, CoreFX 4.6.27818.02), 64bit RyuJIT
@@ -404,10 +460,12 @@ Job=Core Runtime=Core
| Method | Mean | Error | StdDev | Ratio | RatioSD | Rank |
|------------------------------------- |----------:|----------:|----------:|------:|--------:|-----:|
| CliFx | 39.47 us | 0.7490 us | 0.9198 us | 1.00 | 0.00 | 1 |
| System.CommandLine | 153.98 us | 0.7112 us | 0.6652 us | 3.90 | 0.09 | 2 |
| McMaster.Extensions.CommandLineUtils | 180.36 us | 3.5893 us | 6.7416 us | 4.59 | 0.16 | 3 |
| PowerArgs | 427.54 us | 6.9006 us | 6.4548 us | 10.82 | 0.26 | 4 |
| CliFx | 31.29 us | 0.6147 us | 0.7774 us | 1.00 | 0.00 | 2 |
| System.CommandLine | 184.44 us | 3.4993 us | 4.0297 us | 5.90 | 0.21 | 4 |
| McMaster.Extensions.CommandLineUtils | 165.50 us | 1.4805 us | 1.3124 us | 5.33 | 0.13 | 3 |
| CommandLineParser | 26.65 us | 0.5530 us | 0.5679 us | 0.85 | 0.03 | 1 |
| PowerArgs | 405.44 us | 7.7133 us | 9.1821 us | 12.96 | 0.47 | 6 |
| Clipr | 220.82 us | 4.4567 us | 4.9536 us | 7.06 | 0.25 | 5 |
## Philosophy

View File

@@ -1,6 +1,6 @@
version: '{build}'
image: Visual Studio 2017
image: Visual Studio 2019
configuration: Release
before_build: