Improve exceptions

This commit is contained in:
Alexey Golub
2020-04-20 16:36:45 +03:00
parent 7d3ba612c4
commit 65b66b0d27
15 changed files with 584 additions and 279 deletions

View File

@@ -4,11 +4,16 @@ using CliFx.Domain;
using CliFx.Exceptions;
using FluentAssertions;
using Xunit;
using Xunit.Abstractions;
namespace CliFx.Tests
{
public partial class ApplicationSpecs
{
private readonly ITestOutputHelper _output;
public ApplicationSpecs(ITestOutputHelper output) => _output = output;
[Fact]
public void Application_can_be_created_with_a_default_configuration()
{
@@ -52,7 +57,8 @@ namespace CliFx.Tests
var commandTypes = Array.Empty<Type>();
// Act & assert
Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
[Fact]
@@ -62,7 +68,8 @@ namespace CliFx.Tests
var commandTypes = new[] {typeof(NonImplementedCommand)};
// Act & assert
Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
[Fact]
@@ -72,7 +79,8 @@ namespace CliFx.Tests
var commandTypes = new[] {typeof(NonAnnotatedCommand)};
// Act & assert
Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
[Fact]
@@ -82,7 +90,8 @@ namespace CliFx.Tests
var commandTypes = new[] {typeof(DuplicateNameCommandA), typeof(DuplicateNameCommandB)};
// Act & assert
Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
[Fact]
@@ -92,7 +101,8 @@ namespace CliFx.Tests
var commandTypes = new[] {typeof(DuplicateParameterOrderCommand)};
// Act & assert
Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
[Fact]
@@ -102,7 +112,8 @@ namespace CliFx.Tests
var commandTypes = new[] {typeof(DuplicateParameterNameCommand)};
// Act & assert
Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
[Fact]
@@ -112,7 +123,8 @@ namespace CliFx.Tests
var commandTypes = new[] {typeof(MultipleNonScalarParametersCommand)};
// Act & assert
Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
[Fact]
@@ -122,7 +134,8 @@ namespace CliFx.Tests
var commandTypes = new[] {typeof(NonLastNonScalarParameterCommand)};
// Act & assert
Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
[Fact]
@@ -132,7 +145,8 @@ namespace CliFx.Tests
var commandTypes = new[] {typeof(EmptyOptionNameCommand)};
// Act & assert
Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
[Fact]
@@ -142,7 +156,8 @@ namespace CliFx.Tests
var commandTypes = new[] {typeof(SingleCharacterOptionNameCommand)};
// Act & assert
Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
[Fact]
@@ -152,7 +167,8 @@ namespace CliFx.Tests
var commandTypes = new[] {typeof(DuplicateOptionNamesCommand)};
// Act & assert
Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
[Fact]
@@ -162,7 +178,8 @@ namespace CliFx.Tests
var commandTypes = new[] {typeof(DuplicateOptionShortNamesCommand)};
// Act & assert
Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
[Fact]
@@ -172,7 +189,8 @@ namespace CliFx.Tests
var commandTypes = new[] {typeof(DuplicateOptionEnvironmentVariableNamesCommand)};
// Act & assert
Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
var ex = Assert.Throws<CliFxException>(() => ApplicationSchema.Resolve(commandTypes));
_output.WriteLine(ex.Message);
}
[Fact]

View File

@@ -5,11 +5,16 @@ using CliFx.Domain;
using CliFx.Exceptions;
using FluentAssertions;
using Xunit;
using Xunit.Abstractions;
namespace CliFx.Tests
{
public partial class ArgumentBindingSpecs
{
private readonly ITestOutputHelper _output;
public ArgumentBindingSpecs(ITestOutputHelper output) => _output = output;
[Fact]
public void Property_of_type_object_is_bound_directly_from_the_argument_value()
{
@@ -943,7 +948,8 @@ namespace CliFx.Tests
.Build();
// Act & assert
Assert.Throws<CliFxException>(() => schema.InitializeEntryPoint(input));
var ex = Assert.Throws<CliFxException>(() => schema.InitializeEntryPoint(input));
_output.WriteLine(ex.Message);
}
[Fact]
@@ -957,7 +963,8 @@ namespace CliFx.Tests
.Build();
// Act & assert
Assert.Throws<CliFxException>(() => schema.InitializeEntryPoint(input));
var ex = Assert.Throws<CliFxException>(() => schema.InitializeEntryPoint(input));
_output.WriteLine(ex.Message);
}
[Fact]
@@ -996,7 +1003,8 @@ namespace CliFx.Tests
.Build();
// Act & assert
Assert.Throws<CliFxException>(() => schema.InitializeEntryPoint(input));
var ex = Assert.Throws<CliFxException>(() => schema.InitializeEntryPoint(input));
_output.WriteLine(ex.Message);
}
[Fact]
@@ -1010,7 +1018,8 @@ namespace CliFx.Tests
.Build();
// Act & assert
Assert.Throws<CliFxException>(() => schema.InitializeEntryPoint(input));
var ex = Assert.Throws<CliFxException>(() => schema.InitializeEntryPoint(input));
_output.WriteLine(ex.Message);
}
[Fact]
@@ -1024,7 +1033,8 @@ namespace CliFx.Tests
.Build();
// Act & assert
Assert.Throws<CliFxException>(() => schema.InitializeEntryPoint(input));
var ex = Assert.Throws<CliFxException>(() => schema.InitializeEntryPoint(input));
_output.WriteLine(ex.Message);
}
[Fact]
@@ -1038,7 +1048,8 @@ namespace CliFx.Tests
.Build();
// Act & assert
Assert.Throws<CliFxException>(() => schema.InitializeEntryPoint(input));
var ex = Assert.Throws<CliFxException>(() => schema.InitializeEntryPoint(input));
_output.WriteLine(ex.Message);
}
[Fact]
@@ -1052,7 +1063,8 @@ namespace CliFx.Tests
.Build();
// Act & assert
Assert.Throws<CliFxException>(() => schema.InitializeEntryPoint(input));
var ex = Assert.Throws<CliFxException>(() => schema.InitializeEntryPoint(input));
_output.WriteLine(ex.Message);
}
[Fact]
@@ -1067,7 +1079,8 @@ namespace CliFx.Tests
.Build();
// Act & assert
Assert.Throws<CliFxException>(() => schema.InitializeEntryPoint(input));
var ex = Assert.Throws<CliFxException>(() => schema.InitializeEntryPoint(input));
_output.WriteLine(ex.Message);
}
[Fact]
@@ -1084,7 +1097,8 @@ namespace CliFx.Tests
.Build();
// Act & assert
Assert.Throws<CliFxException>(() => schema.InitializeEntryPoint(input));
var ex = Assert.Throws<CliFxException>(() => schema.InitializeEntryPoint(input));
_output.WriteLine(ex.Message);
}
}
}

