From 2409e3d7cea42f830e318a04e5db0678fa1d069f Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 05:55:19 +0000 Subject: [PATCH 1/2] fix: resolve nullable type lookup bypassing custom value parsers Move the nullable type unwrap check before the TypeDescriptor fallback in GetParserImpl(). Previously, when a custom parser was registered via AddOrReplace for a type like TimeSpan, looking up TimeSpan? would miss it because the TypeDescriptor fallback (which returns the built-in converter) ran before the nullable unwrap that checks the dictionary under the unwrapped type. Fixes #559 Co-authored-by: Nate McMaster Co-Authored-By: Claude Opus 4.6 --- .../Abstractions/ValueParserProvider.cs | 10 +-- .../ValueParserProviderCustomTests.cs | 66 +++++++++++++++++++ 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/CommandLineUtils/Abstractions/ValueParserProvider.cs b/src/CommandLineUtils/Abstractions/ValueParserProvider.cs index 89d730a7..12e96000 100644 --- a/src/CommandLineUtils/Abstractions/ValueParserProvider.cs +++ b/src/CommandLineUtils/Abstractions/ValueParserProvider.cs @@ -100,11 +100,6 @@ public IValueParser GetParser(Type type) return EnumParser.Create(type); } - if (_defaultValueParserFactory.TryGetParser(out parser)) - { - return parser; - } - if (ReflectionHelper.IsNullableType(type, out var wrappedType)) { if (wrappedType.IsEnum) @@ -118,6 +113,11 @@ public IValueParser GetParser(Type type) } } + if (_defaultValueParserFactory.TryGetParser(out parser)) + { + return parser; + } + if (ReflectionHelper.IsSpecialValueTupleType(type, out var wrappedType2)) { var innerParser = GetParser(wrappedType2); diff --git a/test/CommandLineUtils.Tests/ValueParserProviderCustomTests.cs b/test/CommandLineUtils.Tests/ValueParserProviderCustomTests.cs index a5295fae..dcc221c7 100644 --- a/test/CommandLineUtils.Tests/ValueParserProviderCustomTests.cs +++ b/test/CommandLineUtils.Tests/ValueParserProviderCustomTests.cs @@ -339,5 +339,71 @@ public void AddOrReplaceThrowsIfNullparser() Assert.Contains("parser", ex.Message); } + + private class CustomTimeSpanParser : IValueParser + { + public Type TargetType => typeof(TimeSpan); + + public TimeSpan Parse(string? argName, string? value, CultureInfo culture) + { + if (value != null && value.EndsWith("s")) + { + var seconds = int.Parse(value.Substring(0, value.Length - 1), culture); + return TimeSpan.FromSeconds(seconds); + } + + return TimeSpan.Parse(value!, culture); + } + + object? IValueParser.Parse(string? argName, string? value, CultureInfo culture) + => Parse(argName, value, culture); + } + + private class NullableTimeSpanOptionProgram + { + [Option("--timeout", CommandOptionType.SingleValue)] + public TimeSpan? Timeout { get; set; } + } + + private class NonNullableTimeSpanOptionProgram + { + [Option("--timeout", CommandOptionType.SingleValue)] + public TimeSpan Timeout { get; set; } + } + + [Fact] + public void CustomParserWorksForNullableBuiltInType() + { + var app = new CommandLineApplication(); + app.ValueParsers.AddOrReplace(new CustomTimeSpanParser()); + app.Conventions.UseDefaultConventions(); + + app.Parse("--timeout", "15s"); + + Assert.Equal(TimeSpan.FromSeconds(15), app.Model.Timeout); + } + + [Fact] + public void CustomParserWorksForNonNullableBuiltInType() + { + var app = new CommandLineApplication(); + app.ValueParsers.AddOrReplace(new CustomTimeSpanParser()); + app.Conventions.UseDefaultConventions(); + + app.Parse("--timeout", "15s"); + + Assert.Equal(TimeSpan.FromSeconds(15), app.Model.Timeout); + } + + [Fact] + public void NullableBuiltInTypeUsesDefaultParserWhenNoCustomParser() + { + var app = new CommandLineApplication(); + app.Conventions.UseDefaultConventions(); + + app.Parse("--timeout", "00:00:15"); + + Assert.Equal(TimeSpan.FromSeconds(15), app.Model.Timeout); + } } } From 394dc90c395d72fa8c43d16aa53be7b160974f5a Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 05:58:28 +0000 Subject: [PATCH 2/2] docs: add release notes for nullable value parser fix (#559) Co-authored-by: Nate McMaster --- CHANGELOG.md | 2 ++ src/CommandLineUtils/releasenotes.props | 1 + 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1781e8ea..8f203488 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ ### Fixes * [@claude]: Validate dotnet path exists before returning from `TryFindDotNetExePath` ([#600]) +* [@claude]: Fix nullable type lookup bypassing custom value parsers registered via `AddOrReplace` ([#559]) +[#559]: https://github.com/natemcmaster/CommandLineUtils/issues/559 [#560]: https://github.com/natemcmaster/CommandLineUtils/pull/560 [#600]: https://github.com/natemcmaster/CommandLineUtils/issues/600 diff --git a/src/CommandLineUtils/releasenotes.props b/src/CommandLineUtils/releasenotes.props index 60678d41..9d29add0 100644 --- a/src/CommandLineUtils/releasenotes.props +++ b/src/CommandLineUtils/releasenotes.props @@ -8,6 +8,7 @@ Features: Fixes: * @claude: Validate dotnet path exists before returning from TryFindDotNetExePath (#600) +* @claude: Fix nullable type lookup bypassing custom value parsers registered via AddOrReplace (#559) Changes since 4.1: