Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 9 additions & 30 deletions src/Repl.Core/Help/HelpTextBuilder.Rendering.cs
Comment thread
carldebilly marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ or OptionSchemaTokenKind.ValueAlias
parameterType = groupInfo.Property.PropertyType;
description = groupInfo.Property.GetCustomAttribute<DescriptionAttribute>()?.Description ?? string.Empty;
var propDefault = groupInfo.Property.GetValue(groupInfo.DefaultInstance);
defaultValue = propDefault is not null && !IsDefaultForType(propDefault, parameterType)
defaultValue = propDefault is not null && !ParsingOptions.IsDefaultForType(propDefault, parameterType)
? $" [default: {propDefault}]"
: string.Empty;
}
Expand Down Expand Up @@ -314,31 +314,6 @@ private static Type UnwrapAsyncReturnType(Type returnType)
: returnType;
}

private static bool IsDefaultForType(object value, Type type)
{
if (type == typeof(bool))
{
return value is false;
}

if (type == typeof(int))
{
return value is 0;
}

if (type == typeof(long))
{
return value is 0L;
}

if (type == typeof(double))
{
return value is 0.0d;
}

return false;
}

private static string ResolveOptionPlaceholder(Type parameterType)
{
var effectiveType = Nullable.GetUnderlyingType(parameterType) ?? parameterType;
Expand Down Expand Up @@ -619,13 +594,17 @@ private static string[][] BuildGlobalOptionRows(ParsingOptions parsingOptions)
.OrderBy(option => option.Name, StringComparer.OrdinalIgnoreCase)
.Select(option =>
{
var aliases = option.Aliases.Count == 0
? string.Empty
: $", {string.Join(", ", option.Aliases)}";
var aliases = option.Aliases.Count == 0
? string.Empty
: $", {string.Join(", ", option.Aliases)}";
var description = string.IsNullOrWhiteSpace(option.Description)
? "Custom global option."
: option.Description;

return new[]
{
$"{option.CanonicalToken}{aliases}",
"Custom global option.",
description,
};
});
return [.. BuiltInGlobalOptionRows.Concat(customRows)];
Expand Down
1 change: 1 addition & 0 deletions src/Repl.Core/Parsing/GlobalOptionDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ internal sealed record GlobalOptionDefinition(
string CanonicalToken,
IReadOnlyList<string> Aliases,
string? DefaultValue,
string? Description,
Type ValueType,
Type? OwnerType);
74 changes: 72 additions & 2 deletions src/Repl.Core/ParsingOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,25 @@ internal bool TryGetRouteConstraint(string name, out Func<string, bool> predicat
/// <param name="aliases">Optional aliases. Values without prefix are normalized to <c>--alias</c>.</param>
/// <param name="defaultValue">Optional default value metadata.</param>
public void AddGlobalOption<T>(string name, string[]? aliases = null, T? defaultValue = default) =>
AddGlobalOptionCore(name, typeof(T), aliases, defaultValue?.ToString());
AddGlobalOptionCore(name, typeof(T), aliases, FormatDefaultValue(defaultValue, typeof(T)));

/// <summary>
/// Registers a custom global option with an explicit help description.
/// </summary>
/// <remarks>
/// <paramref name="description"/> is the trailing required parameter so this overload never collides with
/// <see cref="AddGlobalOption{T}(string, string[], T)"/> during overload resolution: a positional
/// <c>null</c> second argument (for example <c>AddGlobalOption&lt;string&gt;("tenant", null)</c>) binds
/// unambiguously to the aliases-only overload. The typed <see cref="System.ComponentModel.DescriptionAttribute"/>
/// path (<c>UseGlobalOptions&lt;T&gt;()</c>) remains the primary way to attach descriptions.
/// </remarks>
/// <typeparam name="T">Declared value type.</typeparam>
/// <param name="name">Canonical name without prefix (for example: "tenant").</param>
/// <param name="aliases">Aliases (pass <c>null</c> for none). Values without prefix are normalized to <c>--alias</c>.</param>
/// <param name="defaultValue">Default value metadata (pass <c>default</c> for none).</param>
/// <param name="description">Description shown in help output.</param>
public void AddGlobalOption<T>(string name, string[]? aliases, T? defaultValue, string description) =>
AddGlobalOptionCore(name, typeof(T), aliases, FormatDefaultValue(defaultValue, typeof(T)), description);

/// <summary>
/// Registers a custom global option using a type or constraint name
Expand All @@ -117,7 +135,27 @@ public void AddGlobalOption<T>(string name, string[]? aliases = null, T? default
public void AddGlobalOption(string name, string constraintOrTypeName, string[]? aliases = null, string? defaultValue = null) =>
AddGlobalOptionCore(name, ResolveConstraintOrTypeName(constraintOrTypeName, _customRouteConstraints), aliases, defaultValue);

internal void AddGlobalOptionCore(string name, Type valueType, string[]? aliases, string? defaultValue, Type? ownerType = null)
/// <summary>
/// Registers a custom global option (by type or constraint name) with an explicit help description.
/// </summary>
/// <remarks>
/// <paramref name="description"/> is the trailing required parameter so this overload never collides with
/// <see cref="AddGlobalOption(string, string, string[], string)"/> during overload resolution: a positional
/// <c>null</c> third argument (for example <c>AddGlobalOption("port", "int", null)</c>) binds unambiguously
/// to the aliases-only overload.
/// </remarks>
/// <param name="name">Canonical name without prefix (for example: "tenant").</param>
/// <param name="constraintOrTypeName">
/// Built-in type name ("string", "int", "long", "bool", "guid", "uri", "date", "datetime", "timespan")
/// or a registered custom route constraint name. Custom constraints resolve to <c>string</c>.
/// </param>
/// <param name="aliases">Aliases (pass <c>null</c> for none). Values without prefix are normalized to <c>--alias</c>.</param>
/// <param name="defaultValue">Default value as string (pass <c>null</c> for none).</param>
/// <param name="description">Description shown in help output.</param>
public void AddGlobalOption(string name, string constraintOrTypeName, string[]? aliases, string? defaultValue, string description) =>
AddGlobalOptionCore(name, ResolveConstraintOrTypeName(constraintOrTypeName, _customRouteConstraints), aliases, defaultValue, description);

internal void AddGlobalOptionCore(string name, Type valueType, string[]? aliases, string? defaultValue, string? description = null, Type? ownerType = null)
{
name = string.IsNullOrWhiteSpace(name)
? throw new ArgumentException("Global option name cannot be empty.", nameof(name))
Expand All @@ -141,6 +179,7 @@ internal void AddGlobalOptionCore(string name, Type valueType, string[]? aliases
CanonicalToken: normalizedCanonical,
Aliases: normalizedAliases,
DefaultValue: defaultValue,
Description: description,
ValueType: valueType,
OwnerType: ownerType);
}
Expand All @@ -161,6 +200,37 @@ private static string BuildDuplicateGlobalOptionMessage(string name, Type? exist
return $"A global option named '{name}' is already registered by {existingSource} and cannot also be registered by {newSource}.";
}

internal static string? FormatDefaultValue(object? value, Type type) =>
value is not null && !IsDefaultForType(value, type)
? value.ToString()
: null;
Comment on lines +203 to +206

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve explicit zero and false global defaults

When callers explicitly pass a registration default equal to the CLR default, for example AddGlobalOption<int>("port", defaultValue: 0) or AddGlobalOption<bool>("verbose", defaultValue: false), this helper returns null and discards that default. GlobalOptionsSnapshot.GetValue only applies registered defaults when definition.DefaultValue is non-null, so GetValue<int>("port", 8080) now returns the caller fallback instead of the registered 0; the previous generic overload preserved these explicit defaults. Consider separating “argument omitted” from “default(T) was supplied.”

Useful? React with 👍 / 👎.


internal static bool IsDefaultForType(object value, Type type)
{
var effectiveType = Nullable.GetUnderlyingType(type) ?? type;
if (effectiveType == typeof(bool))
{
return value is false;
}

if (effectiveType == typeof(int))
{
return value is 0;
}

if (effectiveType == typeof(long))
{
return value is 0L;
}

if (effectiveType == typeof(double))
{
return value is 0.0d;
}

return false;
}

private static Type ResolveConstraintOrTypeName(
string constraintOrTypeName,
Dictionary<string, Func<string, bool>> customConstraints)
Expand Down
5 changes: 3 additions & 2 deletions src/Repl.Defaults/GlobalOptionsExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ public static class GlobalOptionsExtensions
var optionAttr = property.GetCustomAttribute<ReplOptionAttribute>();
var name = optionAttr?.Name ?? ToKebabCase(property.Name);
var aliases = optionAttr?.Aliases;
var defaultValue = property.GetValue(prototype)?.ToString();
var defaultValue = ParsingOptions.FormatDefaultValue(property.GetValue(prototype), property.PropertyType);
var description = property.GetCustomAttribute<DescriptionAttribute>()?.Description;

options.Parsing.AddGlobalOptionCore(name, property.PropertyType, aliases, defaultValue, typeof(T));
options.Parsing.AddGlobalOptionCore(name, property.PropertyType, aliases, defaultValue, description, typeof(T));
}
});