View File

@@ -1,11 +1,16 @@
using CliFx.Exceptions;
using FluentAssertions;
using Xunit;
using Xunit.Abstractions;
namespace CliFx.Tests
{
public partial class DependencyInjectionSpecs
{
private readonly ITestOutputHelper _output;
public DependencyInjectionSpecs(ITestOutputHelper output) => _output = output;
[Fact]
public void Default_type_activator_can_initialize_a_command_if_it_has_a_parameterless_constructor()
{
@@ -26,8 +31,8 @@ namespace CliFx.Tests
var activator = new DefaultTypeActivator();
// Act & assert
Assert.Throws<CliFxException>(() =>
activator.CreateInstance(typeof(WithDependenciesCommand)));
var ex = Assert.Throws<CliFxException>(() => activator.CreateInstance(typeof(WithDependenciesCommand)));
_output.WriteLine(ex.Message);
}
[Fact]
@@ -48,11 +53,11 @@ namespace CliFx.Tests
public void Delegate_type_activator_throws_if_the_underlying_function_returns_null()
{
// Arrange
var activator = new DelegateTypeActivator(_ => null);
var activator = new DelegateTypeActivator(_ => null!);
// Act & assert
Assert.Throws<CliFxException>(() =>
activator.CreateInstance(typeof(WithDependenciesCommand)));
var ex = Assert.Throws<CliFxException>(() => activator.CreateInstance(typeof(WithDependenciesCommand)));
_output.WriteLine(ex.Message);
}
}
}

View File

@@ -173,10 +173,10 @@ namespace CliFx
public partial class CliApplicationBuilder
{
private static readonly Lazy<Assembly> LazyEntryAssembly = new Lazy<Assembly>(Assembly.GetEntryAssembly);
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 Assembly? EntryAssembly => LazyEntryAssembly.Value;
private static string? GetDefaultTitle() => EntryAssembly?.GetName().Name;
@@ -184,14 +184,12 @@ namespace CliFx
{
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);
}
// The assembly can be an executable or a dll, depending on how it was packaged
var isDll = string.Equals(Path.GetExtension(entryAssemblyLocation), ".dll", StringComparison.OrdinalIgnoreCase);
// Otherwise just use assembly file name without extension
return Path.GetFileNameWithoutExtension(entryAssemblyLocation);
return isDll
? "dotnet " + Path.GetFileName(entryAssemblyLocation)
: Path.GetFileNameWithoutExtension(entryAssemblyLocation);
}
private static string? GetDefaultVersionText() =>

View File

@@ -18,11 +18,7 @@ namespace CliFx
}
catch (Exception ex)
{
throw new CliFxException(new StringBuilder()
.Append($"Failed to create an instance of {type.FullName}.").Append(" ")
.AppendLine("The type must have a public parameter-less constructor in order to be instantiated by the default activator.")
.Append($"To supply a custom activator (for example when using dependency injection), call {nameof(CliApplicationBuilder)}.{nameof(CliApplicationBuilder.UseTypeActivator)}(...).")
.ToString(), ex);
throw CliFxException.DefaultActivatorFailed(type, ex);
}
}
}

View File

@@ -18,10 +18,6 @@ namespace CliFx
/// <inheritdoc />
public object CreateInstance(Type type) =>
_func(type) ?? throw new CliFxException(new StringBuilder()
.Append($"Failed to create an instance of type {type.FullName}, received <null> instead.").Append(" ")
.Append("Make sure that the provided type activator was configured correctly.").Append(" ")
.Append("If you are using a dependency container, make sure that this type is registered.")
.ToString());
_func(type) ?? throw CliFxException.DelegateActivatorReceivedNull(type);
}
}

View File

