From 4cef596fe8664cf6c6612d002e4e8249a8a50421 Mon Sep 17 00:00:00 2001 From: Domn Werner Date: Sat, 16 May 2020 04:11:23 -0700 Subject: [PATCH] Show default values in help (#54) --- CliFx.Tests/HelpTextSpecs.Commands.cs | 114 +++++++++++++++++++++++++- CliFx.Tests/HelpTextSpecs.cs | 64 ++++++++++++++- CliFx/Domain/CommandArgumentSchema.cs | 98 ++++++++++++++++++---- CliFx/Domain/HelpTextWriter.cs | 20 ++++- CliFx/Internal/StringExtensions.cs | 11 ++- CliFx/Internal/TypeExtensions.cs | 2 + 6 files changed, 284 insertions(+), 25 deletions(-) diff --git a/CliFx.Tests/HelpTextSpecs.Commands.cs b/CliFx.Tests/HelpTextSpecs.Commands.cs index bd0bdd9..4f32ac3 100644 --- a/CliFx.Tests/HelpTextSpecs.Commands.cs +++ b/CliFx.Tests/HelpTextSpecs.Commands.cs @@ -1,4 +1,8 @@ -using System.Collections.Generic; +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; @@ -110,5 +114,113 @@ namespace CliFx.Tests public ValueTask ExecuteAsync(IConsole console) => default; } + + [Command("cmd-with-defaults")] + private class DefaultArgumentsCommand : ICommand + { + [CommandOption(nameof(Object))] + public object? Object { get; set; } = 42; + + [CommandOption(nameof(String))] + public string? String { get; set; } = "foo"; + + [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(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" }; + + [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 ValueTask ExecuteAsync(IConsole console) => default; + } } } \ No newline at end of file diff --git a/CliFx.Tests/HelpTextSpecs.cs b/CliFx.Tests/HelpTextSpecs.cs index 714bdb5..6a637a0 100644 --- a/CliFx.Tests/HelpTextSpecs.cs +++ b/CliFx.Tests/HelpTextSpecs.cs @@ -1,4 +1,6 @@ -using System.IO; +using System; +using System.Globalization; +using System.IO; using System.Threading.Tasks; using FluentAssertions; using Xunit; @@ -267,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() { @@ -293,5 +295,63 @@ namespace CliFx.Tests _output.WriteLine(stdOutData); } + + [Fact] + public async Task Help_text_shows_usage_format_which_lists_all_default_values_for_non_required_options() + { + // Arrange + await using var stdOut = new MemoryStream(); + var console = new VirtualConsole(output: stdOut); + + var application = new CliApplicationBuilder() + .AddCommand(typeof(DefaultArgumentsCommand)) + .UseConsole(console) + .Build(); + + // Act + await application.RunAsync(new[] { "cmd-with-defaults", "--help" }); + 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)" + ); + + _output.WriteLine(stdOutData); + } } } \ No newline at end of file diff --git a/CliFx/Domain/CommandArgumentSchema.cs b/CliFx/Domain/CommandArgumentSchema.cs index 6bb06ca..f6ffb1b 100644 --- a/CliFx/Domain/CommandArgumentSchema.cs +++ b/CliFx/Domain/CommandArgumentSchema.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -116,24 +117,6 @@ 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 - if (Property == null) - return result; - - var underlyingPropertyType = - Property.PropertyType.GetNullableUnderlyingType() ?? Property.PropertyType; - - // Enum - if (underlyingPropertyType.IsEnum) - result.AddRange(Enum.GetNames(underlyingPropertyType)); - - return result; - } } internal partial class CommandArgumentSchema @@ -176,4 +159,83 @@ namespace CliFx.Domain 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/HelpTextWriter.cs b/CliFx/Domain/HelpTextWriter.cs index b50e6eb..864ee8e 100644 --- a/CliFx/Domain/HelpTextWriter.cs +++ b/CliFx/Domain/HelpTextWriter.cs @@ -219,6 +219,9 @@ namespace CliFx.Domain 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()) @@ -270,12 +273,23 @@ namespace CliFx.Domain Render(" "); } - // TODO: Render default value here. - // Environment variable if (!string.IsNullOrWhiteSpace(option.EnvironmentVariableName)) { - Render($"Environment variable: {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(); diff --git a/CliFx/Internal/StringExtensions.cs b/CliFx/Internal/StringExtensions.cs index 2e4406f..5aa90ac 100644 --- a/CliFx/Internal/StringExtensions.cs +++ b/CliFx/Internal/StringExtensions.cs @@ -1,4 +1,6 @@ -using System.Text; +using System; +using System.Globalization; +using System.Text; namespace CliFx.Internal { @@ -10,5 +12,12 @@ namespace CliFx.Internal 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); } } \ No newline at end of file diff --git a/CliFx/Internal/TypeExtensions.cs b/CliFx/Internal/TypeExtensions.cs index 2d05445..baba6a6 100644 --- a/CliFx/Internal/TypeExtensions.cs +++ b/CliFx/Internal/TypeExtensions.cs @@ -38,5 +38,7 @@ namespace CliFx.Internal return array; } + + public static bool OverridesToStringMethod(this object obj) => obj?.ToString() != obj?.GetType().ToString(); } } \ No newline at end of file