Expand Down
52 changes: 52 additions & 0 deletions src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Repl.Parameters;

namespace Repl.IntegrationTests;

[TestClass]
Expand Down Expand Up @@ -45,5 +47,55 @@ public void When_RequestingRootHelp_Then_CustomGlobalOptionIsListedInGlobalOptio
output.ExitCode.Should().Be(0);
output.Text.Should().Contain("Global Options:");
output.Text.Should().Contain("--tenant, -t");
output.Text.Should().Contain("Custom global option.");
}

[TestMethod]
[Description("Regression guard: verifies typed global option descriptions are rendered in root help without adding default-value display.")]
public void When_RequestingRootHelpForTypedGlobalOptions_Then_DescriptionsAreListed()
{
var sut = ReplApp.Create()
.UseGlobalOptions<DemoGlobals>();
sut.Map("status", (DemoGlobals globals) => globals.Tenant);

var output = ConsoleCaptureHelper.Capture(() => sut.Run(["--help", "--no-logo"]));

output.ExitCode.Should().Be(0);
output.Text.Should().Contain("--tenant, -t");
output.Text.Should().Contain("Tenant id used for all commands.");
output.Text.Should().Contain("--verbose, -v");
output.Text.Should().Contain("Enable verbose diagnostics for all commands.");
output.Text.Should().NotContain("[default:");
}

