From ef0afc216eff88e1c403af4f37e5238d80493341 Mon Sep 17 00:00:00 2001 From: Simon Ensslen Date: Mon, 2 Feb 2026 08:35:07 +0100 Subject: [PATCH 01/21] make multitarget possible --- .../CommandLineApplication.cs | 6 ++-- .../Conventions/OptionAttributeConvention.cs | 31 +++++++++--------- .../OptionAttributeConventionBase.cs | 5 +-- .../SubcommandAttributeConvention.cs | 8 +++-- .../Extensions/DictionaryExtensions.cs | 22 +++++++++++++ .../Extensions/StringExtensions.cs | 13 ++++++++ .../HelpText/HangingIndentWriter.cs | 3 +- .../Internal/CommandLineProcessor.cs | 3 +- .../Internal/ReflectionHelper.cs | 22 ++++++++++++- .../Internal/SuggestionCreator.cs | 7 ++-- ...cMaster.Extensions.CommandLineUtils.csproj | 32 +++++++++++-------- src/CommandLineUtils/Properties/Strings.cs | 2 +- .../SourceGeneration/ActivatorModelFactory.cs | 2 ++ .../DefaultMetadataResolver.cs | 6 ++++ .../ReflectionExecuteHandler.cs | 2 ++ .../ReflectionMetadataProvider.cs | 5 ++- .../ReflectionValidateHandler.cs | 2 ++ .../ReflectionValidationErrorHandler.cs | 2 ++ src/CommandLineUtils/Utilities/DotNetExe.cs | 3 +- ...ster.Extensions.Hosting.CommandLine.csproj | 14 +++++++- ...AppNameFromEntryAssemblyConventionTests.cs | 2 +- .../ArgumentAttributeTests.cs | 6 ++-- .../AttributeValidatorTests.cs | 16 +++++----- .../CommandLineApplicationExecutorTests.cs | 6 ++-- .../CommandLineApplicationTests.cs | 10 +++--- .../CustomValidationAttributeTest.cs | 7 ++-- .../DefaultHelpTextGeneratorTests.cs | 12 +++---- .../FilePathExistsAttributeTests.cs | 4 +-- .../FilePathNotExistsAttributeTests.cs | 4 +-- .../HelpOptionAttributeTests.cs | 3 +- .../LegalFilePathAttributeTests.cs | 2 +- ...r.Extensions.CommandLineUtils.Tests.csproj | 15 +++++++-- .../OptionAttributeTests.cs | 22 ++++++------- .../ResponseFileTests.cs | 2 +- .../StringExtensionsTests.cs | 4 +-- .../ValidateMethodConventionTests.cs | 8 ++--- .../CommandLineUtils.Tests/ValidationTests.cs | 2 +- .../ValueParserProviderCustomTests.cs | 2 +- .../VersionOptionAttributeTests.cs | 6 ++-- ...xtensions.Hosting.CommandLine.Tests.csproj | 14 +++++++- 40 files changed, 230 insertions(+), 107 deletions(-) create mode 100644 src/CommandLineUtils/Extensions/DictionaryExtensions.cs create mode 100644 src/CommandLineUtils/Extensions/StringExtensions.cs diff --git a/src/CommandLineUtils/CommandLineApplication.cs b/src/CommandLineUtils/CommandLineApplication.cs index 9a9e5bd7..a0468cf3 100644 --- a/src/CommandLineUtils/CommandLineApplication.cs +++ b/src/CommandLineUtils/CommandLineApplication.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; using System.IO; using System.Linq; using System.Reflection; @@ -13,6 +12,7 @@ using System.Threading.Tasks; using McMaster.Extensions.CommandLineUtils.Abstractions; using McMaster.Extensions.CommandLineUtils.Conventions; +using McMaster.Extensions.CommandLineUtils.Extensions; using McMaster.Extensions.CommandLineUtils.HelpText; using McMaster.Extensions.CommandLineUtils.Internal; @@ -221,7 +221,7 @@ public IEnumerable Names { get { - if (!string.IsNullOrEmpty(Name)) + if (!Name.IsNullOrEmpty()) { yield return Name; } @@ -499,7 +499,7 @@ internal CommandLineApplication AddSubcommand(string name, Type modelType, Sourc private void AssertCommandNameIsUnique(string? name, CommandLineApplication? commandToIgnore) { - if (string.IsNullOrEmpty(name)) + if (name.IsNullOrEmpty()) { return; } diff --git a/src/CommandLineUtils/Conventions/OptionAttributeConvention.cs b/src/CommandLineUtils/Conventions/OptionAttributeConvention.cs index 5ac89455..e1e6b8e7 100644 --- a/src/CommandLineUtils/Conventions/OptionAttributeConvention.cs +++ b/src/CommandLineUtils/Conventions/OptionAttributeConvention.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using McMaster.Extensions.CommandLineUtils.Extensions; using McMaster.Extensions.CommandLineUtils.SourceGeneration; namespace McMaster.Extensions.CommandLineUtils.Conventions @@ -32,33 +33,33 @@ public virtual void Apply(ConventionContext context) var (template, shortName, longName) = GetOptionNames(optMeta); // Check for same-class conflicts (options in the same provider with conflicting names) - if (!string.IsNullOrEmpty(shortName) && addedShortOptions.TryGetValue(shortName, out var existingShort)) + if (!shortName.IsNullOrEmpty() && addedShortOptions.TryGetValue(shortName, out var existingShort)) { throw new InvalidOperationException( Strings.OptionNameIsAmbiguous(shortName, optMeta.PropertyName, optMeta.DeclaringType, existingShort.PropertyName, existingShort.DeclaringType)); } - if (!string.IsNullOrEmpty(longName) && addedLongOptions.TryGetValue(longName, out var existingLong)) + if (!longName.IsNullOrEmpty() && addedLongOptions.TryGetValue(longName, out var existingLong)) { throw new InvalidOperationException( Strings.OptionNameIsAmbiguous(longName, optMeta.PropertyName, optMeta.DeclaringType, existingLong.PropertyName, existingLong.DeclaringType)); } // Check if option already exists from parent command (inherited options) - if (!string.IsNullOrEmpty(shortName) && context.Application._shortOptions.ContainsKey(shortName)) + if (!shortName.IsNullOrEmpty() && context.Application._shortOptions.ContainsKey(shortName)) { continue; // Skip - option already registered by parent } - if (!string.IsNullOrEmpty(longName) && context.Application._longOptions.ContainsKey(longName)) + if (!longName.IsNullOrEmpty() && context.Application._longOptions.ContainsKey(longName)) { continue; // Skip - option already registered by parent } // Track this option - if (!string.IsNullOrEmpty(shortName)) + if (!shortName.IsNullOrEmpty()) { addedShortOptions[shortName] = optMeta; } - if (!string.IsNullOrEmpty(longName)) + if (!longName.IsNullOrEmpty()) { addedLongOptions[longName] = optMeta; } @@ -74,18 +75,18 @@ private static (string template, string? shortName, string? longName) GetOptionN string? shortName = meta.ShortName; string? longName = meta.LongName; - if (string.IsNullOrEmpty(template)) + if (template.IsNullOrEmpty()) { // Build template from ShortName/LongName - if (!string.IsNullOrEmpty(shortName) && !string.IsNullOrEmpty(longName)) + if (!shortName.IsNullOrEmpty() && !longName.IsNullOrEmpty()) { template = $"-{shortName}|--{longName}"; } - else if (!string.IsNullOrEmpty(longName)) + else if (!longName.IsNullOrEmpty()) { template = $"--{longName}"; } - else if (!string.IsNullOrEmpty(shortName)) + else if (!shortName.IsNullOrEmpty()) { template = $"-{shortName}"; } @@ -99,17 +100,17 @@ private static (string template, string? shortName, string? longName) GetOptionN else { // Parse short/long names from template if not already set - if (string.IsNullOrEmpty(shortName) || string.IsNullOrEmpty(longName)) + if (shortName.IsNullOrEmpty() || longName.IsNullOrEmpty()) { var parts = template.Split('|'); foreach (var part in parts) { var trimmed = part.Trim(); - if (trimmed.StartsWith("--") && string.IsNullOrEmpty(longName)) + if (trimmed.StartsWith("--") && longName.IsNullOrEmpty()) { longName = trimmed.Substring(2).Split(' ', '<', ':', '=')[0]; } - else if (trimmed.StartsWith("-") && string.IsNullOrEmpty(shortName)) + else if (trimmed.StartsWith("-") && shortName.IsNullOrEmpty()) { shortName = trimmed.Substring(1).Split(' ', '<', ':', '=')[0]; } @@ -175,12 +176,12 @@ private void AddOptionFromMetadata(ConventionContext context, CommandOption opti } // Register names for duplicate checking - if (!string.IsNullOrEmpty(option.ShortName)) + if (!option.ShortName.IsNullOrEmpty()) { context.Application._shortOptions.TryAdd(option.ShortName, null!); } - if (!string.IsNullOrEmpty(option.LongName)) + if (!option.LongName.IsNullOrEmpty()) { context.Application._longOptions.TryAdd(option.LongName, null!); } diff --git a/src/CommandLineUtils/Conventions/OptionAttributeConventionBase.cs b/src/CommandLineUtils/Conventions/OptionAttributeConventionBase.cs index d9c484a5..2e383e4f 100644 --- a/src/CommandLineUtils/Conventions/OptionAttributeConventionBase.cs +++ b/src/CommandLineUtils/Conventions/OptionAttributeConventionBase.cs @@ -6,6 +6,7 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using System.Reflection; +using McMaster.Extensions.CommandLineUtils.Extensions; using McMaster.Extensions.CommandLineUtils.Validation; namespace McMaster.Extensions.CommandLineUtils.Conventions @@ -38,7 +39,7 @@ private protected void AddOption(ConventionContext context, CommandOption option throw new InvalidOperationException(Strings.NoValueTypesMustBeBoolean); } - if (!string.IsNullOrEmpty(option.ShortName)) + if (!option.ShortName.IsNullOrEmpty()) { if (context.Application._shortOptions.TryGetValue(option.ShortName, out var otherProp)) { @@ -53,7 +54,7 @@ private protected void AddOption(ConventionContext context, CommandOption option context.Application._shortOptions.Add(option.ShortName, prop); } - if (!string.IsNullOrEmpty(option.LongName)) + if (!option.LongName.IsNullOrEmpty()) { if (context.Application._longOptions.TryGetValue(option.LongName, out var otherProp)) { diff --git a/src/CommandLineUtils/Conventions/SubcommandAttributeConvention.cs b/src/CommandLineUtils/Conventions/SubcommandAttributeConvention.cs index f615fc1a..d005eb45 100644 --- a/src/CommandLineUtils/Conventions/SubcommandAttributeConvention.cs +++ b/src/CommandLineUtils/Conventions/SubcommandAttributeConvention.cs @@ -6,6 +6,7 @@ using System.Reflection; using McMaster.Extensions.CommandLineUtils.Abstractions; using McMaster.Extensions.CommandLineUtils.Errors; +using McMaster.Extensions.CommandLineUtils.Extensions; using McMaster.Extensions.CommandLineUtils.SourceGeneration; namespace McMaster.Extensions.CommandLineUtils.Conventions @@ -46,7 +47,7 @@ public virtual void Apply(ConventionContext context) private static string GetSubcommandName(Type subcommandType, ICommandMetadataProvider provider) { var commandInfo = provider.CommandInfo; - if (!string.IsNullOrEmpty(commandInfo?.Name)) + if (!(commandInfo?.Name).IsNullOrEmpty()) { // Use the explicit name as-is return commandInfo.Name; @@ -58,7 +59,10 @@ private static string GetSubcommandName(Type subcommandType, ICommandMetadataPro private void AddSubcommandFromMetadata( ConventionContext context, - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] Type subcommandType, +#if NET6_0_OR_GREATER + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] +#endif + Type subcommandType, ICommandMetadataProvider provider, string name) { diff --git a/src/CommandLineUtils/Extensions/DictionaryExtensions.cs b/src/CommandLineUtils/Extensions/DictionaryExtensions.cs new file mode 100644 index 00000000..86a64785 --- /dev/null +++ b/src/CommandLineUtils/Extensions/DictionaryExtensions.cs @@ -0,0 +1,22 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace McMaster.Extensions.CommandLineUtils.Extensions +{ + internal static class DictionaryExtensions + { +#if !NET6_0_OR_GREATER + public static bool TryAdd(this IDictionary dictionary, TKey key, TValue value) + { + if (dictionary.ContainsKey(key)) + { + return false; + } + dictionary.Add(key, value); + return true; + } +#endif + } +} diff --git a/src/CommandLineUtils/Extensions/StringExtensions.cs b/src/CommandLineUtils/Extensions/StringExtensions.cs new file mode 100644 index 00000000..bcc0b8bd --- /dev/null +++ b/src/CommandLineUtils/Extensions/StringExtensions.cs @@ -0,0 +1,13 @@ +// Copyright (c) Nate McMaster. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Diagnostics.CodeAnalysis; + +namespace McMaster.Extensions.CommandLineUtils.Extensions +{ + internal static class StringExtensions + { + public static bool IsNullOrEmpty([NotNullWhen(false)] this string? value) => string.IsNullOrEmpty(value); + public static bool IsNullOrWhiteSpace([NotNullWhen(false)] this string? value) => string.IsNullOrWhiteSpace(value); + } +} diff --git a/src/CommandLineUtils/HelpText/HangingIndentWriter.cs b/src/CommandLineUtils/HelpText/HangingIndentWriter.cs index 7ad7b818..79c4ad84 100644 --- a/src/CommandLineUtils/HelpText/HangingIndentWriter.cs +++ b/src/CommandLineUtils/HelpText/HangingIndentWriter.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using System.Text; +using McMaster.Extensions.CommandLineUtils.Extensions; namespace McMaster.Extensions.CommandLineUtils.HelpText { @@ -46,7 +47,7 @@ public HangingIndentWriter(int indentSize, int? maxLineLength = null, bool inden /// Dynamically wrapped description with explicit newlines preserved. public string Write(string? input) { - if (string.IsNullOrWhiteSpace(input)) + if (input.IsNullOrWhiteSpace()) { return string.Empty; } diff --git a/src/CommandLineUtils/Internal/CommandLineProcessor.cs b/src/CommandLineUtils/Internal/CommandLineProcessor.cs index 4efa9701..3d33fbf8 100644 --- a/src/CommandLineUtils/Internal/CommandLineProcessor.cs +++ b/src/CommandLineUtils/Internal/CommandLineProcessor.cs @@ -8,6 +8,7 @@ using System.IO; using System.Linq; using McMaster.Extensions.CommandLineUtils.Abstractions; +using McMaster.Extensions.CommandLineUtils.Extensions; namespace McMaster.Extensions.CommandLineUtils { @@ -325,7 +326,7 @@ private bool ProcessUnexpectedArg(string argTypeName, string? argValue = null) var suggestions = Enumerable.Empty(); - if (_currentCommand.MakeSuggestionsInErrorMessage && !string.IsNullOrEmpty(value)) + if (_currentCommand.MakeSuggestionsInErrorMessage && !value.IsNullOrEmpty()) { suggestions = SuggestionCreator.GetTopSuggestions(_currentCommand, value); } diff --git a/src/CommandLineUtils/Internal/ReflectionHelper.cs b/src/CommandLineUtils/Internal/ReflectionHelper.cs index 3567560a..da7217d7 100644 --- a/src/CommandLineUtils/Internal/ReflectionHelper.cs +++ b/src/CommandLineUtils/Internal/ReflectionHelper.cs @@ -188,12 +188,32 @@ public bool Equals(MethodInfo? x, MethodInfo? y) return true; } - return x != null && y != null && x.HasSameMetadataDefinitionAs(y); + if (x == null || y == null) + { + return false; + } + +#if NET_6_0_OR_GREATER + return x.HasSameMetadataDefinitionAs(y); +#else + return x.MetadataToken == y.MetadataToken && x.Module.Equals(y.Module); +#endif } public int GetHashCode(MethodInfo obj) { +#if NET_6_0_OR_GREATER return obj.HasMetadataToken() ? obj.GetMetadataToken().GetHashCode() : 0; +#else + try + { + return obj.MetadataToken.GetHashCode(); + } + catch + { + return 0; + } +#endif } } diff --git a/src/CommandLineUtils/Internal/SuggestionCreator.cs b/src/CommandLineUtils/Internal/SuggestionCreator.cs index 95e78b48..1344035e 100644 --- a/src/CommandLineUtils/Internal/SuggestionCreator.cs +++ b/src/CommandLineUtils/Internal/SuggestionCreator.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; +using McMaster.Extensions.CommandLineUtils.Extensions; namespace McMaster.Extensions.CommandLineUtils { @@ -45,17 +46,17 @@ private static IEnumerable GetCandidates(CommandLineApplication command) foreach (var option in command.GetOptions().Where(o => o.ShowInHelpText)) { - if (!string.IsNullOrEmpty(option.LongName)) + if (!option.LongName.IsNullOrEmpty()) { yield return option.LongName; } - if (!string.IsNullOrEmpty(option.ShortName)) + if (!option.ShortName.IsNullOrEmpty()) { yield return option.ShortName; } - if (!string.IsNullOrEmpty(option.SymbolName)) + if (!option.SymbolName.IsNullOrEmpty()) { yield return option.SymbolName; } diff --git a/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj b/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj index ab9ee63c..e8ba8cc5 100644 --- a/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj +++ b/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj @@ -1,7 +1,7 @@ - + - net8.0 + net8.0;net472 true true Command-line parsing API. @@ -21,6 +21,10 @@ McMaster.Extensions.CommandLineUtils.ArgumentEscaper README.md + + 12.0 + + @@ -30,27 +34,27 @@ McMaster.Extensions.CommandLineUtils.ArgumentEscaper + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + - + - + - + - + \ No newline at end of file diff --git a/src/CommandLineUtils/Properties/Strings.cs b/src/CommandLineUtils/Properties/Strings.cs index 34fd28d9..d1611363 100644 --- a/src/CommandLineUtils/Properties/Strings.cs +++ b/src/CommandLineUtils/Properties/Strings.cs @@ -64,7 +64,7 @@ public static string BothOptionAndHelpOptionAttributesCannotBeSpecified(Property public static string BothOptionAndVersionOptionAttributesCannotBeSpecified(PropertyInfo prop) => $"Cannot specify both {nameof(OptionAttribute)} and {nameof(VersionOptionAttribute)} on property {prop.DeclaringType?.Name}.{prop.Name}."; - internal static string UnsupportedParameterTypeOnMethod(string methodName, ParameterInfo methodParam) + internal static string UnsupportedParameterTypeOnMethod(string? methodName, ParameterInfo methodParam) => $"Unsupported type on {methodName} '{methodParam.ParameterType.FullName}' on parameter {methodParam.Name}."; public static string BothHelpOptionAndVersionOptionAttributesCannotBeSpecified(PropertyInfo prop) diff --git a/src/CommandLineUtils/SourceGeneration/ActivatorModelFactory.cs b/src/CommandLineUtils/SourceGeneration/ActivatorModelFactory.cs index 2ec3c8d9..d9953417 100644 --- a/src/CommandLineUtils/SourceGeneration/ActivatorModelFactory.cs +++ b/src/CommandLineUtils/SourceGeneration/ActivatorModelFactory.cs @@ -11,7 +11,9 @@ namespace McMaster.Extensions.CommandLineUtils.SourceGeneration /// /// Model factory that uses Activator.CreateInstance or DI with constructor injection. /// +#if NET6_0_OR_GREATER [RequiresUnreferencedCode("Uses Activator.CreateInstance or DI with constructor injection")] +#endif internal sealed class ActivatorModelFactory : IModelFactory { private readonly Type _modelType; diff --git a/src/CommandLineUtils/SourceGeneration/DefaultMetadataResolver.cs b/src/CommandLineUtils/SourceGeneration/DefaultMetadataResolver.cs index 302a4a4d..a2ca3943 100644 --- a/src/CommandLineUtils/SourceGeneration/DefaultMetadataResolver.cs +++ b/src/CommandLineUtils/SourceGeneration/DefaultMetadataResolver.cs @@ -30,7 +30,9 @@ private DefaultMetadataResolver() /// For full AOT compatibility, ensure the CommandLineUtils.Generators package is referenced /// and the source generator runs during compilation. /// +#if NET6_0_OR_GREATER [RequiresUnreferencedCode("Falls back to reflection when no generated metadata is available. Use the source generator for AOT compatibility.")] +#endif public ICommandMetadataProvider GetProvider(Type modelType) { // Check for generated metadata first (AOT-safe path) @@ -50,7 +52,9 @@ public ICommandMetadataProvider GetProvider(Type modelType) /// For full AOT compatibility, ensure the CommandLineUtils.Generators package is referenced /// and the source generator runs during compilation. /// +#if NET6_0_OR_GREATER [RequiresUnreferencedCode("Falls back to reflection when no generated metadata is available. Use the source generator for AOT compatibility.")] +#endif public ICommandMetadataProvider GetProvider() where TModel : class { // Check for generated metadata first (AOT-safe path) @@ -78,7 +82,9 @@ public bool HasGeneratedMetadata(Type modelType) return CommandMetadataRegistry.HasMetadata(modelType); } +#if NET6_0_OR_GREATER [RequiresUnreferencedCode("Uses reflection to analyze the model type")] +#endif private static ICommandMetadataProvider CreateReflectionProvider(Type modelType) { // This creates a reflection-based implementation of ICommandMetadataProvider diff --git a/src/CommandLineUtils/SourceGeneration/ReflectionExecuteHandler.cs b/src/CommandLineUtils/SourceGeneration/ReflectionExecuteHandler.cs index 7ed1237c..8e7ae1fd 100644 --- a/src/CommandLineUtils/SourceGeneration/ReflectionExecuteHandler.cs +++ b/src/CommandLineUtils/SourceGeneration/ReflectionExecuteHandler.cs @@ -12,7 +12,9 @@ namespace McMaster.Extensions.CommandLineUtils.SourceGeneration /// /// Execute handler that uses reflection to invoke OnExecute/OnExecuteAsync. /// +#if NET6_0_OR_GREATER [RequiresUnreferencedCode("Uses reflection to invoke method")] +#endif internal sealed class ReflectionExecuteHandler : IExecuteHandler { private readonly MethodInfo _method; diff --git a/src/CommandLineUtils/SourceGeneration/ReflectionMetadataProvider.cs b/src/CommandLineUtils/SourceGeneration/ReflectionMetadataProvider.cs index e5b26f7d..7dfbc8ff 100644 --- a/src/CommandLineUtils/SourceGeneration/ReflectionMetadataProvider.cs +++ b/src/CommandLineUtils/SourceGeneration/ReflectionMetadataProvider.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Reflection; using System.Threading.Tasks; +using McMaster.Extensions.CommandLineUtils.Extensions; namespace McMaster.Extensions.CommandLineUtils.SourceGeneration { @@ -15,7 +16,9 @@ namespace McMaster.Extensions.CommandLineUtils.SourceGeneration /// Provides command metadata by analyzing a type using reflection. /// This is the fallback when generated metadata is not available. /// +#if NET6_0_OR_GREATER [RequiresUnreferencedCode("Uses reflection to analyze the model type")] +#endif internal sealed class ReflectionMetadataProvider : ICommandMetadataProvider { private const BindingFlags MethodLookup = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; @@ -442,7 +445,7 @@ private IReadOnlyList ExtractSubcommands() { Func? versionGetter = null; - if (!string.IsNullOrEmpty(fromMemberAttr.MemberName)) + if (!fromMemberAttr.MemberName.IsNullOrEmpty()) { var members = ReflectionHelper.GetPropertyOrMethod(_modelType, fromMemberAttr.MemberName); if (members.Length > 0) diff --git a/src/CommandLineUtils/SourceGeneration/ReflectionValidateHandler.cs b/src/CommandLineUtils/SourceGeneration/ReflectionValidateHandler.cs index bb473e9f..359711d0 100644 --- a/src/CommandLineUtils/SourceGeneration/ReflectionValidateHandler.cs +++ b/src/CommandLineUtils/SourceGeneration/ReflectionValidateHandler.cs @@ -12,7 +12,9 @@ namespace McMaster.Extensions.CommandLineUtils.SourceGeneration /// /// Validate handler that uses reflection to invoke OnValidate. /// +#if NET6_0_OR_GREATER [RequiresUnreferencedCode("Uses reflection to invoke method")] +#endif internal sealed class ReflectionValidateHandler : IValidateHandler { private readonly MethodInfo _method; diff --git a/src/CommandLineUtils/SourceGeneration/ReflectionValidationErrorHandler.cs b/src/CommandLineUtils/SourceGeneration/ReflectionValidationErrorHandler.cs index 0619433f..2791d181 100644 --- a/src/CommandLineUtils/SourceGeneration/ReflectionValidationErrorHandler.cs +++ b/src/CommandLineUtils/SourceGeneration/ReflectionValidationErrorHandler.cs @@ -11,7 +11,9 @@ namespace McMaster.Extensions.CommandLineUtils.SourceGeneration /// /// Validation error handler that uses reflection to invoke OnValidationError. /// +#if NET6_0_OR_GREATER [RequiresUnreferencedCode("Uses reflection to invoke method")] +#endif internal sealed class ReflectionValidationErrorHandler : IValidationErrorHandler { private readonly MethodInfo _method; diff --git a/src/CommandLineUtils/Utilities/DotNetExe.cs b/src/CommandLineUtils/Utilities/DotNetExe.cs index e7424021..72395afb 100644 --- a/src/CommandLineUtils/Utilities/DotNetExe.cs +++ b/src/CommandLineUtils/Utilities/DotNetExe.cs @@ -7,6 +7,7 @@ using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; +using McMaster.Extensions.CommandLineUtils.Extensions; namespace McMaster.Extensions.CommandLineUtils { @@ -49,7 +50,7 @@ public static string FullPathOrDefault() } var mainModule = Process.GetCurrentProcess().MainModule; - if (!string.IsNullOrEmpty(mainModule?.FileName) + if (!(mainModule?.FileName).IsNullOrEmpty() && Path.GetFileName(mainModule.FileName).Equals(fileName, StringComparison.OrdinalIgnoreCase)) { return mainModule.FileName; diff --git a/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj b/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj index 14193d87..d53fa110 100644 --- a/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj +++ b/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj @@ -1,13 +1,17 @@  - net8.0 + net8.0;net472 true true Provides command-line parsing API integration with the generic host API (Microsoft.Extensions.Hosting). commandline;parsing;hosting + + 12.0 + + @@ -15,6 +19,14 @@ + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/test/CommandLineUtils.Tests/AppNameFromEntryAssemblyConventionTests.cs b/test/CommandLineUtils.Tests/AppNameFromEntryAssemblyConventionTests.cs index a3e49d34..77b7d19d 100644 --- a/test/CommandLineUtils.Tests/AppNameFromEntryAssemblyConventionTests.cs +++ b/test/CommandLineUtils.Tests/AppNameFromEntryAssemblyConventionTests.cs @@ -16,7 +16,7 @@ public void ItSetsAppNameToEntryAssemblyIfNotSpecified() return; } - var expected = Assembly.GetEntryAssembly().GetName().Name; + var expected = Assembly.GetEntryAssembly()?.GetName().Name; var app = new CommandLineApplication(); app.Conventions.SetAppNameFromEntryAssembly(); Assert.Equal(expected, app.Name); diff --git a/test/CommandLineUtils.Tests/ArgumentAttributeTests.cs b/test/CommandLineUtils.Tests/ArgumentAttributeTests.cs index 7bf5aa87..71926578 100644 --- a/test/CommandLineUtils.Tests/ArgumentAttributeTests.cs +++ b/test/CommandLineUtils.Tests/ArgumentAttributeTests.cs @@ -2,7 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Linq; +using System.Reflection; using Xunit; using Xunit.Abstractions; @@ -43,8 +43,8 @@ public void ThrowsWhenDuplicateArgumentPositionsAreSpecified() Assert.Equal( Strings.DuplicateArgumentPosition( 0, - typeof(DuplicateArguments).GetProperty("AlsoFirst"), - typeof(DuplicateArguments).GetProperty("First")), + Assert.IsAssignableFrom(typeof(DuplicateArguments).GetProperty("AlsoFirst")), + Assert.IsAssignableFrom(typeof(DuplicateArguments).GetProperty("First"))), ex.Message); } diff --git a/test/CommandLineUtils.Tests/AttributeValidatorTests.cs b/test/CommandLineUtils.Tests/AttributeValidatorTests.cs index 2cfad77f..99febd92 100644 --- a/test/CommandLineUtils.Tests/AttributeValidatorTests.cs +++ b/test/CommandLineUtils.Tests/AttributeValidatorTests.cs @@ -39,7 +39,7 @@ public void ItOnlyInvokesAttributeIfValueExists() [InlineData(typeof(PhoneAttribute), "(800) 555-5555", "xyz")] public void ItExecutesValidationAttribute(Type attributeType, string validValue, string invalidValue) { - var attr = (ValidationAttribute)Activator.CreateInstance(attributeType); + var attr = Assert.IsAssignableFrom(Activator.CreateInstance(attributeType)); var app = new CommandLineApplication(); var arg = app.Argument("arg", "arg"); var validator = new AttributeValidator(attr); @@ -53,7 +53,7 @@ public void ItExecutesValidationAttribute(Type attributeType, string validValue, arg.Reset(); arg.TryParse(invalidValue); var result = validator.GetValidationResult(arg, context); - Assert.NotNull(result); + Assert.NotNull(result?.ErrorMessage); Assert.NotEmpty(result.ErrorMessage); } @@ -61,7 +61,7 @@ public void ItExecutesValidationAttribute(Type attributeType, string validValue, [InlineData(typeof(ClassLevelValidationAttribute), "good", "also good", "bad", "also bad")] public void ItExecutesClassLevelValidationAttribute(Type attributeType, string validProp1Value, string validProp2Value, string invalidProp1Value, string invalidProp2Value) { - var attr = (ValidationAttribute)Activator.CreateInstance(attributeType); + var attr = Assert.IsAssignableFrom(Activator.CreateInstance(attributeType)); var app = new CommandLineApplication(); var validator = new AttributeValidator(attr); var factory = new CommandLineValidationContextFactory(app); @@ -76,7 +76,7 @@ public void ItExecutesClassLevelValidationAttribute(Type attributeType, string v app.Model.Arg2 = invalidProp2Value; var result = validator.GetValidationResult(app, context); - Assert.NotNull(result); + Assert.NotNull(result?.ErrorMessage); Assert.NotEmpty(result.ErrorMessage); } @@ -96,7 +96,7 @@ private void OnExecute() { } [InlineData("email@example.com", 0)] public void ValidatesEmailArgument(string? email, int exitCode) { - Assert.Equal(exitCode, CommandLineApplication.Execute(new TestConsole(_output), email)); + Assert.Equal(exitCode, CommandLineApplication.Execute(new TestConsole(_output), email!)); } private class OptionBuilderApp : CommandLineApplication @@ -165,7 +165,7 @@ public void ValidatesAttributesOnOption(string[] args, int exitCode) private sealed class ThrowingValidationAttribute : ValidationAttribute { - public override bool IsValid(object value) + public override bool IsValid(object? value) { throw new InvalidOperationException(); } @@ -182,7 +182,7 @@ private sealed class ClassLevelValidationApp [AttributeUsage(AttributeTargets.Class)] private sealed class ClassLevelValidationAttribute : ValidationAttribute { - public override bool IsValid(object value) + public override bool IsValid(object? value) => value is ClassLevelValidationApp app && app.Arg1 != null && app.Arg1.Contains("good") && app.Arg2 != null && app.Arg2.Contains("good"); @@ -191,7 +191,7 @@ public override bool IsValid(object value) [AttributeUsage(AttributeTargets.Property)] private sealed class ModeValidationAttribute : ValidationAttribute { - public override bool IsValid(object value) + public override bool IsValid(object? value) { return value is string text && text.Contains("mode"); } diff --git a/test/CommandLineUtils.Tests/CommandLineApplicationExecutorTests.cs b/test/CommandLineUtils.Tests/CommandLineApplicationExecutorTests.cs index 99b6cbfd..02f30d1b 100755 --- a/test/CommandLineUtils.Tests/CommandLineApplicationExecutorTests.cs +++ b/test/CommandLineUtils.Tests/CommandLineApplicationExecutorTests.cs @@ -158,8 +158,8 @@ public void ThrowsForUnknownOnExecuteTypes() var ex = Assert.Throws( () => CommandLineApplication.Execute()); var method = typeof(ExecuteWithUnknownTypes).GetMethod("OnExecute", BindingFlags.Instance | BindingFlags.NonPublic); - var param = Assert.Single(method.GetParameters()); - Assert.Equal(Strings.UnsupportedParameterTypeOnMethod(method.Name, param), ex.Message); + var param = Assert.Single(method?.GetParameters() ?? []); + Assert.Equal(Strings.UnsupportedParameterTypeOnMethod(method?.Name, param), ex.Message); } private class ExecuteAsyncWithInt @@ -312,7 +312,7 @@ public void Dispose() [Command("sub")] private class Subcommand { - public DisposableParentCommand Parent { get; } + public DisposableParentCommand? Parent { get; } public void OnExecute() { diff --git a/test/CommandLineUtils.Tests/CommandLineApplicationTests.cs b/test/CommandLineUtils.Tests/CommandLineApplicationTests.cs index c7d3d5c8..717ab5ed 100644 --- a/test/CommandLineUtils.Tests/CommandLineApplicationTests.cs +++ b/test/CommandLineUtils.Tests/CommandLineApplicationTests.cs @@ -492,7 +492,7 @@ public void AllowNoThrowBehaviorOnUnexpectedOptionAfterSubcommand() // (does not throw) app.Execute("k", "run", unexpectedOption); Assert.Empty(testCmd.RemainingArguments); - var arg = Assert.Single(subCmd?.RemainingArguments); + var arg = Assert.Single(subCmd?.RemainingArguments ?? []); Assert.Equal(unexpectedOption, arg); } @@ -697,9 +697,9 @@ public void NestedInheritedOptions() Assert.Contains(subcmd1.GetOptions(), o => o.LongName == "nest1"); Assert.Contains(subcmd1.GetOptions(), o => o.LongName == "global"); - Assert.Contains(subcmd2?.GetOptions(), o => o.LongName == "nest2"); - Assert.Contains(subcmd2?.GetOptions(), o => o.LongName == "nest1"); - Assert.Contains(subcmd2?.GetOptions(), o => o.LongName == "global"); + Assert.Contains(subcmd2?.GetOptions() ?? [], o => o.LongName == "nest2"); + Assert.Contains(subcmd2?.GetOptions() ?? [], o => o.LongName == "nest1"); + Assert.Contains(subcmd2?.GetOptions() ?? [], o => o.LongName == "global"); Assert.ThrowsAny(() => app.Execute("--nest2", "N2", "--nest1", "N1", "-g", "G")); Assert.ThrowsAny(() => app.Execute("lvl1", "--nest2", "N2", "--nest1", "N1", "-g", "G")); @@ -1051,7 +1051,7 @@ public void ThrowsExceptionOnInvalidArgument(string? inputOption) { var app = new CommandLineApplication(); - var exception = Assert.ThrowsAny(() => app.Execute(inputOption)); + var exception = Assert.ThrowsAny(() => app.Execute(inputOption!)); Assert.Equal($"Unrecognized command or argument '{inputOption}'", exception.Message); } diff --git a/test/CommandLineUtils.Tests/CustomValidationAttributeTest.cs b/test/CommandLineUtils.Tests/CustomValidationAttributeTest.cs index 0cd119f2..2f6692cd 100644 --- a/test/CommandLineUtils.Tests/CustomValidationAttributeTest.cs +++ b/test/CommandLineUtils.Tests/CustomValidationAttributeTest.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.ComponentModel.DataAnnotations; +using System.Linq; using Xunit; namespace McMaster.Extensions.CommandLineUtils.Tests @@ -12,7 +13,7 @@ public class CustomValidationAttributeTest [InlineData(null)] [InlineData("-c", "red")] [InlineData("-c", "blue")] - public void CustomValidationAttributePasses(params string?[] args) + public void CustomValidationAttributePasses(params string[]? args) { var app = new CommandLineApplication(); app.Conventions.UseDefaultConventions(); @@ -34,7 +35,7 @@ public void CustomValidationAttributeFails(params string?[] args) { var app = new CommandLineApplication(); app.Conventions.UseAttributes(); - var result = app.Parse(args); + var result = app.Parse(args.Select(a => a!).ToArray()); var validationResult = result.SelectedCommand.GetValidationResult(); Assert.NotEqual(ValidationResult.Success, validationResult); var program = Assert.IsType>(result.SelectedCommand); @@ -43,7 +44,7 @@ public void CustomValidationAttributeFails(params string?[] args) { Assert.Equal(args[1], app.Model.Color); } - Assert.Equal("The value for --color must be 'red' or 'blue'", validationResult.ErrorMessage); + Assert.Equal("The value for --color must be 'red' or 'blue'", validationResult?.ErrorMessage); } private class RedBlueProgram diff --git a/test/CommandLineUtils.Tests/DefaultHelpTextGeneratorTests.cs b/test/CommandLineUtils.Tests/DefaultHelpTextGeneratorTests.cs index 7f46adf4..e2084052 100644 --- a/test/CommandLineUtils.Tests/DefaultHelpTextGeneratorTests.cs +++ b/test/CommandLineUtils.Tests/DefaultHelpTextGeneratorTests.cs @@ -79,7 +79,7 @@ public void DoesNotOrderCommandsByName() Assert.True(indexOfA > indexOfB); } - private string GetHelpText(CommandLineApplication app, DefaultHelpTextGenerator generator, string helpOption = null) + private string GetHelpText(CommandLineApplication app, DefaultHelpTextGenerator generator, string? helpOption = null) { var sb = new StringBuilder(); app.Out = new StringWriter(sb); @@ -90,7 +90,7 @@ private string GetHelpText(CommandLineApplication app, DefaultHelpTextGenerator return helpText; } - private string GetHelpText(CommandLineApplication app, string helpOption = null) + private string GetHelpText(CommandLineApplication app, string? helpOption = null) { var generator = new DefaultHelpTextGenerator { @@ -232,12 +232,12 @@ SomeNullableEnumArgument nullable enum arg desc. public class MyApp { [Option(ShortName = "strOpt", ValueName = "STR_OPT", Description = "str option desc.")] - public string strOpt { get; set; } + public string? strOpt { get; set; } [Option(ShortName = "rStrOpt", ValueName = "STR_OPT", Description = "restricted str option desc.")] [Required] [AllowedValues("Foo", "Bar")] - public string rStrOpt { get; set; } + public string? rStrOpt { get; set; } [Option(ShortName = "dStrOpt", ValueName = "STR_OPT", Description = "str option with default value desc.")] public string dStrOpt { get; set; } = "Foo"; @@ -265,12 +265,12 @@ public class MyApp public SomeEnum Verb5 { get; set; } [Argument(0, Description = "string arg desc.")] - public string SomeStringArgument { get; set; } + public string? SomeStringArgument { get; set; } [Argument(1, Description = "restricted string arg desc.")] [Required] [AllowedValues("Foo", "Bar")] - public string RestrictedStringArgument { get; set; } + public string? RestrictedStringArgument { get; set; } [Argument(2, Description = "string arg with default value desc.")] public string DefaultValStringArgument { get; set; } = "Foo"; diff --git a/test/CommandLineUtils.Tests/FilePathExistsAttributeTests.cs b/test/CommandLineUtils.Tests/FilePathExistsAttributeTests.cs index 63fd189c..22f06da0 100644 --- a/test/CommandLineUtils.Tests/FilePathExistsAttributeTests.cs +++ b/test/CommandLineUtils.Tests/FilePathExistsAttributeTests.cs @@ -45,7 +45,7 @@ public void ValidatesFilesMustExist(string? filePath) .GetValidationResult(); Assert.NotEqual(ValidationResult.Success, result); - Assert.Equal($"The file path '{filePath}' does not exist.", result.ErrorMessage); + Assert.Equal($"The file path '{filePath}' does not exist.", result?.ErrorMessage); var console = new TestConsole(_output); Assert.NotEqual(0, CommandLineApplication.Execute(console, filePath!)); @@ -95,7 +95,7 @@ public void ValidatesFilesRelativeToAppContext() Assert.Equal(ValidationResult.Success, success); Assert.NotEqual(ValidationResult.Success, fails); - Assert.Equal("The file path 'exists.txt' does not exist.", fails.ErrorMessage); + Assert.Equal("The file path 'exists.txt' does not exist.", fails?.ErrorMessage); var console = new TestConsole(_output); var context = new DefaultCommandLineContext(console, appNotInBaseDir.WorkingDirectory, new[] { "exists.txt" }); diff --git a/test/CommandLineUtils.Tests/FilePathNotExistsAttributeTests.cs b/test/CommandLineUtils.Tests/FilePathNotExistsAttributeTests.cs index 79fff038..9a0374c4 100644 --- a/test/CommandLineUtils.Tests/FilePathNotExistsAttributeTests.cs +++ b/test/CommandLineUtils.Tests/FilePathNotExistsAttributeTests.cs @@ -51,7 +51,7 @@ public void ValidatesFilesMustNotExist(string filePath) .GetValidationResult(); Assert.NotEqual(ValidationResult.Success, result); - Assert.Equal($"The file path '{filePath}' already exists.", result.ErrorMessage); + Assert.Equal($"The file path '{filePath}' already exists.", result?.ErrorMessage); var console = new TestConsole(_output); Assert.NotEqual(0, CommandLineApplication.Execute(console, filePath)); @@ -90,7 +90,7 @@ public void ValidatesFilesRelativeToAppContext() .GetValidationResult(); Assert.NotEqual(ValidationResult.Success, fails); - Assert.Equal("The file path 'exists.txt' already exists.", fails.ErrorMessage); + Assert.Equal("The file path 'exists.txt' already exists.", fails?.ErrorMessage); Assert.Equal(ValidationResult.Success, success); diff --git a/test/CommandLineUtils.Tests/HelpOptionAttributeTests.cs b/test/CommandLineUtils.Tests/HelpOptionAttributeTests.cs index 7e40b5f5..b3fab333 100644 --- a/test/CommandLineUtils.Tests/HelpOptionAttributeTests.cs +++ b/test/CommandLineUtils.Tests/HelpOptionAttributeTests.cs @@ -3,6 +3,7 @@ using System; using System.IO; +using System.Reflection; using System.Text; using Xunit; using Xunit.Abstractions; @@ -90,7 +91,7 @@ public void ThrowsIfMultipleAttributesApplied() { var ex = Assert.Throws(() => new CommandLineApplication().Conventions.UseHelpOptionAttribute()); - var prop = typeof(DuplicateOptionAttributes).GetProperty(nameof(DuplicateOptionAttributes.IsHelpOption)); + var prop = Assert.IsAssignableFrom(typeof(DuplicateOptionAttributes).GetProperty(nameof(DuplicateOptionAttributes.IsHelpOption))); Assert.Equal(Strings.BothOptionAndHelpOptionAttributesCannotBeSpecified(prop), ex.Message); } diff --git a/test/CommandLineUtils.Tests/LegalFilePathAttributeTests.cs b/test/CommandLineUtils.Tests/LegalFilePathAttributeTests.cs index 3f2bf4ea..2bd7b02c 100644 --- a/test/CommandLineUtils.Tests/LegalFilePathAttributeTests.cs +++ b/test/CommandLineUtils.Tests/LegalFilePathAttributeTests.cs @@ -49,7 +49,7 @@ public void ValidatesLegalFilePaths(string filePath) public void FailsInvalidLegalFilePaths(string? filePath) { var console = new TestConsole(_output); - Assert.NotEqual(0, CommandLineApplication.Execute(console, filePath)); + Assert.NotEqual(0, CommandLineApplication.Execute(console, filePath!)); } } } diff --git a/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj b/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj index 317c17cd..bf7b9cb2 100644 --- a/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj +++ b/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj @@ -1,9 +1,13 @@  - net8.0;net10.0 + net8.0;net10.0;net472 - annotations + enable + + + + 12.0 @@ -27,6 +31,13 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/test/CommandLineUtils.Tests/OptionAttributeTests.cs b/test/CommandLineUtils.Tests/OptionAttributeTests.cs index feb8f475..91416c43 100644 --- a/test/CommandLineUtils.Tests/OptionAttributeTests.cs +++ b/test/CommandLineUtils.Tests/OptionAttributeTests.cs @@ -31,7 +31,7 @@ public void ThrowsWhenOptionTypeCannotBeDetermined() var ex = Assert.Throws( () => Create()); Assert.Equal( - Strings.CannotDetermineOptionType(typeof(AppWithUnknownOptionType).GetProperty("Option")), + Strings.CannotDetermineOptionType(Assert.IsAssignableFrom(typeof(AppWithUnknownOptionType).GetProperty("Option"))), ex.Message); } @@ -71,7 +71,7 @@ private class EmptyShortName public void CanSetShortNameToEmptyString() { var app = Create(); - Assert.All(app.Options, o => Assert.Empty(o.ShortName)); + Assert.All(app.Options, o => Assert.Empty(o.ShortName ?? "test")); } private class AmbiguousShortOptionName @@ -91,8 +91,8 @@ public void ThrowsWhenShortOptionNamesAreAmbiguous() Assert.Equal( Strings.OptionNameIsAmbiguous("m", - typeof(AmbiguousShortOptionName).GetProperty("Mode"), - typeof(AmbiguousShortOptionName).GetProperty("Message")), + Assert.IsAssignableFrom(typeof(AmbiguousShortOptionName).GetProperty("Mode")), + Assert.IsAssignableFrom(typeof(AmbiguousShortOptionName).GetProperty("Message"))), ex.Message); } @@ -113,8 +113,8 @@ public void ThrowsWhenLongOptionNamesAreAmbiguous() Assert.Equal( Strings.OptionNameIsAmbiguous("no-edit", - typeof(AmbiguousLongOptionName).GetProperty("NoEdit"), - typeof(AmbiguousLongOptionName).GetProperty("ManuallySetToNoEdit")), + Assert.IsAssignableFrom(typeof(AmbiguousLongOptionName).GetProperty("NoEdit")), + Assert.IsAssignableFrom(typeof(AmbiguousLongOptionName).GetProperty("ManuallySetToNoEdit"))), ex.Message); } @@ -133,7 +133,7 @@ public void ThrowsWhenOptionAndArgumentAreSpecified() Assert.Equal( Strings.BothOptionAndArgumentAttributesCannotBeSpecified( - typeof(BothOptionAndArgument).GetProperty("NotPossible")), + Assert.IsAssignableFrom(typeof(BothOptionAndArgument).GetProperty("NotPossible"))), ex.Message); } @@ -203,7 +203,7 @@ public void KeepsDefaultValues() private class AppWithMultiValueStringOption { [Option("-o1")] - string[] Opt1 { get; } + string[]? Opt1 { get; } [Option("-o2")] string[] Opt2 { get; } = Array.Empty(); @@ -341,12 +341,12 @@ private CommandOption CreateOption(Type propType, string propName) var tb = mb.DefineType("Program"); var pb = tb.DefineProperty(propName, PropertyAttributes.None, propType, Array.Empty()); tb.DefineField($"<{propName}>k__BackingField", propType, FieldAttributes.Private); - var ctor = typeof(OptionAttribute).GetConstructor(Array.Empty()); + var ctor = Assert.IsAssignableFrom(typeof(OptionAttribute).GetConstructor(Array.Empty())); var ab = new CustomAttributeBuilder(ctor, Array.Empty()); pb.SetCustomAttribute(ab); var program = tb.CreateType(); var appBuilder = typeof(CommandLineApplication<>).MakeGenericType(program); - var app = (CommandLineApplication)Activator.CreateInstance(appBuilder, Array.Empty()); + var app = Assert.IsAssignableFrom(Activator.CreateInstance(appBuilder, Array.Empty())); app.Conventions.UseOptionAttributes(); return app.Options.First(); } @@ -465,7 +465,7 @@ public void ApplyingOptionConventionTwice_WithLongOnlyOptions_DoesNotThrow() Assert.Single(app.Options, o => o.LongName == "count"); // Verify short names are empty - Assert.All(app.Options, o => Assert.Empty(o.ShortName)); + Assert.All(app.Options, o => Assert.Empty(o.ShortName ?? "test")); } #endregion diff --git a/test/CommandLineUtils.Tests/ResponseFileTests.cs b/test/CommandLineUtils.Tests/ResponseFileTests.cs index 90621e12..bb5867c9 100644 --- a/test/CommandLineUtils.Tests/ResponseFileTests.cs +++ b/test/CommandLineUtils.Tests/ResponseFileTests.cs @@ -269,7 +269,7 @@ public void SubcommandsCanResponseFileOptions() }); var rspFile = CreateResponseFile(" 'lorem ipsum' ", "dolor sit amet"); app.Execute("save", "@" + rspFile); - Assert.Collection(wordArgs?.Values, + Assert.Collection(wordArgs?.Values ?? [], a => Assert.Equal("lorem ipsum", a), a => Assert.Equal("dolor", a), a => Assert.Equal("sit", a), diff --git a/test/CommandLineUtils.Tests/StringExtensionsTests.cs b/test/CommandLineUtils.Tests/StringExtensionsTests.cs index ce107778..b435ae47 100644 --- a/test/CommandLineUtils.Tests/StringExtensionsTests.cs +++ b/test/CommandLineUtils.Tests/StringExtensionsTests.cs @@ -29,7 +29,7 @@ public class StringExtensionsTests [InlineData("m_Field", "m-field")] public void ToKebabCase(string? input, string? expected) { - Assert.Equal(expected, input.ToKebabCase()); + Assert.Equal(expected, input!.ToKebabCase()); } [Theory] @@ -41,7 +41,7 @@ public void ToKebabCase(string? input, string? expected) [InlineData("MSBuildTask", "MSBUILD_TASK")] public void ToConstantCase(string? input, string? expected) { - Assert.Equal(expected, input.ToConstantCase()); + Assert.Equal(expected, input!.ToConstantCase()); } } } diff --git a/test/CommandLineUtils.Tests/ValidateMethodConventionTests.cs b/test/CommandLineUtils.Tests/ValidateMethodConventionTests.cs index a37097eb..09609a2f 100644 --- a/test/CommandLineUtils.Tests/ValidateMethodConventionTests.cs +++ b/test/CommandLineUtils.Tests/ValidateMethodConventionTests.cs @@ -25,7 +25,7 @@ public void ValidatorAddedViaConvention() app.Conventions.UseOnValidateMethodFromModel(); var result = app.GetValidationResult(); Assert.NotEqual(ValidationResult.Success, result); - Assert.Equal("Failed", result.ErrorMessage); + Assert.Equal("Failed", result?.ErrorMessage); } private class ProgramWithBadOnValidate @@ -49,7 +49,7 @@ private class MainValidate [Option] public int? Middle { get; } - private ValidationResult OnValidate(ValidationContext context, CommandLineContext appContext) + private ValidationResult? OnValidate(ValidationContext context, CommandLineContext appContext) { if (this.Middle.HasValue && this.Middle < 0) { @@ -71,7 +71,7 @@ private class SubcommandValidate [Option] public int End { get; private set; } = Int32.MaxValue; - private ValidationResult OnValidate(ValidationContext context, CommandLineContext appContext) + private ValidationResult? OnValidate(ValidationContext context, CommandLineContext appContext) { if (this.Start >= this.End) { @@ -119,7 +119,7 @@ public void ValidatorShouldGetDeserializedModelInSubcommands(string args, string else { Assert.NotEqual(ValidationResult.Success, result); - Assert.Equal(error, result.ErrorMessage); + Assert.Equal(error, result?.ErrorMessage); } } } diff --git a/test/CommandLineUtils.Tests/ValidationTests.cs b/test/CommandLineUtils.Tests/ValidationTests.cs index b4cfee74..1797c971 100644 --- a/test/CommandLineUtils.Tests/ValidationTests.cs +++ b/test/CommandLineUtils.Tests/ValidationTests.cs @@ -50,7 +50,7 @@ public void ValidatorInvoked() app.OnValidate(_ => { called = true; - return ValidationResult.Success; + return ValidationResult.Success!; }); Assert.Equal(0, app.Execute()); Assert.True(called); diff --git a/test/CommandLineUtils.Tests/ValueParserProviderCustomTests.cs b/test/CommandLineUtils.Tests/ValueParserProviderCustomTests.cs index 77edfb54..a5295fae 100644 --- a/test/CommandLineUtils.Tests/ValueParserProviderCustomTests.cs +++ b/test/CommandLineUtils.Tests/ValueParserProviderCustomTests.cs @@ -144,7 +144,7 @@ public void DefaultCultureCanBeChanged(string property, string test, string cult app.Conventions.UseAttributes(); app.Parse(test); - var actual = (DateTimeOffset)typeof(DateParserProgram).GetProperty(property).GetMethod.Invoke(app.Model, null); + var actual = Assert.IsAssignableFrom(typeof(DateParserProgram).GetProperty(property)?.GetMethod?.Invoke(app.Model, null)); Assert.Equal(expected, actual); } diff --git a/test/CommandLineUtils.Tests/VersionOptionAttributeTests.cs b/test/CommandLineUtils.Tests/VersionOptionAttributeTests.cs index 2afe5da4..b5b7b54d 100644 --- a/test/CommandLineUtils.Tests/VersionOptionAttributeTests.cs +++ b/test/CommandLineUtils.Tests/VersionOptionAttributeTests.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Reflection; using Xunit; using Xunit.Abstractions; @@ -88,7 +89,7 @@ public void ThrowsIfMultipleAttributesApplied() { var ex = Assert.Throws(() => new CommandLineApplication().Conventions.UseVersionOptionAttribute()); - var prop = typeof(DuplicateOptionAttributes).GetProperty(nameof(DuplicateOptionAttributes.IsVersionOption)); + var prop = Assert.IsAssignableFrom(typeof(DuplicateOptionAttributes).GetProperty(nameof(DuplicateOptionAttributes.IsVersionOption))); Assert.Equal(Strings.BothOptionAndVersionOptionAttributesCannotBeSpecified(prop), ex.Message); } @@ -104,8 +105,7 @@ public void ThrowsIfHelpAndVersionAttributesApplied() { var ex = Assert.Throws(() => new CommandLineApplication().Conventions.UseVersionOptionAttribute()); - var prop = typeof(DuplicateOptionAttributes2).GetProperty(nameof(DuplicateOptionAttributes - .IsVersionOption)); + var prop = Assert.IsAssignableFrom(typeof(DuplicateOptionAttributes2).GetProperty(nameof(DuplicateOptionAttributes.IsVersionOption))); Assert.Equal(Strings.BothHelpOptionAndVersionOptionAttributesCannotBeSpecified(prop), ex.Message); } diff --git a/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj b/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj index 5989a3de..26f15771 100644 --- a/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj +++ b/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj @@ -1,7 +1,12 @@  - net8.0;net10.0 + net8.0;net10.0;net472 + enable + + + + 12.0 @@ -24,6 +29,13 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + From 138b42a79f87e474bb9ae41d6f13c810d70e3287 Mon Sep 17 00:00:00 2001 From: Simon Ensslen Date: Tue, 3 Feb 2026 08:17:02 +0100 Subject: [PATCH 02/21] only specify netframework on windows --- .../McMaster.Extensions.CommandLineUtils.csproj | 6 ++++-- .../McMaster.Extensions.Hosting.CommandLine.csproj | 6 ++++-- .../McMaster.Extensions.CommandLineUtils.Tests.csproj | 7 ++++--- .../McMaster.Extensions.Hosting.CommandLine.Tests.csproj | 7 +++++-- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj b/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj index e8ba8cc5..ff939b9a 100644 --- a/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj +++ b/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj @@ -1,7 +1,9 @@  - net8.0;net472 + <_IsWindows Condition="$([MSBuild]::IsOSPlatform('Windows'))">true + net8.0;net472 + net8.0 true true Command-line parsing API. @@ -21,7 +23,7 @@ McMaster.Extensions.CommandLineUtils.ArgumentEscaper README.md - + 12.0 diff --git a/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj b/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj index d53fa110..c4e84d5b 100644 --- a/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj +++ b/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj @@ -1,14 +1,16 @@  - net8.0;net472 + <_IsWindows Condition="$([MSBuild]::IsOSPlatform('Windows'))">true + net8.0;net472 + net8.0 true true Provides command-line parsing API integration with the generic host API (Microsoft.Extensions.Hosting). commandline;parsing;hosting - + 12.0 diff --git a/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj b/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj index bf7b9cb2..02e37a86 100644 --- a/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj +++ b/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj @@ -1,12 +1,13 @@  - net8.0;net10.0;net472 - + <_IsWindows Condition="$([MSBuild]::IsOSPlatform('Windows'))">true + net8.0;net10.0;net472 + net8.0;net10.0 enable - + 12.0 diff --git a/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj b/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj index 26f15771..1090d130 100644 --- a/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj +++ b/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj @@ -1,11 +1,14 @@  - net8.0;net10.0;net472 + <_IsWindows Condition="$([MSBuild]::IsOSPlatform('Windows'))">true + + net8.0;net10.0;net472 + net8.0;net10.0 enable - + 12.0 From 0dc700733396b93528ad3307006af7bb382d1a25 Mon Sep 17 00:00:00 2001 From: Simon Ensslen Date: Tue, 3 Feb 2026 08:18:42 +0100 Subject: [PATCH 03/21] use windows machines to build release --- .github/workflows/ci.yml | 2 +- .github/workflows/docs.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5b7c967..ebe73ffe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,7 @@ jobs: release: if: ${{ github.event.inputs.release }} needs: build - runs-on: ubuntu-latest + runs-on: windows-latest permissions: contents: write # for creating GitHub releases id-token: write # for NuGet trusted publishing (OIDC) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 3e74f011..bee8b7b5 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -17,7 +17,7 @@ on: jobs: generate_docs: - runs-on: ubuntu-latest + runs-on: windows-latest steps: - uses: actions/checkout@v6 From 6aadb1ea9c363ab987e4c910288dc472628a21ea Mon Sep 17 00:00:00 2001 From: Simon Ensslen Date: Tue, 3 Feb 2026 08:23:42 +0100 Subject: [PATCH 04/21] fix non windows targetframeworks setting --- .../McMaster.Extensions.CommandLineUtils.csproj | 3 ++- .../McMaster.Extensions.Hosting.CommandLine.csproj | 3 ++- .../McMaster.Extensions.CommandLineUtils.Tests.csproj | 3 ++- .../McMaster.Extensions.Hosting.CommandLine.Tests.csproj | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj b/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj index ff939b9a..abba97d0 100644 --- a/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj +++ b/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj @@ -2,8 +2,9 @@ <_IsWindows Condition="$([MSBuild]::IsOSPlatform('Windows'))">true + net8.0;net472 - net8.0 + net8.0 true true Command-line parsing API. diff --git a/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj b/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj index c4e84d5b..bde4e8d2 100644 --- a/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj +++ b/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj @@ -2,8 +2,9 @@ <_IsWindows Condition="$([MSBuild]::IsOSPlatform('Windows'))">true + net8.0;net472 - net8.0 + net8.0 true true Provides command-line parsing API integration with the generic host API (Microsoft.Extensions.Hosting). diff --git a/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj b/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj index 02e37a86..0201efed 100644 --- a/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj +++ b/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj @@ -2,8 +2,9 @@ <_IsWindows Condition="$([MSBuild]::IsOSPlatform('Windows'))">true + net8.0;net10.0;net472 - net8.0;net10.0 + net8.0;net10.0 enable diff --git a/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj b/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj index 1090d130..e33e4ee5 100644 --- a/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj +++ b/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj @@ -4,7 +4,7 @@ <_IsWindows Condition="$([MSBuild]::IsOSPlatform('Windows'))">true net8.0;net10.0;net472 - net8.0;net10.0 + net8.0;net10.0 enable From 9ac37dc31e5001b7d5a5771b68c3748938744595 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 07:12:32 +0000 Subject: [PATCH 05/21] Initial plan From 090a300df7ac8b11f84a1064891007e3fa30a1a2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 07:16:42 +0000 Subject: [PATCH 06/21] fix: disable strong naming for source generator project Source generators run in the compiler/IDE process and should not be strong-named. This fixes the Windows .NET Framework 4.7.2 test failures where the generator DLL was being signed with the same key as the main library, causing strong name verification errors. Co-authored-by: sensslen <3428860+sensslen@users.noreply.github.com> --- .../McMaster.Extensions.CommandLineUtils.Generators.csproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/CommandLineUtils.Generators/McMaster.Extensions.CommandLineUtils.Generators.csproj b/src/CommandLineUtils.Generators/McMaster.Extensions.CommandLineUtils.Generators.csproj index 2afadaf5..4a5164c0 100644 --- a/src/CommandLineUtils.Generators/McMaster.Extensions.CommandLineUtils.Generators.csproj +++ b/src/CommandLineUtils.Generators/McMaster.Extensions.CommandLineUtils.Generators.csproj @@ -10,6 +10,8 @@ false McMaster.Extensions.CommandLineUtils.Generators false + + false From f25d48f093214bfccf37443d473e563a5c1ba604 Mon Sep 17 00:00:00 2001 From: Simon Ensslen Date: Thu, 5 Feb 2026 08:21:30 +0100 Subject: [PATCH 07/21] use fixed versions of polysharp and remove langversion specification for .net framework --- .../McMaster.Extensions.CommandLineUtils.Generators.csproj | 1 - .../McMaster.Extensions.CommandLineUtils.csproj | 6 +----- .../McMaster.Extensions.Hosting.CommandLine.csproj | 6 +----- .../McMaster.Extensions.CommandLineUtils.Tests.csproj | 6 +----- .../McMaster.Extensions.Hosting.CommandLine.Tests.csproj | 6 +----- 5 files changed, 4 insertions(+), 21 deletions(-) diff --git a/src/CommandLineUtils.Generators/McMaster.Extensions.CommandLineUtils.Generators.csproj b/src/CommandLineUtils.Generators/McMaster.Extensions.CommandLineUtils.Generators.csproj index 2afadaf5..49a1a11b 100644 --- a/src/CommandLineUtils.Generators/McMaster.Extensions.CommandLineUtils.Generators.csproj +++ b/src/CommandLineUtils.Generators/McMaster.Extensions.CommandLineUtils.Generators.csproj @@ -2,7 +2,6 @@ netstandard2.0 - latest enable true true diff --git a/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj b/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj index abba97d0..f6919147 100644 --- a/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj +++ b/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj @@ -24,10 +24,6 @@ McMaster.Extensions.CommandLineUtils.ArgumentEscaper README.md - - 12.0 - - @@ -39,7 +35,7 @@ McMaster.Extensions.CommandLineUtils.ArgumentEscaper - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj b/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj index bde4e8d2..95c1ef2e 100644 --- a/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj +++ b/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj @@ -11,10 +11,6 @@ commandline;parsing;hosting - - 12.0 - - @@ -24,7 +20,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj b/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj index 0201efed..b0eab390 100644 --- a/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj +++ b/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj @@ -8,10 +8,6 @@ enable - - 12.0 - - @@ -34,7 +30,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj b/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj index e33e4ee5..5c925c68 100644 --- a/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj +++ b/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj @@ -8,10 +8,6 @@ enable - - 12.0 - - @@ -33,7 +29,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive From a74ae705b8bd3cb2773843a525796bff530997ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 07:29:59 +0000 Subject: [PATCH 08/21] Initial plan From 62f95050f45636364823092ccac6be679ee3ee5e Mon Sep 17 00:00:00 2001 From: Simon Ensslen Date: Thu, 5 Feb 2026 08:34:18 +0100 Subject: [PATCH 09/21] add some documentation and remove empty catch --- src/CommandLineUtils/Extensions/StringExtensions.cs | 8 ++++++++ src/CommandLineUtils/Internal/ReflectionHelper.cs | 9 +-------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/CommandLineUtils/Extensions/StringExtensions.cs b/src/CommandLineUtils/Extensions/StringExtensions.cs index bcc0b8bd..fb128474 100644 --- a/src/CommandLineUtils/Extensions/StringExtensions.cs +++ b/src/CommandLineUtils/Extensions/StringExtensions.cs @@ -7,7 +7,15 @@ namespace McMaster.Extensions.CommandLineUtils.Extensions { internal static class StringExtensions { + /// + /// A wrapper around that allows proper nullability annotation. + /// This is a workaround because .NET Framework assemblies are not nullability annotated. + /// public static bool IsNullOrEmpty([NotNullWhen(false)] this string? value) => string.IsNullOrEmpty(value); + /// + /// A wrapper around that allows proper nullability annotation. + /// This is a workaround because .NET Framework assemblies are not nullability annotated. + /// public static bool IsNullOrWhiteSpace([NotNullWhen(false)] this string? value) => string.IsNullOrWhiteSpace(value); } } diff --git a/src/CommandLineUtils/Internal/ReflectionHelper.cs b/src/CommandLineUtils/Internal/ReflectionHelper.cs index da7217d7..bd841805 100644 --- a/src/CommandLineUtils/Internal/ReflectionHelper.cs +++ b/src/CommandLineUtils/Internal/ReflectionHelper.cs @@ -205,14 +205,7 @@ public int GetHashCode(MethodInfo obj) #if NET_6_0_OR_GREATER return obj.HasMetadataToken() ? obj.GetMetadataToken().GetHashCode() : 0; #else - try - { - return obj.MetadataToken.GetHashCode(); - } - catch - { - return 0; - } + return obj.MetadataToken?.GetHashCode() ?? 0; #endif } } From 79de02d04d7db8e1bc98ee0cf497030f4b5611ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 07:36:55 +0000 Subject: [PATCH 10/21] fix: disable strong name signing for test assemblies to resolve .NET Framework build issues Co-authored-by: sensslen <3428860+sensslen@users.noreply.github.com> --- src/CommandLineUtils/Properties/InternalsVisibleTo.cs | 3 ++- test/Directory.Build.props | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/CommandLineUtils/Properties/InternalsVisibleTo.cs b/src/CommandLineUtils/Properties/InternalsVisibleTo.cs index 1e845853..8aa9c2ff 100644 --- a/src/CommandLineUtils/Properties/InternalsVisibleTo.cs +++ b/src/CommandLineUtils/Properties/InternalsVisibleTo.cs @@ -3,4 +3,5 @@ using System.Runtime.CompilerServices; -[assembly: InternalsVisibleTo("McMaster.Extensions.CommandLineUtils.Tests, PublicKey = 00240000048000009400000006020000002400005253413100040000010001001df0eba4297c8ffdf114a13714ad787744619dfb18e29191703f6f782d6a09e4a4cac35b8c768cbbd9ade8197bc0f66ec66fabc9071a206c8060af8b7a332236968d3ee44b90bd2f30d0edcb6150555c6f8d988e48234debaf2d427a08d7c06ba1343411142dc8ac996f7f7dbe0e93d13f17a7624db5400510e6144b0fd683b9")] +// Test assembly is not strong-named to avoid .NET Framework strong name verification issues +[assembly: InternalsVisibleTo("McMaster.Extensions.CommandLineUtils.Tests")] diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 358cb1a6..3049eb37 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -2,5 +2,7 @@ $(DefaultItemExcludes);TestResults\** + + false From b1bfee808ea42144d1e84141868d89aff8caceb3 Mon Sep 17 00:00:00 2001 From: Simon Ensslen Date: Thu, 5 Feb 2026 08:45:07 +0100 Subject: [PATCH 11/21] Fix hashCode generation --- src/CommandLineUtils/Internal/ReflectionHelper.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/CommandLineUtils/Internal/ReflectionHelper.cs b/src/CommandLineUtils/Internal/ReflectionHelper.cs index bd841805..9f5a25f1 100644 --- a/src/CommandLineUtils/Internal/ReflectionHelper.cs +++ b/src/CommandLineUtils/Internal/ReflectionHelper.cs @@ -205,7 +205,19 @@ public int GetHashCode(MethodInfo obj) #if NET_6_0_OR_GREATER return obj.HasMetadataToken() ? obj.GetMetadataToken().GetHashCode() : 0; #else - return obj.MetadataToken?.GetHashCode() ?? 0; + // see https://github.com/dotnet/dotnet/blob/b0f34d51fccc69fd334253924abd8d6853fad7aa/src/runtime/src/libraries/System.Reflection.TypeExtensions/src/System/Reflection/TypeExtensions.cs#L496 + int token = obj.MetadataToken; + + // Tokens have MSB = table index, 3 LSBs = row index + // row index of 0 is a nil token + const int rowMask = 0x00FFFFFF; + if ((token & rowMask) == 0) + { + // Nil token is returned for edge cases like typeof(byte[]).MetadataToken. + return 0; + } + + return token; #endif } } From 8ef3481e14e0a6d3d40f93b70981a278be891017 Mon Sep 17 00:00:00 2001 From: Simon Ensslen Date: Thu, 5 Feb 2026 08:51:55 +0100 Subject: [PATCH 12/21] Revert "fix: disable strong naming for source generator project" This reverts commit 090a300df7ac8b11f84a1064891007e3fa30a1a2. --- .../McMaster.Extensions.CommandLineUtils.Generators.csproj | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/CommandLineUtils.Generators/McMaster.Extensions.CommandLineUtils.Generators.csproj b/src/CommandLineUtils.Generators/McMaster.Extensions.CommandLineUtils.Generators.csproj index 30ab8ea2..49a1a11b 100644 --- a/src/CommandLineUtils.Generators/McMaster.Extensions.CommandLineUtils.Generators.csproj +++ b/src/CommandLineUtils.Generators/McMaster.Extensions.CommandLineUtils.Generators.csproj @@ -9,8 +9,6 @@ false McMaster.Extensions.CommandLineUtils.Generators false - - false From 755749f4080dcb960a95c066b840b285687a647e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 07:55:11 +0000 Subject: [PATCH 13/21] fix: add app.config to bypass strong name verification for .NET Framework tests Co-authored-by: sensslen <3428860+sensslen@users.noreply.github.com> --- .../McMaster.Extensions.CommandLineUtils.Tests.csproj | 1 + test/CommandLineUtils.Tests/app.config | 8 ++++++++ .../McMaster.Extensions.Hosting.CommandLine.Tests.csproj | 1 + test/Hosting.CommandLine.Tests/app.config | 8 ++++++++ 4 files changed, 18 insertions(+) create mode 100644 test/CommandLineUtils.Tests/app.config create mode 100644 test/Hosting.CommandLine.Tests/app.config diff --git a/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj b/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj index b0eab390..16b03d23 100644 --- a/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj +++ b/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj @@ -38,6 +38,7 @@ + diff --git a/test/CommandLineUtils.Tests/app.config b/test/CommandLineUtils.Tests/app.config new file mode 100644 index 00000000..9748f48d --- /dev/null +++ b/test/CommandLineUtils.Tests/app.config @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj b/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj index 5c925c68..50118408 100644 --- a/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj +++ b/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj @@ -37,6 +37,7 @@ + diff --git a/test/Hosting.CommandLine.Tests/app.config b/test/Hosting.CommandLine.Tests/app.config new file mode 100644 index 00000000..9748f48d --- /dev/null +++ b/test/Hosting.CommandLine.Tests/app.config @@ -0,0 +1,8 @@ + + + + + + + + From e1cc18b09aa28f599e700f274b73925665f2cfd5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 07:56:08 +0000 Subject: [PATCH 14/21] fix: restore InternalsVisibleTo public key requirement for strong-named assemblies Co-authored-by: sensslen <3428860+sensslen@users.noreply.github.com> --- src/CommandLineUtils/Properties/InternalsVisibleTo.cs | 3 +-- test/Directory.Build.props | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/CommandLineUtils/Properties/InternalsVisibleTo.cs b/src/CommandLineUtils/Properties/InternalsVisibleTo.cs index 8aa9c2ff..1e845853 100644 --- a/src/CommandLineUtils/Properties/InternalsVisibleTo.cs +++ b/src/CommandLineUtils/Properties/InternalsVisibleTo.cs @@ -3,5 +3,4 @@ using System.Runtime.CompilerServices; -// Test assembly is not strong-named to avoid .NET Framework strong name verification issues -[assembly: InternalsVisibleTo("McMaster.Extensions.CommandLineUtils.Tests")] +[assembly: InternalsVisibleTo("McMaster.Extensions.CommandLineUtils.Tests, PublicKey = 00240000048000009400000006020000002400005253413100040000010001001df0eba4297c8ffdf114a13714ad787744619dfb18e29191703f6f782d6a09e4a4cac35b8c768cbbd9ade8197bc0f66ec66fabc9071a206c8060af8b7a332236968d3ee44b90bd2f30d0edcb6150555c6f8d988e48234debaf2d427a08d7c06ba1343411142dc8ac996f7f7dbe0e93d13f17a7624db5400510e6144b0fd683b9")] diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 3049eb37..358cb1a6 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -2,7 +2,5 @@ $(DefaultItemExcludes);TestResults\** - - false From 117a1e1eefc208c12805b757ed43a881048cf102 Mon Sep 17 00:00:00 2001 From: Simon Ensslen Date: Thu, 5 Feb 2026 09:04:14 +0100 Subject: [PATCH 15/21] no need to ckeck for windows compilation for unit under test --- .../McMaster.Extensions.CommandLineUtils.csproj | 7 ++----- .../McMaster.Extensions.Hosting.CommandLine.csproj | 7 ++----- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj b/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj index f6919147..70f543d4 100644 --- a/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj +++ b/src/CommandLineUtils/McMaster.Extensions.CommandLineUtils.csproj @@ -1,10 +1,7 @@  - <_IsWindows Condition="$([MSBuild]::IsOSPlatform('Windows'))">true - - net8.0;net472 - net8.0 + netstandard2.0;net8.0 true true Command-line parsing API. @@ -33,7 +30,7 @@ McMaster.Extensions.CommandLineUtils.ArgumentEscaper - + all diff --git a/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj b/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj index 95c1ef2e..6b503df0 100644 --- a/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj +++ b/src/Hosting.CommandLine/McMaster.Extensions.Hosting.CommandLine.csproj @@ -1,10 +1,7 @@  - <_IsWindows Condition="$([MSBuild]::IsOSPlatform('Windows'))">true - - net8.0;net472 - net8.0 + netstandard2.0;net8.0 true true Provides command-line parsing API integration with the generic host API (Microsoft.Extensions.Hosting). @@ -18,7 +15,7 @@ - + all From fcdf6cb3a11ec2e034603177e31dc360018a29de Mon Sep 17 00:00:00 2001 From: Simon Ensslen Date: Thu, 5 Feb 2026 09:15:14 +0100 Subject: [PATCH 16/21] address more issues --- .github/workflows/docs.yml | 2 +- src/CommandLineUtils/Properties/NullabilityHelpers.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index bee8b7b5..3e74f011 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -17,7 +17,7 @@ on: jobs: generate_docs: - runs-on: windows-latest + runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/src/CommandLineUtils/Properties/NullabilityHelpers.cs b/src/CommandLineUtils/Properties/NullabilityHelpers.cs index 2ba73d2a..aa27683e 100644 --- a/src/CommandLineUtils/Properties/NullabilityHelpers.cs +++ b/src/CommandLineUtils/Properties/NullabilityHelpers.cs @@ -15,7 +15,7 @@ public NotNullWhenAttribute(bool returnValue) } // https://docs.microsoft.com/en-us/dotnet/api/system.diagnostics.codeanalysis.allownullattribute - [System.AttributeUsage(System.AttributeTargets.Field | System.AttributeTargets.Parameter | System.AttributeTargets.Property, Inherited=false)] + [System.AttributeUsage(System.AttributeTargets.Field | System.AttributeTargets.Parameter | System.AttributeTargets.Property, Inherited = false)] internal sealed class AllowNullAttribute : Attribute { } } #endif From 680e7f43375f99c87eae90217cb9f074ae71e3ef Mon Sep 17 00:00:00 2001 From: Simon Ensslen Date: Thu, 5 Feb 2026 21:21:56 +0100 Subject: [PATCH 17/21] clenup and check if code coverage instrumentation causes issue --- .github/workflows/ci.yml | 8 +++++++- build.ps1 | 11 +++++++++-- .../McMaster.Extensions.CommandLineUtils.Tests.csproj | 1 - test/CommandLineUtils.Tests/app.config | 8 -------- ...Master.Extensions.Hosting.CommandLine.Tests.csproj | 1 - test/Hosting.CommandLine.Tests/app.config | 8 -------- 6 files changed, 16 insertions(+), 21 deletions(-) delete mode 100644 test/CommandLineUtils.Tests/app.config delete mode 100644 test/Hosting.CommandLine.Tests/app.config diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebe73ffe..d92480a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,12 @@ jobs: 8.x 9.x 10.x - - name: Run build script + - name: Run build script windows + if: ${{ matrix.os == 'windows-latest' }} + id: build_script + run: ./build.ps1 -ci -skipCoverage + - name: Run build script non windows + if: ${{ matrix.os != 'windows-latest' }} id: build_script run: ./build.ps1 -ci - uses: actions/upload-artifact@v6 @@ -51,6 +56,7 @@ jobs: path: artifacts/ if-no-files-found: error - uses: codecov/codecov-action@v5 + if: ${{ matrix.os != 'windows-latest' }} with: name: unittests-${{ matrix.os }} fail_ci_if_error: true diff --git a/build.ps1 b/build.ps1 index 989f4708..88e60a69 100755 --- a/build.ps1 +++ b/build.ps1 @@ -4,7 +4,9 @@ param( [ValidateSet('Debug', 'Release')] $Configuration = $null, [switch] - $ci + $ci, + [switch] + $skipCoverage ) Set-StrictMode -Version 1 @@ -27,11 +29,16 @@ if ($ci) { $formatArgs += '--check' } +$coverageCollectArgument = '--collect:"XPlat Code Coverage"' +if( $skipCoverage ) { + $coverageCollectArgument = '' +} + exec dotnet tool run dotnet-format -- -v detailed @formatArgs "$PSScriptRoot/CommandLineUtils.sln" exec dotnet tool run dotnet-format -- -v detailed @formatArgs "$PSScriptRoot/docs/samples/samples.sln" exec dotnet build --configuration $Configuration '-warnaserror:CS1591' exec dotnet pack --no-build --configuration $Configuration -o $artifacts exec dotnet build --configuration $Configuration "$PSScriptRoot/docs/samples/samples.sln" -exec dotnet test --no-build --configuration $Configuration --collect:"XPlat Code Coverage" +exec dotnet test --no-build --configuration $Configuration $coverageCollectArgument write-host -f green 'BUILD SUCCEEDED' diff --git a/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj b/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj index 16b03d23..b0eab390 100644 --- a/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj +++ b/test/CommandLineUtils.Tests/McMaster.Extensions.CommandLineUtils.Tests.csproj @@ -38,7 +38,6 @@ - diff --git a/test/CommandLineUtils.Tests/app.config b/test/CommandLineUtils.Tests/app.config deleted file mode 100644 index 9748f48d..00000000 --- a/test/CommandLineUtils.Tests/app.config +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj b/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj index 50118408..5c925c68 100644 --- a/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj +++ b/test/Hosting.CommandLine.Tests/McMaster.Extensions.Hosting.CommandLine.Tests.csproj @@ -37,7 +37,6 @@ - diff --git a/test/Hosting.CommandLine.Tests/app.config b/test/Hosting.CommandLine.Tests/app.config deleted file mode 100644 index 9748f48d..00000000 --- a/test/Hosting.CommandLine.Tests/app.config +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - From 251de336d8fe0f3df079555dad6587d63bfc88ef Mon Sep 17 00:00:00 2001 From: Simon Ensslen Date: Thu, 5 Feb 2026 21:37:35 +0100 Subject: [PATCH 18/21] fix pipeline --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d92480a6..75444ac3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: runs-on: ${{ matrix.os }} outputs: - package_version: ${{ steps.build_script.outputs.package_version }} + package_version: ${{ steps.build_script_windows.outputs.package_version }} steps: - uses: actions/checkout@v6 @@ -43,11 +43,11 @@ jobs: 10.x - name: Run build script windows if: ${{ matrix.os == 'windows-latest' }} - id: build_script + id: build_script_windows run: ./build.ps1 -ci -skipCoverage - name: Run build script non windows if: ${{ matrix.os != 'windows-latest' }} - id: build_script + id: build_script_non_windows run: ./build.ps1 -ci - uses: actions/upload-artifact@v6 if: ${{ matrix.os == 'windows-latest' }} From 591d3415c91c0a6c5e1fa0da67ef6d70734436fc Mon Sep 17 00:00:00 2001 From: Simon Ensslen Date: Thu, 5 Feb 2026 21:43:28 +0100 Subject: [PATCH 19/21] fix CI --- .github/workflows/ci.yml | 13 ++++--------- build.ps1 | 8 ++++---- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75444ac3..5151e3e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: runs-on: ${{ matrix.os }} outputs: - package_version: ${{ steps.build_script_windows.outputs.package_version }} + package_version: ${{ steps.build_script.outputs.package_version }} steps: - uses: actions/checkout@v6 @@ -41,14 +41,9 @@ jobs: 8.x 9.x 10.x - - name: Run build script windows - if: ${{ matrix.os == 'windows-latest' }} - id: build_script_windows - run: ./build.ps1 -ci -skipCoverage - - name: Run build script non windows - if: ${{ matrix.os != 'windows-latest' }} - id: build_script_non_windows - run: ./build.ps1 -ci + - name: Run build script + id: build_script + run: ./build.ps1 -ci ${{ matrix.os == 'windows-latest' && '-skipCoverage' || '' }} - uses: actions/upload-artifact@v6 if: ${{ matrix.os == 'windows-latest' }} with: diff --git a/build.ps1 b/build.ps1 index 88e60a69..b8aa430d 100755 --- a/build.ps1 +++ b/build.ps1 @@ -29,9 +29,9 @@ if ($ci) { $formatArgs += '--check' } -$coverageCollectArgument = '--collect:"XPlat Code Coverage"' -if( $skipCoverage ) { - $coverageCollectArgument = '' +[string[]] $testArgs = @('--no-build', '--configuration', $Configuration) +if (!$skipCoverage) { + $testArgs += '--collect:"XPlat Code Coverage"' } exec dotnet tool run dotnet-format -- -v detailed @formatArgs "$PSScriptRoot/CommandLineUtils.sln" @@ -39,6 +39,6 @@ exec dotnet tool run dotnet-format -- -v detailed @formatArgs "$PSScriptRoot/doc exec dotnet build --configuration $Configuration '-warnaserror:CS1591' exec dotnet pack --no-build --configuration $Configuration -o $artifacts exec dotnet build --configuration $Configuration "$PSScriptRoot/docs/samples/samples.sln" -exec dotnet test --no-build --configuration $Configuration $coverageCollectArgument +exec dotnet test @testArgs write-host -f green 'BUILD SUCCEEDED' From d14b1926f3ddb581b9b914b00829bafc72ccf444 Mon Sep 17 00:00:00 2001 From: Simon Ensslen Date: Thu, 5 Feb 2026 21:46:47 +0100 Subject: [PATCH 20/21] attempt to fix CI again --- build.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.ps1 b/build.ps1 index b8aa430d..09ef477a 100755 --- a/build.ps1 +++ b/build.ps1 @@ -31,7 +31,7 @@ if ($ci) { [string[]] $testArgs = @('--no-build', '--configuration', $Configuration) if (!$skipCoverage) { - $testArgs += '--collect:"XPlat Code Coverage"' + $testArgs += "--collect:`"XPlat Code Coverage`"" } exec dotnet tool run dotnet-format -- -v detailed @formatArgs "$PSScriptRoot/CommandLineUtils.sln" From cd1b5857d5653c211a5a06b144b4ab75f0c94822 Mon Sep 17 00:00:00 2001 From: Simon Ensslen Date: Thu, 5 Feb 2026 21:49:27 +0100 Subject: [PATCH 21/21] fix ci again --- build.ps1 | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/build.ps1 b/build.ps1 index 09ef477a..2343b28a 100755 --- a/build.ps1 +++ b/build.ps1 @@ -39,6 +39,11 @@ exec dotnet tool run dotnet-format -- -v detailed @formatArgs "$PSScriptRoot/doc exec dotnet build --configuration $Configuration '-warnaserror:CS1591' exec dotnet pack --no-build --configuration $Configuration -o $artifacts exec dotnet build --configuration $Configuration "$PSScriptRoot/docs/samples/samples.sln" -exec dotnet test @testArgs + +if ($skipCoverage) { + exec dotnet test --no-build --configuration $Configuration +} else { + exec dotnet test --no-build --configuration $Configuration --collect:"XPlat Code Coverage" +} write-host -f green 'BUILD SUCCEEDED'