@@ -1,10 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Internal;
namespace CliFx.Domain
{
@@ -71,18 +68,14 @@ namespace CliFx.Domain
IReadOnlyDictionary<string, string> environmentVariables,
ITypeActivator activator)
{
var command = TryFindCommand(commandLineInput, out var argumentOffset);
if (command == null)
{
throw new CliFxException(
$"Can't find a command that matches arguments [{string.Join(" ", commandLineInput.UnboundArguments)}].");
}
var command = TryFindCommand(commandLineInput, out var argumentOffset) ??
throw CliFxException.CannotFindMatchingCommand(commandLineInput);
var parameterValues = argumentOffset == 0
? commandLineInput.UnboundArguments.Select(a => a.Value).ToArray()
: commandLineInput.UnboundArguments.Skip(argumentOffset).Select(a => a.Value).ToArray();
var parameterInputs = argumentOffset == 0
? commandLineInput.UnboundArguments.ToArray()
: commandLineInput.UnboundArguments.Skip(argumentOffset).ToArray();
return command.CreateInstance(parameterValues, commandLineInput.Options, environmentVariables, activator);
return command.CreateInstance(parameterInputs, commandLineInput.Options, environmentVariables, activator);
}
public ICommand InitializeEntryPoint(
@@ -106,28 +99,23 @@ namespace CliFx.Domain
if (duplicateOrderGroup != null)
{
throw new CliFxException(new StringBuilder()
.AppendLine($"Command {command.Type.FullName} contains two or more parameters that have the same order ({duplicateOrderGroup.Key}):")
.AppendBulletList(duplicateOrderGroup.Select(o => o.Property.Name))
.AppendLine()
.Append("Parameters in a command must all have unique order.")
.ToString());
throw CliFxException.CommandParametersDuplicateOrder(
command,
duplicateOrderGroup.Key,
duplicateOrderGroup.ToArray());
}
var duplicateNameGroup = command.Parameters
.Where(a => !string.IsNullOrWhiteSpace(a.Name))
.GroupBy(a => a.Name, StringComparer.OrdinalIgnoreCase)
.GroupBy(a => a.Name!, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(g => g.Count() > 1);
if (duplicateNameGroup != null)
{
throw new CliFxException(new StringBuilder()
.AppendLine($"Command {command.Type.FullName} contains two or more parameters that have the same name ({duplicateNameGroup.Key}):")
.AppendBulletList(duplicateNameGroup.Select(o => o.Property.Name))
.AppendLine()
.Append("Parameters in a command must all have unique names.").Append(" ")
.Append("Comparison is NOT case-sensitive.")
.ToString());
throw CliFxException.CommandParametersDuplicateName(
command,
duplicateNameGroup.Key,
duplicateNameGroup.ToArray());
}
var nonScalarParameters = command.Parameters
@@ -136,13 +124,9 @@ namespace CliFx.Domain
if (nonScalarParameters.Length > 1)
{
throw new CliFxException(new StringBuilder()
.AppendLine($"Command [{command.Type.FullName}] contains two or more parameters of an enumerable type:")
.AppendBulletList(nonScalarParameters.Select(o => o.Property.Name))
.AppendLine()
.AppendLine("There can only be one parameter of an enumerable type in a command.")
.Append("Note, the string type is not considered enumerable in this context.")
.ToString());
throw CliFxException.CommandParametersTooManyNonScalar(
command,
nonScalarParameters);
}
var nonLastNonScalarParameter = command.Parameters
@@ -152,92 +136,74 @@ namespace CliFx.Domain
if (nonLastNonScalarParameter != null)
{
throw new CliFxException(new StringBuilder()
.AppendLine($"Command {command.Type.FullName} contains a parameter of an enumerable type which doesn't appear last in order:")
.AppendLine($"- {nonLastNonScalarParameter.Property.Name}")
.AppendLine()
.Append("Parameter of an enumerable type must always come last to avoid ambiguity.")
.ToString());
throw CliFxException.CommandParametersNonLastNonScalar(
command,
nonLastNonScalarParameter);
}
}
private static void ValidateOptions(CommandSchema command)
{
var emptyNameGroup = command.Options
var noNameGroup = command.Options
.Where(o => o.ShortName == null && string.IsNullOrWhiteSpace(o.Name))
.ToArray();
if (emptyNameGroup.Any())
if (noNameGroup.Any())
{
throw new CliFxException(new StringBuilder()
.AppendLine($"Command {command.Type.FullName} contains one or more options that have empty names:")
.AppendBulletList(emptyNameGroup.Select(o => o.Property.Name))
.AppendLine()
.Append("Options in a command must all have at least a name or a short name.")
.ToString());
throw CliFxException.CommandOptionsNoName(
command,
noNameGroup.ToArray());
}
var invalidNameGroup = command.Options
var invalidLengthNameGroup = command.Options
.Where(o => !string.IsNullOrWhiteSpace(o.Name))
.Where(o => o.Name.Length <= 1)
.Where(o => o.Name!.Length <= 1)
.ToArray();
if (invalidNameGroup.Any())
if (invalidLengthNameGroup.Any())
{
throw new CliFxException(new StringBuilder()
.AppendLine($"Command {command.Type.FullName} contains one or more options that have names that are too short:")
.AppendBulletList(invalidNameGroup.Select(o => o.Property.Name))
.AppendLine()
.Append("Options in a command must all have names that are longer than a single character.")
.ToString());
throw CliFxException.CommandOptionsInvalidLengthName(
command,
invalidLengthNameGroup);
}
var duplicateNameGroup = command.Options
.Where(o => !string.IsNullOrWhiteSpace(o.Name))
.GroupBy(o => o.Name, StringComparer.OrdinalIgnoreCase)
.GroupBy(o => o.Name!, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(g => g.Count() > 1);
if (duplicateNameGroup != null)
{
throw new CliFxException(new StringBuilder()
.AppendLine($"Command {command.Type.FullName} contains two or more options that have the same name ({duplicateNameGroup.Key}):")
.AppendBulletList(duplicateNameGroup.Select(o => o.Property.Name))
.AppendLine()
.Append("Options in a command must all have unique names.").Append(" ")
.Append("Comparison is NOT case-sensitive.")
.ToString());
throw CliFxException.CommandOptionsDuplicateName(
command,
duplicateNameGroup.Key,
duplicateNameGroup.ToArray());
}
var duplicateShortNameGroup = command.Options
.Where(o => o.ShortName != null)
.GroupBy(o => o.ShortName)
.GroupBy(o => o.ShortName!.Value)
.FirstOrDefault(g => g.Count() > 1);
if (duplicateShortNameGroup != null)
{
throw new CliFxException(new StringBuilder()
.AppendLine($"Command {command.Type.FullName} contains two or more options that have the same short name ({duplicateShortNameGroup.Key}):")
.AppendBulletList(duplicateShortNameGroup.Select(o => o.Property.Name))
.AppendLine()
.Append("Options in a command must all have unique short names.").Append(" ")
.Append("Comparison is case-sensitive.")
.ToString());
throw CliFxException.CommandOptionsDuplicateShortName(
command,
duplicateShortNameGroup.Key,
duplicateShortNameGroup.ToArray());
}
var duplicateEnvironmentVariableNameGroup = command.Options
.Where(o => !string.IsNullOrWhiteSpace(o.EnvironmentVariableName))
.GroupBy(o => o.EnvironmentVariableName, StringComparer.OrdinalIgnoreCase)
.GroupBy(o => o.EnvironmentVariableName!, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(g => g.Count() > 1);
if (duplicateEnvironmentVariableNameGroup != null)
{
throw new CliFxException(new StringBuilder()
.AppendLine($"Command {command.Type.FullName} contains two or more options that have the same environment variable name ({duplicateEnvironmentVariableNameGroup.Key}):")
.AppendBulletList(duplicateEnvironmentVariableNameGroup.Select(o => o.Property.Name))
.AppendLine()
.Append("Options in a command must all have unique environment variable names.").Append(" ")
.Append("Comparison is NOT case-sensitive.")
.ToString());
throw CliFxException.CommandOptionsDuplicateEnvironmentVariableName(
command,
duplicateEnvironmentVariableNameGroup.Key,
duplicateEnvironmentVariableNameGroup.ToArray());
}
}
@@ -245,7 +211,7 @@ namespace CliFx.Domain
{
if (!commands.Any())
{
throw new CliFxException("There are no commands configured for this application.");
throw CliFxException.CommandsNotRegistered();
}
var duplicateNameGroup = commands
@@ -254,13 +220,12 @@ namespace CliFx.Domain
if (duplicateNameGroup != null)
{
throw new CliFxException(new StringBuilder()
.AppendLine($"Application contains two or more commands that have the same name ({duplicateNameGroup.Key}):")
.AppendBulletList(duplicateNameGroup.Select(o => o.Type.FullName))
.AppendLine()
.Append("Commands must all have unique names. Likewise, there must not be more than one command without a name.").Append(" ")
.Append("Comparison is NOT case-sensitive.")
.ToString());
if (!string.IsNullOrWhiteSpace(duplicateNameGroup.Key))
throw CliFxException.CommandsDuplicateName(
duplicateNameGroup.Key,
duplicateNameGroup.ToArray());
throw CliFxException.CommandsTooManyDefaults(duplicateNameGroup.ToArray());
}
}
@@ -270,17 +235,8 @@ namespace CliFx.Domain
foreach (var commandType in commandTypes)
{
var command = CommandSchema.TryResolve(commandType);
if (command == null)
{
throw new CliFxException(new StringBuilder()
.Append($"Command {commandType.FullName} is not a valid command type.").Append(" ")
.AppendLine("In order to be a valid command type it must:")
.AppendLine($" - Be annotated with {typeof(CommandAttribute).FullName}")
.AppendLine($" - Implement {typeof(ICommand).FullName}")
.AppendLine(" - Not be an abstract class")
.ToString());
}
var command = CommandSchema.TryResolve(commandType) ??
throw CliFxException.InvalidCommandType(commandType);
ValidateParameters(command);
ValidateOptions(command);

View File

@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Text;
using CliFx.Exceptions;
using CliFx.Internal;
@@ -15,7 +14,9 @@ namespace CliFx.Domain
public string? Description { get; }
public bool IsScalar => GetEnumerableArgumentUnderlyingType() == null;
public abstract string DisplayName { get; }
public bool IsScalar => TryGetEnumerableArgumentUnderlyingType() == null;
protected CommandArgumentSchema(PropertyInfo property, string? description)
{
@@ -23,28 +24,85 @@ namespace CliFx.Domain
Description = description;
}
private Type? GetEnumerableArgumentUnderlyingType() =>
private Type? TryGetEnumerableArgumentUnderlyingType() =>
Property.PropertyType != typeof(string)
? Property.PropertyType.GetEnumerableUnderlyingType()
: null;
private object Convert(IReadOnlyList<string> values)
private object? ConvertScalar(string? value, Type targetType)
{
try
{
// Primitive
var primitiveConverter = PrimitiveConverters.GetValueOrDefault(targetType);
if (primitiveConverter != null)
return primitiveConverter(value);
// Enum
if (targetType.IsEnum)
return Enum.Parse(targetType, value, true);
// Nullable
var nullableUnderlyingType = targetType.GetNullableUnderlyingType();
if (nullableUnderlyingType != null)
return !string.IsNullOrWhiteSpace(value)
? ConvertScalar(value, nullableUnderlyingType)
: null;
// String-constructable
var stringConstructor = GetStringConstructor(targetType);
if (stringConstructor != null)
return stringConstructor.Invoke(new object[] {value!});
// String-parseable (with format provider)
var parseMethodWithFormatProvider = GetStaticParseMethodWithFormatProvider(targetType);
if (parseMethodWithFormatProvider != null)
return parseMethodWithFormatProvider.Invoke(null, new object[] {value!, ConversionFormatProvider});
// String-parseable (without format provider)
var parseMethod = GetStaticParseMethod(targetType);
if (parseMethod != null)
return parseMethod.Invoke(null, new object[] {value!});
}
catch (Exception ex)
{
throw CliFxException.CannotConvertToType(this, value, targetType, ex);
}
throw CliFxException.CannotConvertToType(this, value, targetType);
}
private object ConvertNonScalar(IReadOnlyList<string> values, Type targetEnumerableType, Type targetElementType)
{
var array = values
.Select(v => ConvertScalar(v, targetElementType))
.ToNonGenericArray(targetElementType);
var arrayType = array.GetType();
// Assignable from an array
if (targetEnumerableType.IsAssignableFrom(arrayType))
return array;
// Constructable from an array
var arrayConstructor = targetEnumerableType.GetConstructor(new[] {arrayType});
if (arrayConstructor != null)
return arrayConstructor.Invoke(new object[] {array});
throw CliFxException.CannotConvertNonScalar(this, values, targetEnumerableType);
}
private object? Convert(IReadOnlyList<string> values)
{
var targetType = Property.PropertyType;
var enumerableUnderlyingType = GetEnumerableArgumentUnderlyingType();
var enumerableUnderlyingType = TryGetEnumerableArgumentUnderlyingType();
// Scalar
if (enumerableUnderlyingType == null)
{
if (values.Count > 1)
{
throw new CliFxException(new StringBuilder()
.AppendLine($"Can't convert a sequence of values [{string.Join(", ", values)}] to type {targetType.FullName}.")
.Append("Target type is not enumerable and can't accept more than one value.")
.ToString());
}
return ConvertScalar(values.SingleOrDefault(), targetType);
return values.Count <= 1
? ConvertScalar(values.SingleOrDefault(), targetType)
: throw CliFxException.CannotConvertMultipleValuesToNonScalar(this, values);
}
// Non-scalar
else
@@ -64,8 +122,8 @@ namespace CliFx.Domain
{
private static readonly IFormatProvider ConversionFormatProvider = CultureInfo.InvariantCulture;
private static readonly IReadOnlyDictionary<Type, Func<string, object>> PrimitiveConverters =
new Dictionary<Type, Func<string?, object>>
private static readonly IReadOnlyDictionary<Type, Func<string?, object?>> PrimitiveConverters =
new Dictionary<Type, Func<string?, object?>>
{
[typeof(object)] = v => v,
[typeof(string)] = v => v,
@@ -99,78 +157,5 @@ namespace CliFx.Domain
type.GetMethod("Parse",
BindingFlags.Public | BindingFlags.Static,
null, new[] {typeof(string), typeof(IFormatProvider)}, null);
private static object ConvertScalar(string? value, Type targetType)
{
try
{
// Primitive
var primitiveConverter = PrimitiveConverters.GetValueOrDefault(targetType);
if (primitiveConverter != null)
return primitiveConverter(value);
// Enum
if (targetType.IsEnum)
return Enum.Parse(targetType, value, true);
// Nullable
var nullableUnderlyingType = targetType.GetNullableUnderlyingType();
if (nullableUnderlyingType != null)
return !string.IsNullOrWhiteSpace(value)
? ConvertScalar(value, nullableUnderlyingType)
: null;
// String-constructable
var stringConstructor = GetStringConstructor(targetType);
if (stringConstructor != null)
return stringConstructor.Invoke(new object[] {value});
// String-parseable (with format provider)
var parseMethodWithFormatProvider = GetStaticParseMethodWithFormatProvider(targetType);
if (parseMethodWithFormatProvider != null)
return parseMethodWithFormatProvider.Invoke(null, new object[] {value, ConversionFormatProvider});
// String-parseable (without format provider)
var parseMethod = GetStaticParseMethod(targetType);
if (parseMethod != null)
return parseMethod.Invoke(null, new object[] {value});
}
catch (Exception ex)
{
throw new CliFxException(new StringBuilder()
.AppendLine($"Failed to convert value '{value ?? "<null>"}' to type {targetType.FullName}.")
.Append(ex.Message)
.ToString(), ex);
}
throw new CliFxException(new StringBuilder()
.AppendLine($"Can't convert value '{value ?? "<null>"}' to type {targetType.FullName}.")
.Append("Target type is not supported by CliFx.")
.ToString());
}
private static object ConvertNonScalar(IReadOnlyList<string> values, Type targetEnumerableType, Type targetElementType)
{
var array = values
.Select(v => ConvertScalar(v, targetElementType))
.ToNonGenericArray(targetElementType);
var arrayType = array.GetType();
// Assignable from an array
if (targetEnumerableType.IsAssignableFrom(arrayType))
return array;
// Constructable from an array
var arrayConstructor = targetEnumerableType.GetConstructor(new[] {arrayType});
if (arrayConstructor != null)
return arrayConstructor.Invoke(new object[] {array});
throw new CliFxException(new StringBuilder()
.AppendLine($"Can't convert a sequence of values [{string.Join(", ", values)}] to type {targetEnumerableType.FullName}.")
.AppendLine($"Underlying element type is [{targetElementType.FullName}].")
.Append("Target type must either be assignable from an array or have a public constructor that takes a single array argument.")
.ToString());
}
}
}

View File

@@ -8,6 +8,11 @@ namespace CliFx.Domain
{
public string Alias { get; }
public string DisplayAlias =>
Alias.Length > 1
? $"--{Alias}"
: $"-{Alias}";
public IReadOnlyList<string> Values { get; }
public bool IsHelpOption => CommandOptionSchema.HelpOption.MatchesNameOrShortName(Alias);
@@ -24,8 +29,7 @@ namespace CliFx.Domain
{
var buffer = new StringBuilder();
buffer.Append(Alias.Length > 1 ? "--" : "-");
buffer.Append(Alias);
buffer.Append(DisplayAlias);
foreach (var value in Values)
{

View File

@@ -3,7 +3,6 @@ using System.Linq;
using System.Reflection;
using System.Text;
using CliFx.Attributes;
using CliFx.Internal;
namespace CliFx.Domain
{
@@ -13,9 +12,9 @@ namespace CliFx.Domain
public char? ShortName { get; }
public string DisplayName => !string.IsNullOrWhiteSpace(Name)
? Name
: ShortName?.AsString()!;
public override string DisplayName => !string.IsNullOrWhiteSpace(Name)
? $"--{Name}"
: $"-{ShortName}";
public string? EnvironmentVariableName { get; }

View File

@@ -10,9 +10,10 @@ namespace CliFx.Domain
public string? Name { get; }
public string DisplayName => !string.IsNullOrWhiteSpace(Name)
? Name
: Property.Name.ToLowerInvariant();
public override string DisplayName =>
!string.IsNullOrWhiteSpace(Name)
? Name
: Property.Name.ToLowerInvariant();
public CommandParameterSchema(PropertyInfo property, int order, string? name, string? description)
: base(property, description)

View File

@@ -40,7 +40,7 @@ namespace CliFx.Domain
public bool MatchesName(string? name) => string.Equals(name, Name, StringComparison.OrdinalIgnoreCase);
private void InjectParameters(ICommand command, IReadOnlyList<string> parameterInputs)
private void InjectParameters(ICommand command, IReadOnlyList<CommandUnboundArgumentInput> parameterInputs)
{
// All inputs must be bound
var remainingParameterInputs = parameterInputs.ToList();
@@ -57,9 +57,9 @@ namespace CliFx.Domain
var scalarParameterInput = i < parameterInputs.Count
? parameterInputs[i]
: throw new CliFxException($"Missing value for parameter <{scalarParameter.DisplayName}>.");
: throw CliFxException.ParameterNotSet(scalarParameter);
scalarParameter.Inject(command, scalarParameterInput);
scalarParameter.Inject(command, scalarParameterInput.Value);
remainingParameterInputs.Remove(scalarParameterInput);
}
@@ -70,18 +70,16 @@ namespace CliFx.Domain
if (nonScalarParameter != null)
{
var nonScalarParameterInputs = parameterInputs.Skip(scalarParameters.Length).ToArray();
nonScalarParameter.Inject(command, nonScalarParameterInputs);
var nonScalarParameterValues = parameterInputs.Skip(scalarParameters.Length).Select(i => i.Value).ToArray();
nonScalarParameter.Inject(command, nonScalarParameterValues);
remainingParameterInputs.Clear();
}
// Ensure all inputs were bound
if (remainingParameterInputs.Any())
{
throw new CliFxException(new StringBuilder()
.AppendLine("Unrecognized parameters provided:")
.AppendBulletList(remainingParameterInputs)
.ToString());
throw CliFxException.UnrecognizedParametersProvided(remainingParameterInputs);
}
}
@@ -136,24 +134,18 @@ namespace CliFx.Domain
// Ensure all required options were set
if (unsetRequiredOptions.Any())
{
throw new CliFxException(new StringBuilder()
.AppendLine("Missing values for some of the required options:")
.AppendBulletList(unsetRequiredOptions.Select(o => o.DisplayName))
.ToString());
throw CliFxException.RequiredOptionsNotSet(unsetRequiredOptions);
}
// Ensure all inputs were bound
if (remainingOptionInputs.Any())
{
throw new CliFxException(new StringBuilder()
.AppendLine("Unrecognized options provided:")
.AppendBulletList(remainingOptionInputs.Select(o => o.Alias).Distinct())
.ToString());
throw CliFxException.UnrecognizedOptionsProvided(remainingOptionInputs);
}
}
public ICommand CreateInstance(
IReadOnlyList<string> parameterInputs,
IReadOnlyList<CommandUnboundArgumentInput> parameterInputs,
IReadOnlyList<CommandOptionInput> optionInputs,
IReadOnlyDictionary<string, string> environmentVariables,
ITypeActivator activator)

View File

@@ -1,11 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CliFx.Attributes;
using CliFx.Domain;
namespace CliFx.Exceptions
{
/// <summary>
/// Domain exception thrown within CliFx.
/// </summary>
public class CliFxException : Exception
public partial class CliFxException : Exception
{
/// <summary>
/// Initializes an instance of <see cref="CliFxException"/>.
@@ -23,4 +27,340 @@ namespace CliFx.Exceptions
{
}
}
// Mid-user-facing exceptions
// Provide more diagnostic information here
public partial class CliFxException
{
internal static CliFxException DefaultActivatorFailed(Type type, Exception? innerException = null)
{
var configureActivatorMethodName = $"{nameof(CliApplicationBuilder)}.{nameof(CliApplicationBuilder.UseTypeActivator)}(...)";
var message = $@"
Failed to create an instance of type '{type.FullName}'.
The type must have a public parameterless constructor in order to be instantiated by the default activator.
To fix this, either make sure this type has a public parameterless constructor, or configure a custom activator using {configureActivatorMethodName}.
Refer to the readme to learn how to integrate a dependency container of your choice to act as a type activator.";
return new CliFxException(message.Trim(), innerException);
}
internal static CliFxException DelegateActivatorReceivedNull(Type type)
{
var message = $@"
Failed to create an instance of type '{type.FullName}', received <null> instead.
To fix this, ensure that the provided type activator was configured correctly, as it's not expected to return <null>.
If you are using a dependency container, ensure this type is registered, because it may return <null> otherwise.";
return new CliFxException(message.Trim());
}
internal static CliFxException InvalidCommandType(Type type)
{
var message = $@"
Command '{type.FullName}' is not a valid command type.
In order to be a valid command type, it must:
- Not be an abstract class
- Implement {typeof(ICommand).FullName}
- Be annotated with {typeof(CommandAttribute).FullName}
To fix this, ensure that the command adheres to these constraints.
If you're experiencing problems, please refer to readme for a quickstart example.";
return new CliFxException(message.Trim());
}
internal static CliFxException CommandsNotRegistered()
{
var message = $@"
There are no commands configured in the application.
To fix this, ensure that at least one command is added through one of the methods on {nameof(CliApplicationBuilder)}.
If you're experiencing problems, please refer to readme for a quickstart example.";
return new CliFxException(message.Trim());
}
internal static CliFxException CommandsTooManyDefaults(
IReadOnlyList<CommandSchema> invalidCommands)
{
var message = $@"
Application configuration is invalid because there are {invalidCommands.Count} default commands:
{string.Join(Environment.NewLine, invalidCommands.Select(p => p.Type.FullName))}
There can only be one default command (i.e. command with no name) in an application.
Other commands must have unique non-empty names that identify them.
To fix this, ensure that all extra commands have different names.";
return new CliFxException(message.Trim());
}
internal static CliFxException CommandsDuplicateName(
string name,
IReadOnlyList<CommandSchema> invalidCommands)
{
var message = $@"
Application configuration is invalid because there are {invalidCommands.Count} commands with the same name ('{name}'):
{string.Join(Environment.NewLine, invalidCommands.Select(p => p.Type.FullName))}
Commands must have unique names, because that's what identifies them.
Names are not case-sensitive.
To fix this, ensure that all commands have different names.";
return new CliFxException(message.Trim());
}
internal static CliFxException CommandParametersDuplicateOrder(
CommandSchema command,
int order,
IReadOnlyList<CommandParameterSchema> invalidParameters)
{
var message = $@"
Command '{command.Type.FullName}' is invalid because it contains {invalidParameters.Count} parameters with the same order ({order}):
{string.Join(Environment.NewLine, invalidParameters.Select(p => p.Property.Name))}
Parameters must have unique order, because that's what identifies them.
To fix this, ensure that all parameters have different order.";
return new CliFxException(message.Trim());
}
internal static CliFxException CommandParametersDuplicateName(
CommandSchema command,
string name,
IReadOnlyList<CommandParameterSchema> invalidParameters)
{
var message = $@"
Command '{command.Type.FullName}' is invalid because it contains {invalidParameters.Count} parameters with the same name ('{name}'):
{string.Join(Environment.NewLine, invalidParameters.Select(p => p.Property.Name))}
Parameters must have unique names to avoid potential confusion in the help text.
Names are not case-sensitive.
To fix this, ensure that all parameters have different names.";
return new CliFxException(message.Trim());
}
internal static CliFxException CommandParametersTooManyNonScalar(
CommandSchema command,
IReadOnlyList<CommandParameterSchema> invalidParameters)
{
var message = $@"
Command '{command.Type.FullName}' is invalid because it contains {invalidParameters.Count} non-scalar parameters:
{string.Join(Environment.NewLine, invalidParameters.Select(p => p.Property.Name))}
Non-scalar parameter is such that is bound from more than one value (e.g. array or a complex object).
Only one parameter in a command may be non-scalar and it must be the last one in order.
To fix this, ensure there's only a single non-scalar parameter.
If that's not possible, consider converting one or more of the parameters into options, to avoid this limitation.";
return new CliFxException(message.Trim());
}
internal static CliFxException CommandParametersNonLastNonScalar(
CommandSchema command,
CommandParameterSchema invalidParameter)
{
var message = $@"
Command '{command.Type.FullName}' is invalid because it contains a non-scalar parameter which is not the last in order:
{invalidParameter.Property.Name}
Non-scalar parameter is such that is bound from more than one value (e.g. array or a complex object).
Only one parameter in a command may be non-scalar and it must be the last one in order.
To fix this, ensure that the non-scalar parameter is last in order.
If that's not possible, consider converting the parameter into an option, to avoid this limitation.";
return new CliFxException(message.Trim());
}
internal static CliFxException CommandOptionsNoName(
CommandSchema command,
IReadOnlyList<CommandOptionSchema> invalidOptions)
{
var message = $@"
Command '{command.Type.FullName}' is invalid because it contains one or more options without a name:
{string.Join(Environment.NewLine, invalidOptions.Select(p => p.Property.Name))}
Options must have either a name or a short name or both, because that's what identifies them.
To fix this, ensure all options have their names or short names set to some values.";
return new CliFxException(message.Trim());
}
internal static CliFxException CommandOptionsInvalidLengthName(
CommandSchema command,
IReadOnlyList<CommandOptionSchema> invalidOptions)
{
var message = $@"
Command '{command.Type.FullName}' is invalid because it contains one or more options whose names are too short:
{string.Join(Environment.NewLine, invalidOptions.Select(p => $"{p.Property.Name} ('{p.DisplayName}')"))}
Option names must be at least 2 characters long to avoid confusion with short names.
If you intended to set the short name instead, use the corresponding attribute overload.
To fix this, ensure all option names are at least 2 characters long.";
return new CliFxException(message.Trim());
}
internal static CliFxException CommandOptionsDuplicateName(
CommandSchema command,
string name,
IReadOnlyList<CommandOptionSchema> invalidOptions)
{
var message = $@"
Command '{command.Type.FullName}' is invalid because it contains {invalidOptions.Count} options with the same name ('{name}'):
{string.Join(Environment.NewLine, invalidOptions.Select(p => p.Property.Name))}
Options must have unique names, because that's what identifies them.
Names are not case-sensitive.
To fix this, ensure that all options have different names.";
return new CliFxException(message.Trim());
}
internal static CliFxException CommandOptionsDuplicateShortName(
CommandSchema command,
char shortName,
IReadOnlyList<CommandOptionSchema> invalidOptions)
{
var message = $@"
Command '{command.Type.FullName}' is invalid because it contains {invalidOptions.Count} options with the same short name ('{shortName}'):
{string.Join(Environment.NewLine, invalidOptions.Select(p => p.Property.Name))}
Options must have unique short names, because that's what identifies them.
Short names are case-sensitive (i.e. 'a' and 'A' are different short names).
To fix this, ensure that all options have different short names.";
return new CliFxException(message.Trim());
}
internal static CliFxException CommandOptionsDuplicateEnvironmentVariableName(
CommandSchema command,
string environmentVariableName,
IReadOnlyList<CommandOptionSchema> invalidOptions)
{
var message = $@"
Command '{command.Type.FullName}' is invalid because it contains {invalidOptions.Count} options with the same fallback environment variable name ('{environmentVariableName}'):
{string.Join(Environment.NewLine, invalidOptions.Select(p => p.Property.Name))}
Options cannot share the same environment variable as a fallback.
Environment variable names are not case-sensitive.
To fix this, ensure that all options have different fallback environment variables.";
return new CliFxException(message.Trim());
}
}
// End-user-facing exceptions
// Avoid internal details and fix recommendations here
public partial class CliFxException
{
internal static CliFxException CannotFindMatchingCommand(CommandLineInput input)
{
var message = $@"
Can't find a command that matches the following arguments:
{string.Join(" ", input.UnboundArguments.Select(a => a.Value))}";
return new CliFxException(message.Trim());
}
internal static CliFxException CannotConvertMultipleValuesToNonScalar(
CommandArgumentSchema argument,
IReadOnlyList<string> values)
{
var argumentDisplayText = argument is CommandParameterSchema
? $"Parameter <{argument.DisplayName}>"
: $"Option '{argument.DisplayName}'";
var message = $@"
{argumentDisplayText} expects a single value, but provided with multiple:
{string.Join(", ", values.Select(v => $"'{v}'"))}";
return new CliFxException(message.Trim());
}
internal static CliFxException CannotConvertToType(
CommandArgumentSchema argument,
string? value,
Type type,
Exception? innerException = null)
{
var argumentDisplayText = argument is CommandParameterSchema
? $"parameter <{argument.DisplayName}>"
: $"option '{argument.DisplayName}'";
var message = $@"
Can't convert value '{value ?? "<null>"}' to type '{type.FullName}' for {argumentDisplayText}.
{innerException?.Message ?? "This type is not supported."}";
return new CliFxException(message.Trim(), innerException);
}
internal static CliFxException CannotConvertNonScalar(
CommandArgumentSchema argument,
IReadOnlyList<string> values,
Type type)
{
var argumentDisplayText = argument is CommandParameterSchema
? $"parameter <{argument.DisplayName}>"
: $"option '{argument.DisplayName}'";
var message = $@"
Can't convert provided values to type '{type.FullName}' for {argumentDisplayText}:
{string.Join(", ", values.Select(v => $"'{v}'"))}
Target type is not assignable from array and doesn't have a public constructor that takes an array.";
return new CliFxException(message.Trim());
}
internal static CliFxException ParameterNotSet(CommandParameterSchema parameter)
{
var message = $@"
Missing value for parameter <{parameter.DisplayName}>.";
return new CliFxException(message.Trim());
}
internal static CliFxException RequiredOptionsNotSet(IReadOnlyList<CommandOptionSchema> options)
{
var message = $@"
Missing values for one or more required options:
{string.Join(Environment.NewLine, options.Select(o => o.DisplayName))}";
return new CliFxException(message.Trim());
}
internal static CliFxException UnrecognizedParametersProvided(IReadOnlyList<CommandUnboundArgumentInput> inputs)
{
var message = $@"
Unrecognized parameters provided:
{string.Join(Environment.NewLine, inputs.Select(i => $"<{i.Value}>"))}";
return new CliFxException(message.Trim());
}
internal static CliFxException UnrecognizedOptionsProvided(IReadOnlyList<CommandOptionInput> inputs)
{
var message = $@"
Unrecognized options provided:
{string.Join(Environment.NewLine, inputs.Select(i => i.DisplayAlias))}";
return new CliFxException(message.Trim());
}
}
}

View File

@@ -15,18 +15,6 @@ namespace CliFx.Internal
public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) =>
builder.Length > 0 ? builder.Append(value) : builder;
public static StringBuilder AppendBulletList<T>(this StringBuilder builder, IEnumerable<T> items)
{
foreach (var item in items)
{
builder.Append("- ");
builder.Append(item);
builder.AppendLine();
}
return builder;
}
public static bool Implements(this Type type, Type interfaceType) => type.GetInterfaces().Contains(interfaceType);
public static Type? GetNullableUnderlyingType(this Type type) => Nullable.GetUnderlyingType(type);

View File

@@ -7,7 +7,7 @@ namespace CliFx
/// <summary>
/// Implementation of <see cref="IConsole"/> that wraps the default system console.
/// </summary>
public class SystemConsole : IConsole
public partial class SystemConsole : IConsole
{
private CancellationTokenSource? _cancellationTokenSource;
@@ -48,9 +48,9 @@ namespace CliFx
/// </summary>
public SystemConsole()
{
Input = new StreamReader(Console.OpenStandardInput(), Console.InputEncoding, false);
Output = new StreamWriter(Console.OpenStandardOutput(), Console.OutputEncoding) {AutoFlush = true};
Error = new StreamWriter(Console.OpenStandardError(), Console.OutputEncoding) {AutoFlush = true};
Input = WrapInput(Console.OpenStandardInput());
Output = WrapOutput(Console.OpenStandardOutput());
Error = WrapOutput(Console.OpenStandardError());
}
/// <inheritdoc />
@@ -77,4 +77,17 @@ namespace CliFx
return (_cancellationTokenSource = cts).Token;
}
}
public partial class SystemConsole
{
private static StreamReader WrapInput(Stream? stream) =>
stream != null
? new StreamReader(stream, Console.InputEncoding, false)
: StreamReader.Null;
private static StreamWriter WrapOutput(Stream? stream) =>
stream != null
? new StreamWriter(stream, Console.OutputEncoding) {AutoFlush = true}
: StreamWriter.Null;
}
}