[TestMethod]
[Description("Regression guard: verifies explicit global option descriptions are rendered in root help.")]
public void When_RequestingRootHelpForExplicitGlobalOption_Then_DescriptionIsListed()
{
var sut = ReplApp.Create()
.Options(options => options.Parsing.AddGlobalOption<string>(
"tenant",
aliases: ["-t"],
defaultValue: null,
description: "Tenant id used for all commands."));
sut.Map("ping", () => "ok");

var output = ConsoleCaptureHelper.Capture(() => sut.Run(["--help", "--no-logo"]));

output.ExitCode.Should().Be(0);
output.Text.Should().Contain("--tenant, -t");
output.Text.Should().Contain("Tenant id used for all commands.");
output.Text.Should().NotContain("[default:");
}

private sealed class DemoGlobals
{
[System.ComponentModel.Description("Tenant id used for all commands.")]
[ReplOption(Aliases = ["-t"])]
public string? Tenant { get; set; } = "default";

[System.ComponentModel.Description("Enable verbose diagnostics for all commands.")]
[ReplOption(Aliases = ["-v"])]
public bool Verbose { get; set; }
}
}
43 changes: 43 additions & 0 deletions src/Repl.Tests/Given_GlobalOptionsAccessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,49 @@ public void When_OptionNotProvidedButRegisteredDefault_Then_GetValueReturnsRegis
sut.GetValue<int>("port").Should().Be(3000);
}

[TestMethod]
[Description("GetValue returns caller default when a value-typed option has no explicit registration default.")]
public void When_ValueTypeOptionNotProvidedAndNoRegisteredDefault_Then_GetValueReturnsCallerDefault()
{
var parsing = new ParsingOptions();
parsing.AddGlobalOption<int>("port");
var sut = new GlobalOptionsSnapshot(parsing);
sut.Update(new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase));

sut.GetValue<int>("port", 8080).Should().Be(8080);
}

[TestMethod]
[Description("GetValue returns caller default when a described value-typed option has no explicit registration default.")]
public void When_DescribedValueTypeOptionNotProvidedAndNoRegisteredDefault_Then_GetValueReturnsCallerDefault()
{
var parsing = new ParsingOptions();
parsing.AddGlobalOption<int>("port", aliases: null, defaultValue: default, description: "Port used by the server.");
var sut = new GlobalOptionsSnapshot(parsing);
sut.Update(new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase));

sut.GetValue<int>("port", 8080).Should().Be(8080);
}

[TestMethod]
[Description("Regression (#34 review): a positional null second argument keeps binding the aliases-only overload. The description overloads take description as the trailing required parameter precisely so this call never becomes ambiguous (CS0121) with the description overload.")]
public void When_AddGlobalOptionCalledWithPositionalNull_Then_BindsAliasesOnlyOverload()
{
var parsing = new ParsingOptions();

// The positional null is the regression scenario itself: naming the argument would make
// resolution trivial and defeat the test. These calls must compile and bind unambiguously
// to the aliases-only overloads (description overloads take description as a trailing required arg).
#pragma warning disable MA0003 // Name the parameter — intentionally positional here; see comment above.
parsing.AddGlobalOption<string>("tenant", null);
parsing.AddGlobalOption("port", "int", null);
#pragma warning restore MA0003

parsing.GlobalOptions["tenant"].Description.Should().BeNull();
parsing.GlobalOptions["tenant"].Aliases.Should().BeEmpty();
parsing.GlobalOptions["port"].Description.Should().BeNull();
}

[TestMethod]
[Description("HasValue returns false before parsing.")]
public void When_NeverUpdated_Then_HasValueReturnsFalse()
Expand Down
Loading