diff --git a/CliFx.Tests/ConsoleSpecs.cs b/CliFx.Tests/ConsoleSpecs.cs index e3574f6..f3ffc98 100644 --- a/CliFx.Tests/ConsoleSpecs.cs +++ b/CliFx.Tests/ConsoleSpecs.cs @@ -51,6 +51,8 @@ namespace CliFx.Tests console.ResetColor(); console.ForegroundColor = ConsoleColor.DarkMagenta; console.BackgroundColor = ConsoleColor.DarkMagenta; + console.CursorLeft = 42; + console.CursorTop = 24; // Assert stdInData.Should().Be("input"); diff --git a/CliFx.Tests/HelpTextSpecs.Commands.cs b/CliFx.Tests/HelpTextSpecs.Commands.cs index 4f32ac3..1304052 100644 --- a/CliFx.Tests/HelpTextSpecs.Commands.cs +++ b/CliFx.Tests/HelpTextSpecs.Commands.cs @@ -1,11 +1,7 @@ using System; -using System.Collections; using System.Collections.Generic; -using System.Globalization; -using System.Linq; using System.Threading.Tasks; using CliFx.Attributes; -using CliFx.Exceptions; namespace CliFx.Tests { @@ -89,16 +85,16 @@ namespace CliFx.Tests [Command("cmd-with-enum-args")] private class EnumArgumentsCommand : ICommand { - public enum TestEnum { Value1, Value2, Value3 }; + public enum CustomEnum { Value1, Value2, Value3 }; [CommandParameter(0, Name = "value", Description = "Enum parameter.")] - public TestEnum ParamA { get; set; } + public CustomEnum ParamA { get; set; } [CommandOption("value", Description = "Enum option.", IsRequired = true)] - public TestEnum OptionA { get; set; } = TestEnum.Value1; + public CustomEnum OptionA { get; set; } = CustomEnum.Value1; [CommandOption("nullable-value", Description = "Nullable enum option.")] - public TestEnum? OptionB { get; set; } + public CustomEnum? OptionB { get; set; } public ValueTask ExecuteAsync(IConsole console) => default; } @@ -116,8 +112,10 @@ namespace CliFx.Tests } [Command("cmd-with-defaults")] - private class DefaultArgumentsCommand : ICommand + private class ArgumentsWithDefaultValuesCommand : ICommand { + public enum CustomEnum { Value1, Value2, Value3 }; + [CommandOption(nameof(Object))] public object? Object { get; set; } = 42; @@ -127,98 +125,29 @@ namespace CliFx.Tests [CommandOption(nameof(EmptyString))] public string EmptyString { get; set; } = ""; - [CommandOption(nameof(WhiteSpaceString))] - public string WhiteSpaceString { get; set; } = " "; - [CommandOption(nameof(Bool))] public bool Bool { get; set; } = true; [CommandOption(nameof(Char))] public char Char { get; set; } = 't'; - [CommandOption(nameof(Sbyte))] - public sbyte Sbyte { get; set; } = -0b11; - - [CommandOption(nameof(Byte))] - public byte Byte { get; set; } = 0b11; - - [CommandOption(nameof(Short))] - public short Short { get; set; } = -1234; - - [CommandOption(nameof(Ushort))] - public short Ushort { get; set; } = 1234; - [CommandOption(nameof(Int))] public int Int { get; set; } = 1337; - [CommandOption(nameof(Uint))] - public uint Uint { get; set; } = 2345; - - [CommandOption(nameof(Long))] - public long Long { get; set; } = -1234567; - - [CommandOption(nameof(Ulong))] - public ulong Ulong { get; set; } = 12345678; - - [CommandOption(nameof(Float))] - public float Float { get; set; } = 123.4567F; - - [CommandOption(nameof(Double))] - public double Double { get; set; } = 420.1337; - - [CommandOption(nameof(Decimal))] - public decimal Decimal { get; set; } = 1337.420M; - - [CommandOption(nameof(DateTime))] - public DateTime DateTime { get; set; } = new DateTime(2020, 4, 20); - - [CommandOption(nameof(DateTimeOffset))] - public DateTimeOffset DateTimeOffset { get; set; } = - new DateTimeOffset(2008, 5, 1, 0, 0, 0, new TimeSpan(0, 1, 0, 0, 0)); - [CommandOption(nameof(TimeSpan))] public TimeSpan TimeSpan { get; set; } = TimeSpan.FromMinutes(123); - public enum TestEnum { Value1, Value2, Value3 }; - - [CommandOption(nameof(CustomEnum))] - public TestEnum CustomEnum { get; set; } = TestEnum.Value2; + [CommandOption(nameof(Enum))] + public CustomEnum Enum { get; set; } = CustomEnum.Value2; [CommandOption(nameof(IntNullable))] public int? IntNullable { get; set; } = 1337; - [CommandOption(nameof(CustomEnumNullable))] - public TestEnum? CustomEnumNullable { get; set; } = TestEnum.Value2; - - [CommandOption(nameof(TimeSpanNullable))] - public TimeSpan? TimeSpanNullable { get; set; } = TimeSpan.FromMinutes(234); - - [CommandOption(nameof(ObjectArray))] - public object[]? ObjectArray { get; set; } = new object[] { "123", 4, 3.14 }; - [CommandOption(nameof(StringArray))] - public string[]? StringArray { get; set; } = new[] { "foo", "bar", "baz" }; + public string[]? StringArray { get; set; } = { "foo", "bar", "baz" }; [CommandOption(nameof(IntArray))] - public int[]? IntArray { get; set; } = new[] { 1, 2, 3 }; - - [CommandOption(nameof(CustomEnumArray))] - public TestEnum[]? CustomEnumArray { get; set; } = new[] { TestEnum.Value1, TestEnum.Value3 }; - - [CommandOption(nameof(IntNullableArray))] - public int?[]? IntNullableArray { get; set; } = new int?[] { 2, 3, 4, null, 5 }; - - [CommandOption(nameof(EnumerableNullable))] - public IEnumerable? EnumerableNullable { get; set; } = Enumerable.Repeat("foo", 3); - - [CommandOption(nameof(StringEnumerable))] - public IEnumerable? StringEnumerable { get; set; } = Enumerable.Repeat("bar", 3); - - [CommandOption(nameof(StringReadOnlyList))] - public IReadOnlyList? StringReadOnlyList { get; set; } = new[] { "foo", "bar", "baz" }; - - [CommandOption(nameof(StringList))] - public List? StringList { get; set; } = new List() { "foo", "bar", "baz" }; + public int[]? IntArray { get; set; } = { 1, 2, 3 }; public ValueTask ExecuteAsync(IConsole console) => default; } diff --git a/CliFx.Tests/HelpTextSpecs.cs b/CliFx.Tests/HelpTextSpecs.cs index 6a637a0..b0214ff 100644 --- a/CliFx.Tests/HelpTextSpecs.cs +++ b/CliFx.Tests/HelpTextSpecs.cs @@ -269,7 +269,7 @@ namespace CliFx.Tests _output.WriteLine(stdOutData); } - + [Fact] public async Task Help_text_lists_environment_variable_names_for_options_that_have_them_defined() { @@ -304,51 +304,30 @@ namespace CliFx.Tests var console = new VirtualConsole(output: stdOut); var application = new CliApplicationBuilder() - .AddCommand(typeof(DefaultArgumentsCommand)) + .AddCommand(typeof(ArgumentsWithDefaultValuesCommand)) .UseConsole(console) .Build(); // Act await application.RunAsync(new[] { "cmd-with-defaults", "--help" }); - var stdOutData = console.Output.Encoding.GetString(stdOut.ToArray()).TrimEnd(); + var stdOutData = console.Output.Encoding.GetString(stdOut.ToArray()).TrimEnd(); // Assert stdOutData.Should().ContainAll( "Usage", "cmd-with-defaults", "[options]", "Options", - "--Object", "(Default: 42)", - "--String", "(Default: foo)", - "--EmptyString", "(Default: \"\"", - "--WhiteSpaceString", "(Default: \" \"", - "--Bool", "(Default: True)", - "--Char", "(Default: t)", - "--Sbyte", "(Default: -3)", - "--Byte", "(Default: 3)", - "--Short", "(Default: -1234)", - "--Ushort", "(Default: 1234)", - "--Int", "(Default: 1337)", - "--Uint", "(Default: 2345)", - "--Long", "(Default: -1234567)", - "--Ulong", "(Default: 12345678)", - "--Float", "(Default: 123.4567)", - "--Double", "(Default: 420.1337)", - "--Decimal", "(Default: 1337.420)", - "--DateTime", $"(Default: {new DateTime(2020, 4, 20)}", - "--DateTimeOffset", $"(Default: {new DateTimeOffset(2008, 5, 1, 0, 0, 0, new TimeSpan(0, 1, 0, 0, 0))}", - "--TimeSpan", "(Default: 02:03:00)", - "--IntNullable", "(Default: 1337)", - "--CustomEnumNullable", "(Default: Value2)", - "--TimeSpanNullable", "(Default: 03:54:00)", - "--ObjectArray", "(Default: 123 4 3.14)", - "--StringArray", "(Default: foo bar baz)", - "--IntArray", "(Default: 1 2 3)", - "--CustomEnumArray", "(Default: Value1 Value3)", - "--IntNullableArray", "(Default: 2 3 4 5)", - "--EnumerableNullable", "(Default: foo foo foo)", - "--StringEnumerable", "(Default: bar bar bar)", - "--StringReadOnlyList", "(Default: foo bar baz)", - "--StringList", "(Default: foo bar baz)" + "--Object", "Default: \"42\"", + "--String", "Default: \"foo\"", + "--EmptyString", "Default: \"\"", + "--Bool", "Default: \"True\"", + "--Char", "Default: \"t\"", + "--Int", "Default: \"1337\"", + "--TimeSpan", "Default: \"02:03:00\"", + "--Enum", "Default: \"Value2\"", + "--IntNullable", "Default: \"1337\"", + "--StringArray", "Default: \"foo\" \"bar\" \"baz\"", + "--IntArray", "Default: \"1\" \"2\" \"3\"" ); _output.WriteLine(stdOutData); diff --git a/CliFx/CliApplication.cs b/CliFx/CliApplication.cs index 26ee3a5..9c09042 100644 --- a/CliFx/CliApplication.cs +++ b/CliFx/CliApplication.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Threading.Tasks; using CliFx.Domain; using CliFx.Exceptions; +using CliFx.Internal; namespace CliFx { @@ -33,7 +34,7 @@ namespace CliFx _console = console; _typeActivator = typeActivator; - _helpTextWriter = new HelpTextWriter(metadata, console); + _helpTextWriter = new HelpTextWriter(metadata, console, typeActivator); } private async ValueTask HandleDebugDirectiveAsync(CommandLineInput commandLineInput) @@ -42,8 +43,10 @@ namespace CliFx if (!isDebugMode) return null; + var processId = ProcessEx.GetCurrentProcessId(); + _console.WithForegroundColor(ConsoleColor.Green, () => - _console.Output.WriteLine($"Attach debugger to PID {Process.GetCurrentProcess().Id} to continue.")); + _console.Output.WriteLine($"Attach debugger to PID {processId} to continue.")); while (!Debugger.IsAttached) await Task.Delay(100); @@ -124,7 +127,7 @@ namespace CliFx // Get the command schema that matches the input or use a dummy default command as a fallback var commandSchema = applicationSchema.TryFindCommand(commandLineInput) ?? - CommandSchema.StubDefaultCommand; + CommandSchema.StubDefaultCommand.Schema; _helpTextWriter.Write(applicationSchema, commandSchema); @@ -143,30 +146,26 @@ namespace CliFx return 0; } - /// - /// Handle s differently from the rest because we want to - /// display it different based on whether we are showing the help text or not. - /// - private int HandleCliFxException(IReadOnlyList commandLineArguments, CliFxException cfe) + private int HandleCliFxException(IReadOnlyList commandLineArguments, CliFxException ex) { - var showHelp = cfe.ShowHelp; + var showHelp = ex.ShowHelp; - var errorMessage = cfe.HasMessage - ? cfe.Message - : cfe.ToString(); - - _console.WithForegroundColor(ConsoleColor.Red, () => _console.Error.WriteLine(errorMessage)); + var errorMessage = ex.HasMessage + ? ex.Message + : ex.ToString(); + + _console.WithForegroundColor(ConsoleColor.Red, () => _console.Error.WriteLine(errorMessage)); if (showHelp) { var applicationSchema = ApplicationSchema.Resolve(_configuration.CommandTypes); var commandLineInput = CommandLineInput.Parse(commandLineArguments); var commandSchema = applicationSchema.TryFindCommand(commandLineInput) ?? - CommandSchema.StubDefaultCommand; + CommandSchema.StubDefaultCommand.Schema; _helpTextWriter.Write(applicationSchema, commandSchema); } - return cfe.ExitCode; + return ex.ExitCode; } /// @@ -188,16 +187,15 @@ namespace CliFx HandleHelpOption(applicationSchema, commandLineInput) ?? await HandleCommandExecutionAsync(applicationSchema, commandLineInput, environmentVariables); } - catch (CliFxException cfe) + catch (CliFxException ex) { - // We want to catch exceptions in order to print errors and return correct exit codes. - // Doing this also gets rid of the annoying Windows troubleshooting dialog that shows up on unhandled exceptions. - var exitCode = HandleCliFxException(commandLineArguments, cfe); - return exitCode; + // Some exceptions may specify exit code or request help + return HandleCliFxException(commandLineArguments, ex); } catch (Exception ex) { - // For all other errors, we just write the entire thing to stderr. + // We want to catch all exceptions in order to print errors and return correct exit codes. + // Doing this also gets rid of the annoying Windows troubleshooting dialog that shows up on unhandled exceptions. _console.WithForegroundColor(ConsoleColor.Red, () => _console.Error.WriteLine(ex.ToString())); return ex.HResult; } diff --git a/CliFx/DelegateTypeActivator.cs b/CliFx/DelegateTypeActivator.cs index 72110cc..12eabd6 100644 --- a/CliFx/DelegateTypeActivator.cs +++ b/CliFx/DelegateTypeActivator.cs @@ -18,6 +18,6 @@ namespace CliFx /// public object CreateInstance(Type type) => - _func(type) ?? throw CliFxException.DelegateActivatorReceivedNull(type); + _func(type) ?? throw CliFxException.DelegateActivatorReturnedNull(type); } } \ No newline at end of file diff --git a/CliFx/Domain/CommandArgumentSchema.cs b/CliFx/Domain/CommandArgumentSchema.cs index f6ffb1b..3f22d1a 100644 --- a/CliFx/Domain/CommandArgumentSchema.cs +++ b/CliFx/Domain/CommandArgumentSchema.cs @@ -15,8 +15,6 @@ namespace CliFx.Domain public string? Description { get; } - public abstract string DisplayName { get; } - public bool IsScalar => TryGetEnumerableArgumentUnderlyingType() == null; protected CommandArgumentSchema(PropertyInfo property, string? description) @@ -51,17 +49,17 @@ namespace CliFx.Domain : null; // String-constructable - var stringConstructor = GetStringConstructor(targetType); + var stringConstructor = targetType.GetConstructor(new[] {typeof(string)}); if (stringConstructor != null) return stringConstructor.Invoke(new object[] {value!}); // String-parseable (with format provider) - var parseMethodWithFormatProvider = GetStaticParseMethodWithFormatProvider(targetType); + var parseMethodWithFormatProvider = targetType.GetStaticParseMethod(true); if (parseMethodWithFormatProvider != null) - return parseMethodWithFormatProvider.Invoke(null, new object[] {value!, ConversionFormatProvider}); + return parseMethodWithFormatProvider.Invoke(null, new object[] {value!, FormatProvider}); // String-parseable (without format provider) - var parseMethod = GetStaticParseMethod(targetType); + var parseMethod = targetType.GetStaticParseMethod(); if (parseMethod != null) return parseMethod.Invoke(null, new object[] {value!}); } @@ -117,11 +115,62 @@ namespace CliFx.Domain public void Inject(ICommand command, params string[] values) => Inject(command, (IReadOnlyList) values); + + public IReadOnlyList GetValidValues() + { + var result = new List(); + + // Some arguments may have this as null due to a hack that enables built-in options + // TODO fix this + if (Property == null) + return result; + + var underlyingType = + Property.PropertyType.GetNullableUnderlyingType() ?? Property.PropertyType; + + // Enum + if (underlyingType.IsEnum) + result.AddRange(Enum.GetNames(underlyingType)); + + return result; + } + + public string? TryGetDefaultValue(ICommand instance) + { + // Some arguments may have this as null due to a hack that enables built-in options + // TODO fix this + if (Property == null) + return null; + + var rawDefaultValue = Property.GetValue(instance); + + if (!(rawDefaultValue is string) && rawDefaultValue is IEnumerable rawDefaultValues) + { + var elementType = rawDefaultValues.GetType().GetEnumerableUnderlyingType() ?? typeof(object); + + return elementType.IsToStringOverriden() + ? rawDefaultValues + .Cast() + .Where(o => o != null) + .Select(o => o!.ToFormattableString(FormatProvider).Quote()) + .JoinToString(" ") + : null; + } + + if (rawDefaultValue != null && !Equals(rawDefaultValue, rawDefaultValue.GetType().GetDefaultValue())) + { + return rawDefaultValue.GetType().IsToStringOverriden() + ? rawDefaultValue.ToFormattableString(FormatProvider).Quote() + : null; + } + + return null; + } } internal partial class CommandArgumentSchema { - private static readonly IFormatProvider ConversionFormatProvider = CultureInfo.InvariantCulture; + private static readonly IFormatProvider FormatProvider = CultureInfo.InvariantCulture; private static readonly IReadOnlyDictionary> PrimitiveConverters = new Dictionary> @@ -130,112 +179,20 @@ namespace CliFx.Domain [typeof(string)] = v => v, [typeof(bool)] = v => string.IsNullOrWhiteSpace(v) || bool.Parse(v), [typeof(char)] = v => v.Single(), - [typeof(sbyte)] = v => sbyte.Parse(v, ConversionFormatProvider), - [typeof(byte)] = v => byte.Parse(v, ConversionFormatProvider), - [typeof(short)] = v => short.Parse(v, ConversionFormatProvider), - [typeof(ushort)] = v => ushort.Parse(v, ConversionFormatProvider), - [typeof(int)] = v => int.Parse(v, ConversionFormatProvider), - [typeof(uint)] = v => uint.Parse(v, ConversionFormatProvider), - [typeof(long)] = v => long.Parse(v, ConversionFormatProvider), - [typeof(ulong)] = v => ulong.Parse(v, ConversionFormatProvider), - [typeof(float)] = v => float.Parse(v, ConversionFormatProvider), - [typeof(double)] = v => double.Parse(v, ConversionFormatProvider), - [typeof(decimal)] = v => decimal.Parse(v, ConversionFormatProvider), - [typeof(DateTime)] = v => DateTime.Parse(v, ConversionFormatProvider), - [typeof(DateTimeOffset)] = v => DateTimeOffset.Parse(v, ConversionFormatProvider), - [typeof(TimeSpan)] = v => TimeSpan.Parse(v, ConversionFormatProvider), + [typeof(sbyte)] = v => sbyte.Parse(v, FormatProvider), + [typeof(byte)] = v => byte.Parse(v, FormatProvider), + [typeof(short)] = v => short.Parse(v, FormatProvider), + [typeof(ushort)] = v => ushort.Parse(v, FormatProvider), + [typeof(int)] = v => int.Parse(v, FormatProvider), + [typeof(uint)] = v => uint.Parse(v, FormatProvider), + [typeof(long)] = v => long.Parse(v, FormatProvider), + [typeof(ulong)] = v => ulong.Parse(v, FormatProvider), + [typeof(float)] = v => float.Parse(v, FormatProvider), + [typeof(double)] = v => double.Parse(v, FormatProvider), + [typeof(decimal)] = v => decimal.Parse(v, FormatProvider), + [typeof(DateTime)] = v => DateTime.Parse(v, FormatProvider), + [typeof(DateTimeOffset)] = v => DateTimeOffset.Parse(v, FormatProvider), + [typeof(TimeSpan)] = v => TimeSpan.Parse(v, FormatProvider), }; - - private static ConstructorInfo? GetStringConstructor(Type type) => - type.GetConstructor(new[] {typeof(string)}); - - private static MethodInfo? GetStaticParseMethod(Type type) => - type.GetMethod("Parse", - BindingFlags.Public | BindingFlags.Static, - null, new[] {typeof(string)}, null); - - private static MethodInfo? GetStaticParseMethodWithFormatProvider(Type type) => - type.GetMethod("Parse", - BindingFlags.Public | BindingFlags.Static, - null, new[] {typeof(string), typeof(IFormatProvider)}, null); - } - - // Default and valid value handling. - internal partial class CommandArgumentSchema - { - /// - /// Retrieves the valid values of this command argument. - /// - /// A string collection of this command's valid values. - public IReadOnlyList GetValidValues() - { - var result = new List(); - - // Some arguments may have this as null due to a hack that enables built-in options - if (Property == null) - return result; - - var underlyingPropertyType = - Property.PropertyType.GetNullableUnderlyingType() ?? Property.PropertyType; - - // Enum - if (underlyingPropertyType.IsEnum) - result.AddRange(Enum.GetNames(underlyingPropertyType)); - - return result; - } - - /// - /// Gets the default value of this command argument. - /// Returns null if there's no default value. - /// - /// A dummy instance of the command - /// this command argument belongs to. - /// The string representation of the default value. - /// If there's no default value, it returns null. - /// - /// We need a dummy instance in order to implement this because - /// we cannot retrieve it from a PropertyInfo. - /// - public string? GetDefaultValue(ICommand? instance) - { - if (Property is null || instance is null) - { - return null; - } - - var propertyName = Property?.Name; - string? defaultValue = null; - // Get the current culture so that the default value string - // matches the user's culture for cultured information like - // DateTimes and TimeSpans. - var culture = CultureInfo.CurrentCulture; - - if (!string.IsNullOrWhiteSpace(propertyName)) - { - var instanceProperty = instance.GetType().GetProperty(propertyName); - var value = instanceProperty.GetValue(instance); - - if (value.OverridesToStringMethod()) - { - // Wrap empty or whitespace strings in quotes so that they're not - // just an ugly blank in the output. - defaultValue = value.ToCulturedString(culture) - .WrapWithQuotesIfEmptyOrWhiteSpace(); - } - else if (value is IEnumerable values) - { - // Cast 'values' to IEnumerable so we can use LINQ on it. - defaultValue = - string.Join(" ", - values.Cast() - .Where(v => v != null) - .Select(v => v.ToCulturedString(culture) - .WrapWithQuotesIfEmptyOrWhiteSpace())); - } - } - - return defaultValue; - } } } \ No newline at end of file diff --git a/CliFx/Domain/CommandDirectiveInput.cs b/CliFx/Domain/CommandDirectiveInput.cs index 4245ac0..31b8172 100644 --- a/CliFx/Domain/CommandDirectiveInput.cs +++ b/CliFx/Domain/CommandDirectiveInput.cs @@ -10,10 +10,7 @@ namespace CliFx.Domain public bool IsPreviewDirective => string.Equals(Name, "preview", StringComparison.OrdinalIgnoreCase); - public CommandDirectiveInput(string name) - { - Name = name; - } + public CommandDirectiveInput(string name) => Name = name; public override string ToString() => $"[{Name}]"; } diff --git a/CliFx/Domain/CommandOptionInput.cs b/CliFx/Domain/CommandOptionInput.cs index dfc3198..8ced133 100644 --- a/CliFx/Domain/CommandOptionInput.cs +++ b/CliFx/Domain/CommandOptionInput.cs @@ -8,10 +8,9 @@ namespace CliFx.Domain { public string Alias { get; } - public string DisplayAlias => - Alias.Length > 1 - ? $"--{Alias}" - : $"-{Alias}"; + public string RawAlias => Alias.Length > 1 + ? $"--{Alias}" + : $"-{Alias}"; public IReadOnlyList Values { get; } @@ -29,7 +28,7 @@ namespace CliFx.Domain { var buffer = new StringBuilder(); - buffer.Append(DisplayAlias); + buffer.Append(RawAlias); foreach (var value in Values) { diff --git a/CliFx/Domain/CommandOptionSchema.cs b/CliFx/Domain/CommandOptionSchema.cs index cd9ca89..4227580 100644 --- a/CliFx/Domain/CommandOptionSchema.cs +++ b/CliFx/Domain/CommandOptionSchema.cs @@ -12,10 +12,6 @@ namespace CliFx.Domain public char? ShortName { get; } - public override string DisplayName => !string.IsNullOrWhiteSpace(Name) - ? $"--{Name}" - : $"-{ShortName}"; - public string? EnvironmentVariableName { get; } public bool IsRequired { get; } @@ -51,27 +47,35 @@ namespace CliFx.Domain !string.IsNullOrWhiteSpace(EnvironmentVariableName) && string.Equals(EnvironmentVariableName, environmentVariableName, StringComparison.OrdinalIgnoreCase); - public override string ToString() + public string GetUserFacingDisplayString() { var buffer = new StringBuilder(); if (!string.IsNullOrWhiteSpace(Name)) { - buffer.Append("--"); - buffer.Append(Name); + buffer + .Append("--") + .Append(Name); } if (!string.IsNullOrWhiteSpace(Name) && ShortName != null) + { buffer.Append('|'); + } if (ShortName != null) { - buffer.Append('-'); - buffer.Append(ShortName); + buffer + .Append('-') + .Append(ShortName); } return buffer.ToString(); } + + public string GetInternalDisplayString() => $"{Property.Name} ('{GetUserFacingDisplayString()}')"; + + public override string ToString() => GetInternalDisplayString(); } internal partial class CommandOptionSchema diff --git a/CliFx/Domain/CommandParameterSchema.cs b/CliFx/Domain/CommandParameterSchema.cs index 00b309d..668573f 100644 --- a/CliFx/Domain/CommandParameterSchema.cs +++ b/CliFx/Domain/CommandParameterSchema.cs @@ -8,31 +8,30 @@ namespace CliFx.Domain { public int Order { get; } - public string? Name { get; } + public string Name { get; } - public override string DisplayName => - !string.IsNullOrWhiteSpace(Name) - ? Name - : Property.Name.ToLowerInvariant(); - - public CommandParameterSchema(PropertyInfo property, int order, string? name, string? description) + public CommandParameterSchema(PropertyInfo property, int order, string name, string? description) : base(property, description) { Order = order; Name = name; } - public override string ToString() + public string GetUserFacingDisplayString() { var buffer = new StringBuilder(); buffer .Append('<') - .Append(DisplayName) + .Append(Name) .Append('>'); return buffer.ToString(); } + + public string GetInternalDisplayString() => $"{Property.Name} ([{Order}] {GetUserFacingDisplayString()})"; + + public override string ToString() => GetInternalDisplayString(); } internal partial class CommandParameterSchema @@ -43,10 +42,12 @@ namespace CliFx.Domain if (attribute == null) return null; + var name = attribute.Name ?? property.Name.ToLowerInvariant(); + return new CommandParameterSchema( property, attribute.Order, - attribute.Name, + name, attribute.Description ); } diff --git a/CliFx/Domain/CommandSchema.cs b/CliFx/Domain/CommandSchema.cs index 825e40b..3d75660 100644 --- a/CliFx/Domain/CommandSchema.cs +++ b/CliFx/Domain/CommandSchema.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; -using System.Text; +using System.Threading.Tasks; using CliFx.Attributes; using CliFx.Exceptions; using CliFx.Internal; @@ -173,27 +173,11 @@ namespace CliFx.Domain return command; } - public override string ToString() - { - var buffer = new StringBuilder(); + public string GetUserFacingDisplayString() => Name ?? ""; - if (!string.IsNullOrWhiteSpace(Name)) - buffer.Append(Name); + public string GetInternalDisplayString() => $"{Type.FullName} ('{GetUserFacingDisplayString()}')"; - foreach (var parameter in Parameters) - { - buffer.AppendIfNotEmpty(' '); - buffer.Append(parameter); - } - - foreach (var option in Options) - { - buffer.AppendIfNotEmpty(' '); - buffer.Append(option); - } - - return buffer.ToString(); - } + public override string ToString() => GetInternalDisplayString(); } internal partial class CommandSchema @@ -233,7 +217,13 @@ namespace CliFx.Domain internal partial class CommandSchema { - public static CommandSchema StubDefaultCommand { get; } = - new CommandSchema(null!, null, null, new CommandParameterSchema[0], new CommandOptionSchema[0]); + // TODO: won't work with dep injection + [Command] + public class StubDefaultCommand : ICommand + { + public ValueTask ExecuteAsync(IConsole console) => default; + + public static CommandSchema Schema { get; } = TryResolve(typeof(StubDefaultCommand))!; + } } } \ No newline at end of file diff --git a/CliFx/Domain/CommandUnboundArgumentInput.cs b/CliFx/Domain/CommandUnboundArgumentInput.cs index deed700..a05bef8 100644 --- a/CliFx/Domain/CommandUnboundArgumentInput.cs +++ b/CliFx/Domain/CommandUnboundArgumentInput.cs @@ -4,10 +4,7 @@ { public string Value { get; } - public CommandUnboundArgumentInput(string value) - { - Value = value; - } + public CommandUnboundArgumentInput(string value) => Value = value; public override string ToString() => Value; } diff --git a/CliFx/Domain/HelpTextWriter.cs b/CliFx/Domain/HelpTextWriter.cs index 864ee8e..cb660dd 100644 --- a/CliFx/Domain/HelpTextWriter.cs +++ b/CliFx/Domain/HelpTextWriter.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using CliFx.Internal; @@ -8,353 +9,342 @@ namespace CliFx.Domain { private readonly ApplicationMetadata _metadata; private readonly IConsole _console; + private readonly ITypeActivator _typeActivator; - public HelpTextWriter(ApplicationMetadata metadata, IConsole console) + private int _column; + private int _row; + + private bool IsEmpty => _column == 0 && _row == 0; + + public HelpTextWriter(ApplicationMetadata metadata, IConsole console, ITypeActivator typeActivator) { _metadata = metadata; _console = console; + _typeActivator = typeActivator; } - public void Write(ApplicationSchema applicationSchema, CommandSchema command) + private void Write(char value) { - var column = 0; - var row = 0; + _console.Output.Write(value); + _column++; + } - var childCommands = applicationSchema.GetChildCommands(command.Name); + private void Write(string value) + { + _console.Output.Write(value); + _column += value.Length; + } - bool IsEmpty() => column == 0 && row == 0; + private void Write(ConsoleColor foregroundColor, string value) + { + _console.WithForegroundColor(foregroundColor, () => Write(value)); + } - void Render(string text) + private void WriteLine() + { + _console.Output.WriteLine(); + _column = 0; + _row++; + } + + private void WriteVerticalMargin(int size = 1) + { + if (IsEmpty) + return; + + for (var i = 0; i < size; i++) + WriteLine(); + } + + private void WriteHorizontalMargin(int size = 2) + { + if (IsEmpty) + return; + + for (var i = 0; i < size; i++) + Write(' '); + } + + private void WriteHorizontalColumnMargin(int columnSize = 20, int offsetSize = 2) + { + if (_column + offsetSize < columnSize) + WriteHorizontalMargin(columnSize - _column); + else + WriteHorizontalMargin(offsetSize); + } + + private void WriteHeader(string text) + { + Write(ConsoleColor.Magenta, text); + WriteLine(); + } + + private void WriteApplicationInfo(CommandSchema commandSchema) + { + if (!commandSchema.IsDefault) + return; + + // Title and version + Write(ConsoleColor.Yellow, _metadata.Title); + Write(' '); + Write(ConsoleColor.Yellow, _metadata.VersionText); + WriteLine(); + + // Description + if (!string.IsNullOrWhiteSpace(_metadata.Description)) { - _console.Output.Write(text); + WriteHorizontalMargin(); + Write(_metadata.Description); + WriteLine(); + } + } - column += text.Length; + private void WriteCommandDescription(CommandSchema commandSchema) + { + if (string.IsNullOrWhiteSpace(commandSchema.Description)) + return; + + WriteVerticalMargin(); + WriteHeader("Description"); + + WriteHorizontalMargin(); + Write(commandSchema.Description); + WriteLine(); + } + + private void WriteCommandUsage( + CommandSchema commandSchema, + IReadOnlyList childCommandSchemas) + { + WriteVerticalMargin(); + WriteHeader("Usage"); + + // Exe name + WriteHorizontalMargin(); + Write(_metadata.ExecutableName); + + // Command name + if (!string.IsNullOrWhiteSpace(commandSchema.Name)) + { + Write(' '); + Write(ConsoleColor.Cyan, commandSchema.Name); } - void RenderNewLine() + // Child command placeholder + if (childCommandSchemas.Any()) { - _console.Output.WriteLine(); - - column = 0; - row++; + Write(' '); + Write(ConsoleColor.Cyan, "[command]"); } - void RenderMargin(int lines = 1) + // Parameters + foreach (var parameterSchema in commandSchema.Parameters) { - if (!IsEmpty()) + Write(' '); + Write(parameterSchema.IsScalar + ? $"<{parameterSchema.Name}>" + : $"<{parameterSchema.Name}...>" + ); + } + + // Required options + foreach (var optionSchema in commandSchema.Options.Where(o => o.IsRequired)) + { + Write(' '); + Write(ConsoleColor.White, !string.IsNullOrWhiteSpace(optionSchema.Name) + ? $"--{optionSchema.Name}" + : $"-{optionSchema.ShortName}" + ); + + Write(' '); + Write(optionSchema.IsScalar + ? "" + : "" + ); + } + + // Options placeholder + Write(' '); + Write(ConsoleColor.White, "[options]"); + + WriteLine(); + } + + private void WriteCommandParameters(CommandSchema commandSchema) + { + if (!commandSchema.Parameters.Any()) + return; + + WriteVerticalMargin(); + WriteHeader("Parameters"); + + foreach (var parameterSchema in commandSchema.Parameters.OrderBy(p => p.Order)) + { + Write(ConsoleColor.Red, "* "); + Write(ConsoleColor.White, $"{parameterSchema.Name}"); + + WriteHorizontalColumnMargin(); + + // Description + if (!string.IsNullOrWhiteSpace(parameterSchema.Description)) { - for (var i = 0; i < lines; i++) - RenderNewLine(); + Write(parameterSchema.Description); + Write(' '); } - } - void RenderIndent(int spaces = 2) - { - Render(' '.Repeat(spaces)); - } - - void RenderColumnIndent(int spaces = 20, int margin = 2) - { - if (column + margin < spaces) + // Valid values + var validValues = parameterSchema.GetValidValues(); + if (validValues.Any()) { - RenderIndent(spaces - column); + Write($"Valid values: {string.Join(", ", validValues)}."); + } + + WriteLine(); + } + } + + private void WriteCommandOptions(CommandSchema commandSchema, ICommand command) + { + WriteVerticalMargin(); + WriteHeader("Options"); + + var actualOptionSchemas = commandSchema.Options + .OrderByDescending(o => o.IsRequired) + .Concat(commandSchema.GetBuiltInOptions()); + + foreach (var optionSchema in actualOptionSchemas) + { + if (optionSchema.IsRequired) + { + Write(ConsoleColor.Red, "* "); } else { - RenderIndent(margin); + WriteHorizontalMargin(); } - } - void RenderWithColor(string text, ConsoleColor foregroundColor) - { - _console.WithForegroundColor(foregroundColor, () => Render(text)); - } + // Short name + if (optionSchema.ShortName != null) + { + Write(ConsoleColor.White, $"-{optionSchema.ShortName}"); + } - void RenderHeader(string text) - { - RenderWithColor(text, ConsoleColor.Magenta); - RenderNewLine(); - } + // Delimiter + if (!string.IsNullOrWhiteSpace(optionSchema.Name) && optionSchema.ShortName != null) + { + Write('|'); + } - void RenderApplicationInfo() - { - if (!command.IsDefault) - return; + // Name + if (!string.IsNullOrWhiteSpace(optionSchema.Name)) + { + Write(ConsoleColor.White, $"--{optionSchema.Name}"); + } - // Title and version - RenderWithColor(_metadata.Title, ConsoleColor.Yellow); - Render(" "); - RenderWithColor(_metadata.VersionText, ConsoleColor.Yellow); - RenderNewLine(); + WriteHorizontalColumnMargin(); // Description - if (!string.IsNullOrWhiteSpace(_metadata.Description)) + if (!string.IsNullOrWhiteSpace(optionSchema.Description)) { - Render(_metadata.Description); - RenderNewLine(); + Write(optionSchema.Description); + Write(' '); } - } - void RenderDescription() + // Valid values + var validValues = optionSchema.GetValidValues(); + if (validValues.Any()) + { + Write($"Valid values: {validValues.Select(v => v.Quote()).JoinToString(", ")}."); + Write(' '); + } + + // Environment variable + if (!string.IsNullOrWhiteSpace(optionSchema.EnvironmentVariableName)) + { + Write($"Environment variable: \"{optionSchema.EnvironmentVariableName}\"."); + Write(' '); + } + + // Default value + if (!optionSchema.IsRequired) + { + // TODO: move quoting logic here? + var defaultValue = optionSchema.TryGetDefaultValue(command); + if (defaultValue != null) + { + Write($"Default: {defaultValue}."); + } + } + + WriteLine(); + } + } + + private void WriteCommandChildren( + CommandSchema commandSchema, + IReadOnlyList childCommandSchemas) + { + if (!childCommandSchemas.Any()) + return; + + WriteVerticalMargin(); + WriteHeader("Commands"); + + foreach (var childCommandSchema in childCommandSchemas) { - if (string.IsNullOrWhiteSpace(command.Description)) - return; + var relativeCommandName = !string.IsNullOrWhiteSpace(commandSchema.Name) + ? childCommandSchema.Name!.Substring(commandSchema.Name.Length + 1) + : childCommandSchema.Name!; - RenderMargin(); - RenderHeader("Description"); + // Name + WriteHorizontalMargin(); + Write(ConsoleColor.Cyan, relativeCommandName); - RenderIndent(); - Render(command.Description); - RenderNewLine(); + // Description + if (!string.IsNullOrWhiteSpace(childCommandSchema.Description)) + { + WriteHorizontalColumnMargin(); + Write(childCommandSchema.Description); + } + + WriteLine(); } - void RenderUsage() + // Child command help tip + WriteVerticalMargin(); + Write("You can run `"); + Write(_metadata.ExecutableName); + + if (!string.IsNullOrWhiteSpace(commandSchema.Name)) { - RenderMargin(); - RenderHeader("Usage"); - - // Exe name - RenderIndent(); - Render(_metadata.ExecutableName); - - // Command name - if (!string.IsNullOrWhiteSpace(command.Name)) - { - Render(" "); - RenderWithColor(command.Name, ConsoleColor.Cyan); - } - - // Child command placeholder - if (childCommands.Any()) - { - Render(" "); - RenderWithColor("[command]", ConsoleColor.Cyan); - } - - // Parameters - foreach (var parameter in command.Parameters) - { - Render(" "); - Render(parameter.IsScalar - ? $"<{parameter.DisplayName}>" - : $"<{parameter.DisplayName}...>"); - } - - // Required options - var requiredOptionSchemas = command.Options - .Where(o => o.IsRequired) - .ToArray(); - - foreach (var option in requiredOptionSchemas) - { - Render(" "); - if (!string.IsNullOrWhiteSpace(option.Name)) - { - RenderWithColor($"--{option.Name}", ConsoleColor.White); - Render(" "); - Render(option.IsScalar - ? "" - : ""); - } - else - { - RenderWithColor($"-{option.ShortName}", ConsoleColor.White); - Render(" "); - Render(option.IsScalar - ? "" - : ""); - } - } - - // Options placeholder - if (command.Options.Count != requiredOptionSchemas.Length) - { - Render(" "); - RenderWithColor("[options]", ConsoleColor.White); - } - - RenderNewLine(); + Write(' '); + Write(ConsoleColor.Cyan, commandSchema.Name); } - void RenderParameters() - { - if (!command.Parameters.Any()) - return; + Write(' '); + Write(ConsoleColor.Cyan, "[command]"); - RenderMargin(); - RenderHeader("Parameters"); + Write(' '); + Write(ConsoleColor.White, "--help"); - var parameters = command.Parameters - .OrderBy(p => p.Order) - .ToArray(); + Write("` to show help on a specific command."); - foreach (var parameter in parameters) - { - RenderWithColor("* ", ConsoleColor.Red); - RenderWithColor($"{parameter.DisplayName}", ConsoleColor.White); + WriteLine(); + } - RenderColumnIndent(); - - // Description - if (!string.IsNullOrWhiteSpace(parameter.Description)) - { - Render(parameter.Description); - Render(" "); - } - - // Valid values - var validValues = parameter.GetValidValues(); - if (validValues.Any()) - { - Render($"Valid values: {string.Join(", ", validValues)}."); - Render(" "); - } - - RenderNewLine(); - } - } - - void RenderOptions() - { - RenderMargin(); - RenderHeader("Options"); - - // Instantiate a temporary instance of the command so we can get default values from it. - ICommand? tempInstance = command.Type is null ? null : Activator.CreateInstance(command.Type) as ICommand; - - var options = command.Options - .OrderByDescending(o => o.IsRequired) - .Concat(command.GetBuiltInOptions()) - .ToArray(); - - foreach (var option in options) - { - if (option.IsRequired) - { - RenderWithColor("* ", ConsoleColor.Red); - } - else - { - RenderIndent(); - } - - // Short name - if (option.ShortName != null) - { - RenderWithColor($"-{option.ShortName}", ConsoleColor.White); - } - - // Delimiter - if (!string.IsNullOrWhiteSpace(option.Name) && option.ShortName != null) - { - Render("|"); - } - - // Name - if (!string.IsNullOrWhiteSpace(option.Name)) - { - RenderWithColor($"--{option.Name}", ConsoleColor.White); - } - - RenderColumnIndent(); - - // Description - if (!string.IsNullOrWhiteSpace(option.Description)) - { - Render(option.Description); - Render(" "); - } - - // Valid values - var validValues = option.GetValidValues(); - if (validValues.Any()) - { - Render($"Valid values: {string.Join(", ", validValues)}."); - Render(" "); - } - - // Environment variable - if (!string.IsNullOrWhiteSpace(option.EnvironmentVariableName)) - { - Render($"(Environment variable: {option.EnvironmentVariableName})"); - Render(" "); - } - - // Default value - if (!option.IsRequired) - { - var defaultValue = option.GetDefaultValue(tempInstance); - // If 'defaultValue' is null, it means there's no default value. - if (defaultValue is object) - { - Render($"(Default: {defaultValue})"); - Render(" "); - } - } - - RenderNewLine(); - } - } - - void RenderChildCommands() - { - if (!childCommands.Any()) - return; - - RenderMargin(); - RenderHeader("Commands"); - - foreach (var childCommand in childCommands) - { - var relativeCommandName = - !string.IsNullOrWhiteSpace(command.Name) - ? childCommand.Name!.Substring(command.Name.Length + 1) - : childCommand.Name!; - - // Name - RenderIndent(); - RenderWithColor(relativeCommandName, ConsoleColor.Cyan); - - // Description - if (!string.IsNullOrWhiteSpace(childCommand.Description)) - { - RenderColumnIndent(); - Render(childCommand.Description); - } - - RenderNewLine(); - } - - RenderMargin(); - - // Child command help tip - Render("You can run `"); - Render(_metadata.ExecutableName); - - if (!string.IsNullOrWhiteSpace(command.Name)) - { - Render(" "); - RenderWithColor(command.Name, ConsoleColor.Cyan); - } - - Render(" "); - RenderWithColor("[command]", ConsoleColor.Cyan); - - Render(" "); - RenderWithColor("--help", ConsoleColor.White); - - Render("` to show help on a specific command."); - - RenderNewLine(); - } + public void Write(ApplicationSchema applicationSchema, CommandSchema commandSchema) + { + var childCommandSchemas = applicationSchema.GetChildCommands(commandSchema.Name); + var command = (ICommand) _typeActivator.CreateInstance(commandSchema.Type); _console.ResetColor(); - RenderApplicationInfo(); - RenderDescription(); - RenderUsage(); - RenderParameters(); - RenderOptions(); - RenderChildCommands(); + + WriteApplicationInfo(commandSchema); + WriteCommandDescription(commandSchema); + WriteCommandUsage(commandSchema, childCommandSchemas); + WriteCommandParameters(commandSchema); + WriteCommandOptions(commandSchema, command); + WriteCommandChildren(commandSchema, childCommandSchemas); } } } \ No newline at end of file diff --git a/CliFx/Exceptions/CliFxException.cs b/CliFx/Exceptions/CliFxException.cs index a3e5970..f770fce 100644 --- a/CliFx/Exceptions/CliFxException.cs +++ b/CliFx/Exceptions/CliFxException.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using CliFx.Attributes; using CliFx.Domain; +using CliFx.Internal; namespace CliFx.Exceptions { @@ -35,14 +36,6 @@ namespace CliFx.Exceptions /// public int ExitCode { get; } - /// - /// Initializes an instance of . - /// - public CliFxException(string? message, bool showHelp = false) - : this(message, null, showHelp: showHelp) - { - } - /// /// Initializes an instance of . /// @@ -52,9 +45,18 @@ namespace CliFx.Exceptions ExitCode = exitCode != 0 ? exitCode : throw new ArgumentException("Exit code must not be zero in order to signify failure."); + HasMessage = !string.IsNullOrWhiteSpace(message); ShowHelp = showHelp; } + + /// + /// Initializes an instance of . + /// + public CliFxException(string? message, bool showHelp = false) + : this(message, null, showHelp: showHelp) + { + } } // Mid-user-facing exceptions @@ -75,7 +77,7 @@ Refer to the readme to learn how to integrate a dependency container of your cho return new CliFxException(message.Trim(), innerException); } - internal static CliFxException DelegateActivatorReceivedNull(Type type) + internal static CliFxException DelegateActivatorReturnedNull(Type type) { var message = $@" Failed to create an instance of type '{type.FullName}', received instead. @@ -112,12 +114,11 @@ If you're experiencing problems, please refer to the readme for a quickstart exa return new CliFxException(message.Trim()); } - internal static CliFxException CommandsTooManyDefaults( - IReadOnlyList invalidCommands) + internal static CliFxException CommandsTooManyDefaults(IReadOnlyList invalidCommandSchemas) { var message = $@" -Application configuration is invalid because there are {invalidCommands.Count} default commands: -{string.Join(Environment.NewLine, invalidCommands.Select(p => p.Type.FullName))} +Application configuration is invalid because there are {invalidCommandSchemas.Count} default commands: +{invalidCommandSchemas.JoinToString(Environment.NewLine)} 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."; @@ -127,11 +128,11 @@ Other commands must have unique non-empty names that identify them."; internal static CliFxException CommandsDuplicateName( string name, - IReadOnlyList invalidCommands) + IReadOnlyList invalidCommandSchemas) { 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))} +Application configuration is invalid because there are {invalidCommandSchemas.Count} commands with the same name ('{name}'): +{invalidCommandSchemas.JoinToString(Environment.NewLine)} Commands must have unique names. Names are not case-sensitive."; @@ -140,13 +141,13 @@ Names are not case-sensitive."; } internal static CliFxException CommandParametersDuplicateOrder( - CommandSchema command, + CommandSchema commandSchema, int order, - IReadOnlyList invalidParameters) + IReadOnlyList invalidParameterSchemas) { 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))} +Command '{commandSchema.Type.FullName}' is invalid because it contains {invalidParameterSchemas.Count} parameters with the same order ({order}): +{invalidParameterSchemas.JoinToString(Environment.NewLine)} Parameters must have unique order."; @@ -154,13 +155,13 @@ Parameters must have unique order."; } internal static CliFxException CommandParametersDuplicateName( - CommandSchema command, + CommandSchema commandSchema, string name, - IReadOnlyList invalidParameters) + IReadOnlyList invalidParameterSchemas) { 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))} +Command '{commandSchema.Type.FullName}' is invalid because it contains {invalidParameterSchemas.Count} parameters with the same name ('{name}'): +{invalidParameterSchemas.JoinToString(Environment.NewLine)} Parameters must have unique names to avoid potential confusion in the help text. Names are not case-sensitive."; @@ -169,12 +170,12 @@ Names are not case-sensitive."; } internal static CliFxException CommandParametersTooManyNonScalar( - CommandSchema command, - IReadOnlyList invalidParameters) + CommandSchema commandSchema, + IReadOnlyList invalidParameterSchemas) { 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))} +Command '{commandSchema.Type.FullName}' is invalid because it contains {invalidParameterSchemas.Count} non-scalar parameters: +{invalidParameterSchemas.JoinToString(Environment.NewLine)} 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. @@ -185,12 +186,12 @@ If it's not feasible to fit into these constraints, consider using options inste } internal static CliFxException CommandParametersNonLastNonScalar( - CommandSchema command, - CommandParameterSchema invalidParameter) + CommandSchema commandSchema, + CommandParameterSchema invalidParameterSchema) { 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} +Command '{commandSchema.Type.FullName}' is invalid because it contains a non-scalar parameter which is not the last in order: +{invalidParameterSchema} 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. @@ -201,12 +202,12 @@ If it's not feasible to fit into these constraints, consider using options inste } internal static CliFxException CommandOptionsNoName( - CommandSchema command, - IReadOnlyList invalidOptions) + CommandSchema commandSchema, + IReadOnlyList invalidOptionSchemas) { var message = $@" -Command '{command.Type.FullName}' is invalid because it contains one or more options without a name: -{string.Join(Environment.NewLine, invalidOptions.Select(o => o.Property.Name))} +Command '{commandSchema.Type.FullName}' is invalid because it contains one or more options without a name: +{invalidOptionSchemas.JoinToString(Environment.NewLine)} Options must have either a name or a short name or both."; @@ -214,12 +215,12 @@ Options must have either a name or a short name or both."; } internal static CliFxException CommandOptionsInvalidLengthName( - CommandSchema command, - IReadOnlyList invalidOptions) + CommandSchema commandSchema, + IReadOnlyList invalidOptionSchemas) { 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(o => $"{o.Property.Name} ('{o.DisplayName}')"))} +Command '{commandSchema.Type.FullName}' is invalid because it contains one or more options whose names are too short: +{invalidOptionSchemas.JoinToString(Environment.NewLine)} 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 attribute overload that accepts a char."; @@ -228,30 +229,28 @@ If you intended to set the short name instead, use the attribute overload that a } internal static CliFxException CommandOptionsDuplicateName( - CommandSchema command, + CommandSchema commandSchema, string name, - IReadOnlyList invalidOptions) + IReadOnlyList invalidOptionSchemas) { 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(o => o.Property.Name))} +Command '{commandSchema.Type.FullName}' is invalid because it contains {invalidOptionSchemas.Count} options with the same name ('{name}'): +{invalidOptionSchemas.JoinToString(Environment.NewLine)} -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."; +Options must have unique names. +Names are not case-sensitive."; return new CliFxException(message.Trim()); } internal static CliFxException CommandOptionsDuplicateShortName( - CommandSchema command, + CommandSchema commandSchema, char shortName, - IReadOnlyList invalidOptions) + IReadOnlyList invalidOptionSchemas) { 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(o => o.Property.Name))} +Command '{commandSchema.Type.FullName}' is invalid because it contains {invalidOptionSchemas.Count} options with the same short name ('{shortName}'): +{invalidOptionSchemas.JoinToString(Environment.NewLine)} Options must have unique short names. Short names are case-sensitive (i.e. 'a' and 'A' are different short names)."; @@ -260,13 +259,13 @@ Short names are case-sensitive (i.e. 'a' and 'A' are different short names)."; } internal static CliFxException CommandOptionsDuplicateEnvironmentVariableName( - CommandSchema command, + CommandSchema commandSchema, string environmentVariableName, - IReadOnlyList invalidOptions) + IReadOnlyList invalidOptionSchemas) { 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(o => o.Property.Name))} +Command '{commandSchema.Type.FullName}' is invalid because it contains {invalidOptionSchemas.Count} options with the same fallback environment variable name ('{environmentVariableName}'): +{invalidOptionSchemas.JoinToString(Environment.NewLine)} Options cannot share the same environment variable as a fallback. Environment variable names are not case-sensitive."; @@ -283,92 +282,148 @@ Environment variable names are not case-sensitive."; { var message = $@" Can't find a command that matches the following arguments: -{string.Join(" ", input.UnboundArguments.Select(a => a.Value))}"; +{input.UnboundArguments.JoinToString(" ")}"; return new CliFxException(message.Trim(), showHelp: true); } internal static CliFxException CannotConvertMultipleValuesToNonScalar( - CommandArgumentSchema argument, + CommandParameterSchema parameterSchema, IReadOnlyList 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}'"))}"; +Parameter {parameterSchema.GetUserFacingDisplayString()} expects a single value, but provided with multiple: +{values.Select(v => v.Quote()).JoinToString(" ")}"; return new CliFxException(message.Trim(), showHelp: true); } + internal static CliFxException CannotConvertMultipleValuesToNonScalar( + CommandOptionSchema optionSchema, + IReadOnlyList values) + { + var message = $@" +Option {optionSchema.GetUserFacingDisplayString()} expects a single value, but provided with multiple: +{values.Select(v => v.Quote()).JoinToString(" ")}"; + + return new CliFxException(message.Trim(), showHelp: true); + } + + internal static CliFxException CannotConvertMultipleValuesToNonScalar( + CommandArgumentSchema argumentSchema, + IReadOnlyList values) => argumentSchema switch + { + CommandParameterSchema parameterSchema => CannotConvertMultipleValuesToNonScalar(parameterSchema, values), + CommandOptionSchema optionSchema => CannotConvertMultipleValuesToNonScalar(optionSchema, values), + _ => throw new ArgumentOutOfRangeException(nameof(argumentSchema)) + }; + internal static CliFxException CannotConvertToType( - CommandArgumentSchema argument, + CommandParameterSchema parameterSchema, 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 ?? ""}' to type '{type.FullName}' for {argumentDisplayText}. +Can't convert value ""{value ?? ""}"" to type '{type.Name}' for parameter {parameterSchema.GetUserFacingDisplayString()}. {innerException?.Message ?? "This type is not supported."}"; return new CliFxException(message.Trim(), innerException, showHelp: true); } + internal static CliFxException CannotConvertToType( + CommandOptionSchema optionSchema, + string? value, + Type type, + Exception? innerException = null) + { + var message = $@" +Can't convert value ""{value ?? ""}"" to type '{type.Name}' for option {optionSchema.GetUserFacingDisplayString()}. +{innerException?.Message ?? "This type is not supported."}"; + + return new CliFxException(message.Trim(), innerException, showHelp: true); + } + + internal static CliFxException CannotConvertToType( + CommandArgumentSchema argumentSchema, + string? value, + Type type, + Exception? innerException = null) => argumentSchema switch + { + CommandParameterSchema parameterSchema => CannotConvertToType(parameterSchema, value, type, innerException), + CommandOptionSchema optionSchema => CannotConvertToType(optionSchema, value, type, innerException), + _ => throw new ArgumentOutOfRangeException(nameof(argumentSchema)) + }; + internal static CliFxException CannotConvertNonScalar( - CommandArgumentSchema argument, + CommandParameterSchema parameterSchema, IReadOnlyList 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}'"))} +Can't convert provided values to type '{type.Name}' for parameter {parameterSchema.GetUserFacingDisplayString()}: +{values.Select(v => v.Quote()).JoinToString(" ")} Target type is not assignable from array and doesn't have a public constructor that takes an array."; return new CliFxException(message.Trim(), showHelp: true); } - internal static CliFxException ParameterNotSet(CommandParameterSchema parameter) + internal static CliFxException CannotConvertNonScalar( + CommandOptionSchema optionSchema, + IReadOnlyList values, + Type type) { var message = $@" -Missing value for parameter <{parameter.DisplayName}>."; +Can't convert provided values to type '{type.Name}' for option {optionSchema.GetUserFacingDisplayString()}: +{values.Select(v => v.Quote()).JoinToString(" ")} + +Target type is not assignable from array and doesn't have a public constructor that takes an array."; return new CliFxException(message.Trim(), showHelp: true); } - internal static CliFxException RequiredOptionsNotSet(IReadOnlyList options) + internal static CliFxException CannotConvertNonScalar( + CommandArgumentSchema argumentSchema, + IReadOnlyList values, + Type type) => argumentSchema switch + { + CommandParameterSchema parameterSchema => CannotConvertNonScalar(parameterSchema, values, type), + CommandOptionSchema optionSchema => CannotConvertNonScalar(optionSchema, values, type), + _ => throw new ArgumentOutOfRangeException(nameof(argumentSchema)) + }; + + internal static CliFxException ParameterNotSet(CommandParameterSchema parameterSchema) + { + var message = $@" +Missing value for parameter {parameterSchema.GetUserFacingDisplayString()}."; + + return new CliFxException(message.Trim(), showHelp: true); + } + + internal static CliFxException RequiredOptionsNotSet(IReadOnlyList optionSchemas) { var message = $@" Missing values for one or more required options: -{string.Join(Environment.NewLine, options.Select(o => o.DisplayName))}"; +{optionSchemas.Select(o => o.GetUserFacingDisplayString()).JoinToString(Environment.NewLine)}"; return new CliFxException(message.Trim(), showHelp: true); } - internal static CliFxException UnrecognizedParametersProvided(IReadOnlyList inputs) + internal static CliFxException UnrecognizedParametersProvided(IReadOnlyList argumentInputs) { var message = $@" Unrecognized parameters provided: -{string.Join(Environment.NewLine, inputs.Select(i => $"<{i.Value}>"))}"; +{argumentInputs.Select(a => a.Value.Quote()).JoinToString(" ")}"; return new CliFxException(message.Trim(), showHelp: true); } - internal static CliFxException UnrecognizedOptionsProvided(IReadOnlyList inputs) + internal static CliFxException UnrecognizedOptionsProvided(IReadOnlyList optionInputs) { var message = $@" Unrecognized options provided: -{string.Join(Environment.NewLine, inputs.Select(i => i.DisplayAlias))}"; +{optionInputs.Select(o => o.RawAlias).JoinToString(Environment.NewLine)}"; return new CliFxException(message.Trim(), showHelp: true); } diff --git a/CliFx/Exceptions/CommandException.cs b/CliFx/Exceptions/CommandException.cs index 2ef5159..7980c3d 100644 --- a/CliFx/Exceptions/CommandException.cs +++ b/CliFx/Exceptions/CommandException.cs @@ -12,18 +12,17 @@ namespace CliFx.Exceptions /// /// Initializes an instance of . /// - public CommandException(string? message, Exception? innerException, + public CommandException(string? message, Exception? innerException, int exitCode = DefaultExitCode, bool showHelp = false) - : base(message, innerException, exitCode, showHelp) + : base(message, innerException, exitCode, showHelp) { - } /// /// Initializes an instance of . /// public CommandException(string? message, int exitCode = DefaultExitCode, bool showHelp = false) - : this(message, null, exitCode, showHelp) + : this(message, null, exitCode, showHelp) { } diff --git a/CliFx/ICommand.cs b/CliFx/ICommand.cs index 7eb95d8..cc7ff6b 100644 --- a/CliFx/ICommand.cs +++ b/CliFx/ICommand.cs @@ -9,7 +9,7 @@ namespace CliFx { /// /// Executes the command using the specified implementation of . - /// This is the method that's called when the command is invoked by a user through command line interface. + /// This is the method that's called when the command is invoked by a user through command line. /// /// If the execution of the command is not asynchronous, simply end the method with return default; ValueTask ExecuteAsync(IConsole console); diff --git a/CliFx/ITypeActivator.cs b/CliFx/ITypeActivator.cs index 878753e..edd49fd 100644 --- a/CliFx/ITypeActivator.cs +++ b/CliFx/ITypeActivator.cs @@ -3,12 +3,12 @@ namespace CliFx { /// - /// Abstraction for a service can initialize objects at runtime. + /// Abstraction for a service that can initialize objects at runtime. /// public interface ITypeActivator { /// - /// Creates an instance of specified type. + /// Creates an instance of the specified type. /// object CreateInstance(Type type); } diff --git a/CliFx/Internal/ProcessEx.cs b/CliFx/Internal/ProcessEx.cs new file mode 100644 index 0000000..383df5b --- /dev/null +++ b/CliFx/Internal/ProcessEx.cs @@ -0,0 +1,13 @@ +using System.Diagnostics; + +namespace CliFx.Internal +{ + internal static class ProcessEx + { + public static int GetCurrentProcessId() + { + using var process = Process.GetCurrentProcess(); + return process.Id; + } + } +} \ No newline at end of file diff --git a/CliFx/Internal/StringExtensions.cs b/CliFx/Internal/StringExtensions.cs index 5aa90ac..6c659c1 100644 --- a/CliFx/Internal/StringExtensions.cs +++ b/CliFx/Internal/StringExtensions.cs @@ -1,5 +1,5 @@ using System; -using System.Globalization; +using System.Collections.Generic; using System.Text; namespace CliFx.Internal @@ -10,14 +10,17 @@ namespace CliFx.Internal public static string AsString(this char c) => c.Repeat(1); + public static string Quote(this string str) => $"\"{str}\""; + + public static string JoinToString(this IEnumerable source, string separator) => string.Join(separator, source); + public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) => builder.Length > 0 ? builder.Append(value) : builder; - public static bool IsEmptyOrWhiteSpace(this string s) => s is object && string.IsNullOrWhiteSpace(s); - - public static string WrapWithQuotesIfEmptyOrWhiteSpace(this string s) => - s.IsEmptyOrWhiteSpace() ? $"\"{s}\"" : s; - - public static string ToCulturedString(this object obj, CultureInfo culture) => Convert.ToString(obj, culture); + public static string ToFormattableString(this object obj, + IFormatProvider? formatProvider = null, string? format = null) => + obj is IFormattable formattable + ? formattable.ToString(format, formatProvider) + : obj.ToString(); } } \ No newline at end of file diff --git a/CliFx/Internal/TypeExtensions.cs b/CliFx/Internal/TypeExtensions.cs index baba6a6..cbbcc05 100644 --- a/CliFx/Internal/TypeExtensions.cs +++ b/CliFx/Internal/TypeExtensions.cs @@ -2,11 +2,17 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Reflection; namespace CliFx.Internal { internal static class TypeExtensions { + public static object? GetDefaultValue(this Type type) => + type.IsValueType + ? Activator.CreateInstance(type) + : null; + public static bool Implements(this Type type, Type interfaceType) => type.GetInterfaces().Contains(interfaceType); public static Type? GetNullableUnderlyingType(this Type type) => Nullable.GetUnderlyingType(type); @@ -22,13 +28,30 @@ namespace CliFx.Internal if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) return type.GetGenericArguments().FirstOrDefault(); - return type.GetInterfaces() + return type + .GetInterfaces() .Select(GetEnumerableUnderlyingType) .Where(t => t != null) .OrderByDescending(t => t != typeof(object)) // prioritize more specific types .FirstOrDefault(); } + public static MethodInfo GetToStringMethod(this Type type) => type.GetMethod(nameof(ToString), Type.EmptyTypes); + + public static bool IsToStringOverriden(this Type type) => type.GetToStringMethod() != typeof(object).GetToStringMethod(); + + public static MethodInfo GetStaticParseMethod(this Type type, bool withFormatProvider = false) + { + var argumentTypes = withFormatProvider + ? new[] {typeof(string), typeof(IFormatProvider)} + : new[] {typeof(string)}; + + return type.GetMethod("Parse", + BindingFlags.Public | BindingFlags.Static, + null, argumentTypes, null + ); + } + public static Array ToNonGenericArray(this IEnumerable source, Type elementType) { var sourceAsCollection = source as ICollection ?? source.ToArray(); @@ -38,7 +61,5 @@ namespace CliFx.Internal return array; } - - public static bool OverridesToStringMethod(this object obj) => obj?.ToString() != obj?.GetType().ToString(); } } \ No newline at end of file