diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.Schema.cs b/src/Nullean.Argh.Generator/CliParserGenerator.Schema.cs index 9b91a30..5e7f42b 100644 --- a/src/Nullean.Argh.Generator/CliParserGenerator.Schema.cs +++ b/src/Nullean.Argh.Generator/CliParserGenerator.Schema.cs @@ -379,7 +379,7 @@ private static string EmitCliParameterSchemaNewExpression(ParameterModel p) if (type == "enum" && !p.EnumMemberNames.IsDefaultOrEmpty) { - var enumArr = string.Join(", ", p.EnumMemberNames.Select(m => $"\"{Escape(m.ToLowerInvariant())}\"")); + var enumArr = string.Join(", ", p.EnumMemberNames.Select((m, i) => $"\"{Escape(ResolveEnumMemberCliName(p.EnumMemberCliNames, i, m))}\"")); sb.Append($", EnumValues: new string[] {{ {enumArr} }}"); } diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.cs b/src/Nullean.Argh.Generator/CliParserGenerator.cs index 97c6b62..6ddee6b 100644 --- a/src/Nullean.Argh.Generator/CliParserGenerator.cs +++ b/src/Nullean.Argh.Generator/CliParserGenerator.cs @@ -6799,6 +6799,7 @@ p with TypeName = p.ElementTypeName, EnumTypeFq = p.ElementEnumTypeFq, EnumMemberNames = p.ElementEnumMemberNames, + EnumMemberCliNames = p.ElementEnumMemberCliNames, ParserTypeFq = p.ElementParserTypeFq, CustomValueTypeFq = p.ElementCustomValueTypeFq, Special = BoolSpecialKind.None, @@ -7175,10 +7176,22 @@ private static void EmitParseFromString(StringBuilder sb, ParameterModel p, stri var e = Escape(p.CliLongName); string Out(string name) => outVarKeyword ? "out var " + name : "out " + name; - if (p.ScalarKind == CliScalarKind.Enum && p.EnumTypeFq is not null) + if (p.ScalarKind == CliScalarKind.Enum && p.EnumTypeFq is not null && !p.EnumMemberNames.IsDefaultOrEmpty) { var evVar = "__ev_" + p.LocalVarName; - sb.AppendLine($"{ind}if (!global::System.Enum.TryParse<{p.EnumTypeFq}>({rawExpr}, true, out var {evVar}) || !global::System.Enum.IsDefined(typeof({p.EnumTypeFq}), {evVar}))"); + var evParsed = "__evp_" + p.LocalVarName; + sb.AppendLine($"{ind}var {evParsed} = false;"); + sb.AppendLine($"{ind}{p.EnumTypeFq} {evVar} = default;"); + sb.AppendLine($"{ind}switch (({rawExpr} ?? \"\").ToLowerInvariant())"); + sb.AppendLine($"{ind}{{"); + for (var i = 0; i < p.EnumMemberNames.Length; i++) + { + var memberName = p.EnumMemberNames[i]; + var cliName = ResolveEnumMemberCliName(p.EnumMemberCliNames, i, memberName); + sb.AppendLine($"{ind}\tcase \"{Escape(cliName.ToLowerInvariant())}\": {evVar} = {p.EnumTypeFq}.{memberName}; {evParsed} = true; break;"); + } + sb.AppendLine($"{ind}}}"); + sb.AppendLine($"{ind}if (!{evParsed})"); sb.AppendLine($"{ind}{{"); sb.AppendLine($"{ind}\tConsole.Error.WriteLine($\"Error: invalid value for --{e}: '{{{rawExpr}}}'.\");"); EmitAfterCliParseErrorHelp(sb, p, $"{ind}\t", helpMethodName, flagHelpStdErrMethodName, parseFailureRunHint); @@ -8130,20 +8143,26 @@ private static ImmutableArray OrderPathValidations(Immutab return b.ToImmutable(); } + private static string ResolveEnumMemberCliName(ImmutableArray cliNames, int index, string memberName) + => !cliNames.IsDefaultOrEmpty ? cliNames[index] : memberName.ToLowerInvariant(); + private static string? BuildValidationLine(ParameterModel p) { var tokens = new List(); - // Enum members displayed as [allowed: …] on the validation line (lowercase invariant; parsing is case-insensitive) if (p.ScalarKind == CliScalarKind.Enum && !p.EnumMemberNames.IsDefaultOrEmpty) { - tokens.Add("One of: <" + string.Join("|", p.EnumMemberNames.Select(m => m.ToLowerInvariant())) + ">"); + tokens.Add("One of: <" + string.Join("|", p.EnumMemberNames.Select((m, i) => ResolveEnumMemberCliName(p.EnumMemberCliNames, i, m))) + ">"); if (p.EnumMemberDocs is { Count: > 0 } docs) { var memberDescParts = new List(); - foreach (var member in p.EnumMemberNames) + for (var i = 0; i < p.EnumMemberNames.Length; i++) + { + var member = p.EnumMemberNames[i]; + var cliName = ResolveEnumMemberCliName(p.EnumMemberCliNames, i, member); if (docs.TryGetValue(member, out var memberDoc) && !string.IsNullOrWhiteSpace(memberDoc)) - memberDescParts.Add($"{member.ToLowerInvariant()}: {memberDoc.Trim()}"); + memberDescParts.Add($"{cliName}: {memberDoc.Trim()}"); + } if (memberDescParts.Count > 0) tokens.Add("(" + string.Join("; ", memberDescParts) + ")"); } @@ -8152,13 +8171,17 @@ private static ImmutableArray OrderPathValidations(Immutab if (p.IsCollection && p.ElementScalarKind == CliScalarKind.Enum && !p.ElementEnumMemberNames.IsDefaultOrEmpty) { var label = p.CollectionTargetIsReadOnlySet ? "Combination of:" : "One or more of:"; - tokens.Add(label + " <" + string.Join("|", p.ElementEnumMemberNames.Select(m => m.ToLowerInvariant())) + ">"); + tokens.Add(label + " <" + string.Join("|", p.ElementEnumMemberNames.Select((m, i) => ResolveEnumMemberCliName(p.ElementEnumMemberCliNames, i, m))) + ">"); if (p.ElementEnumMemberDocs is { Count: > 0 } elemDocs) { var memberDescParts = new List(); - foreach (var member in p.ElementEnumMemberNames) + for (var i = 0; i < p.ElementEnumMemberNames.Length; i++) + { + var member = p.ElementEnumMemberNames[i]; + var cliName = ResolveEnumMemberCliName(p.ElementEnumMemberCliNames, i, member); if (elemDocs.TryGetValue(member, out var memberDoc) && !string.IsNullOrWhiteSpace(memberDoc)) - memberDescParts.Add($"{member.ToLowerInvariant()}: {memberDoc.Trim()}"); + memberDescParts.Add($"{cliName}: {memberDoc.Trim()}"); + } if (memberDescParts.Count > 0) tokens.Add("(" + string.Join("; ", memberDescParts) + ")"); } @@ -8619,12 +8642,11 @@ private static string FormatDefaultForHelp(ParameterModel p) if (p.ScalarKind == CliScalarKind.Enum && !p.EnumMemberNames.IsDefaultOrEmpty) { var lit = p.DefaultValueLiteral.Trim(); - foreach (var member in p.EnumMemberNames) + for (var i = 0; i < p.EnumMemberNames.Length; i++) { - if (string.Equals(lit, member, StringComparison.Ordinal)) - return member.ToLowerInvariant(); - if (lit.EndsWith("." + member, StringComparison.Ordinal)) - return member.ToLowerInvariant(); + var member = p.EnumMemberNames[i]; + if (string.Equals(lit, member, StringComparison.Ordinal) || lit.EndsWith("." + member, StringComparison.Ordinal)) + return ResolveEnumMemberCliName(p.EnumMemberCliNames, i, member); } } @@ -9362,6 +9384,8 @@ private sealed record ParameterModel( string ElementTypeName = "string", string? ElementEnumTypeFq = null, ImmutableArray ElementEnumMemberNames = default, + ImmutableArray EnumMemberCliNames = default, + ImmutableArray ElementEnumMemberCliNames = default, string? ElementParserTypeFq = null, string? ElementCustomValueTypeFq = null, string? FullDeclaredTypeFq = null, @@ -9440,6 +9464,7 @@ private static ParameterModel BuildCollectionParameterModel( { ClassifyScalarForType(elementType, attributeHost, BoolSpecialKind.None, out var elemSk, out var elemTn, out var eFq, out var eMem, out var pFq, out var cFq); + var eCliMem = elemSk == CliScalarKind.Enum ? TryGetEnumCliNames(elementType) : default; var elemEnumDocs = elemSk == CliScalarKind.Enum ? TryGetEnumDocs(elementType) : null; var sep = TryGetCollectionSeparatorFromAttribute(attributeHost); var required = isSeparateType @@ -9479,6 +9504,7 @@ private static ParameterModel BuildCollectionParameterModel( ElementTypeName: elemTn, ElementEnumTypeFq: eFq, ElementEnumMemberNames: eMem, + ElementEnumMemberCliNames: eCliMem, ElementParserTypeFq: pFq, ElementCustomValueTypeFq: cFq, FullDeclaredTypeFq: fq, @@ -9542,6 +9568,7 @@ public static ParameterModel From(IParameterSymbol p, SourceProductionContext? r var required = ComputeRequired(p, bs); var defLit = TryGetDefaultLiteral(p, bs); var enumDocs = sk == CliScalarKind.Enum ? TryGetEnumDocs(p.Type) : null; + var enumCliNames = sk == CliScalarKind.Enum ? TryGetEnumCliNames(p.Type) : default; var validations = ReadValidationConstraints(p, sk, typeName); var expandProf = TryReadExpandUserProfileBeforeBind(p, sk); return new ParameterModel( @@ -9561,6 +9588,7 @@ public static ParameterModel From(IParameterSymbol p, SourceProductionContext? r "", null, ImmutableArray.Empty, + EnumMemberCliNames: enumCliNames, EnumMemberDocs: enumDocs, ExpandUserProfileBeforeBind: expandProf, Validations: validations, @@ -9590,6 +9618,7 @@ public static ParameterModel FromOptionsProperty(IPropertySymbol prop, Compilati var isCrossAssemblyDefault = defaultValueLiteral is null && prop.DeclaringSyntaxReferences.IsEmpty; var required = !isCrossAssemblyDefault && ComputeRequiredForOptionsType(prop.Type, bs) && defaultValueLiteral is null; var enumDocs = sk == CliScalarKind.Enum ? TryGetEnumDocs(prop.Type) : null; + var enumCliNames = sk == CliScalarKind.Enum ? TryGetEnumCliNames(prop.Type) : default; var validations = ReadValidationConstraints(prop, sk, typeName); var defLit = QualifyOptionsEnumDefaultLiteral(defaultValueLiteral, sk, enumFq, enumMembers); var expandProf = TryReadExpandUserProfileBeforeBind(prop, sk); @@ -9610,6 +9639,7 @@ public static ParameterModel FromOptionsProperty(IPropertySymbol prop, Compilati doc.Description, doc.ShortOpt, doc.Aliases, + EnumMemberCliNames: enumCliNames, EnumMemberDocs: enumDocs, ExpandUserProfileBeforeBind: expandProf, Validations: validations, @@ -9638,6 +9668,7 @@ public static ParameterModel FromOptionsField(IFieldSymbol field, Compilation? c out var sk, out var typeName, out var enumFq, out var enumMembers, out var parserFq, out var customValFq); var isCrossAssemblyDefault = defaultValueLiteral is null && field.DeclaringSyntaxReferences.IsEmpty; var required = !isCrossAssemblyDefault && ComputeRequiredForOptionsType(field.Type, bs) && defaultValueLiteral is null; + var enumCliNames = sk == CliScalarKind.Enum ? TryGetEnumCliNames(field.Type) : default; var validations = ReadValidationConstraints(field, sk, typeName); var defLit = QualifyOptionsEnumDefaultLiteral(defaultValueLiteral, sk, enumFq, enumMembers); var expandProf = TryReadExpandUserProfileBeforeBind(field, sk); @@ -9658,6 +9689,7 @@ public static ParameterModel FromOptionsField(IFieldSymbol field, Compilation? c doc.Description, doc.ShortOpt, doc.Aliases, + EnumMemberCliNames: enumCliNames, ExpandUserProfileBeforeBind: expandProf, Validations: validations, IsHidden: HasHiddenAttribute(field), @@ -9744,6 +9776,7 @@ public static ParameterModel FromAsParametersCtorParameter( var required = ComputeRequired(cp, bs); var defLit = TryGetDefaultLiteral(cp, bs); + var enumCliNames = sk == CliScalarKind.Enum ? TryGetEnumCliNames(cp.Type) : default; var validations = ReadValidationConstraints(cp, sk, typeName); var expandProf = TryReadExpandUserProfileBeforeBind(cp, sk); return new ParameterModel( @@ -9763,6 +9796,7 @@ public static ParameterModel FromAsParametersCtorParameter( desc, null, ImmutableArray.Empty, + EnumMemberCliNames: enumCliNames, AsParametersOwnerParamName: methodParamName, AsParametersMemberOrder: memberOrder, AsParametersTypeFq: typeFq, @@ -9840,6 +9874,7 @@ public static ParameterModel FromAsParametersInitProperty( var isCrossAssemblyDefault = defaultValueLiteral is null && prop.DeclaringSyntaxReferences.IsEmpty; var required = !isCrossAssemblyDefault && ComputeRequiredForOptionsType(prop.Type, bs) && defaultValueLiteral is null; var defLit = QualifyOptionsEnumDefaultLiteral(defaultValueLiteral, sk, enumFq, enumMembers); + var enumCliNames = sk == CliScalarKind.Enum ? TryGetEnumCliNames(prop.Type) : default; var validations = ReadValidationConstraints(prop, sk, typeName); var expandProf = TryReadExpandUserProfileBeforeBind(prop, sk); return new ParameterModel( @@ -9859,6 +9894,7 @@ public static ParameterModel FromAsParametersInitProperty( doc.Description, doc.ShortOpt, doc.Aliases, + EnumMemberCliNames: enumCliNames, AsParametersOwnerParamName: methodParamName, AsParametersMemberOrder: memberOrder, AsParametersTypeFq: typeFq, @@ -10061,6 +10097,39 @@ private static ImmutableArray GetEnumMemberNames(INamedTypeSymbol enumTy return b.ToImmutable(); } + private static ImmutableArray GetEnumMemberCliNames(INamedTypeSymbol enumType) + { + var hasAny = false; + var b = ImmutableArray.CreateBuilder(); + foreach (var m in enumType.GetMembers()) + { + if (m is not IFieldSymbol { HasConstantValue: true, IsImplicitlyDeclared: false }) + continue; + string? cliName = null; + foreach (var attr in m.GetAttributes()) + { + if (attr.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::Nullean.Argh.EnumValueAttribute" + && attr.ConstructorArguments.Length > 0 + && attr.ConstructorArguments[0].Value is string v) + { + cliName = v; + hasAny = true; + break; + } + } + b.Add(cliName ?? m.Name.ToLowerInvariant()); + } + return hasAny ? b.ToImmutable() : default; + } + + private static ImmutableArray TryGetEnumCliNames(ITypeSymbol type) + { + var t = type; + if (t is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nn) + t = nn.TypeArguments[0]; + return t is INamedTypeSymbol { TypeKind: TypeKind.Enum } en ? GetEnumMemberCliNames(en) : default; + } + private static ImmutableDictionary GetEnumMemberDocs(INamedTypeSymbol enumType) { var b = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); diff --git a/src/Nullean.Argh.Interfaces/Annotations/Attributes.cs b/src/Nullean.Argh.Interfaces/Annotations/Attributes.cs index f559e9b..0561fa4 100644 --- a/src/Nullean.Argh.Interfaces/Annotations/Attributes.cs +++ b/src/Nullean.Argh.Interfaces/Annotations/Attributes.cs @@ -168,6 +168,20 @@ public sealed class RejectSymbolicLinksAttribute : Attribute; [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] public sealed class ExpandUserProfileAttribute : Attribute; +/// +/// Overrides the CLI string used to parse and display an enum member. +/// Without this attribute the CLI string is the member name lowercased (e.g. MyValuemyvalue). +/// The value is matched case-insensitively at parse time. +/// +[AttributeUsage(AttributeTargets.Field)] +public sealed class EnumValueAttribute : Attribute +{ + public EnumValueAttribute(string value) => Value = value; + + /// The CLI string users type on the command line (e.g. "fire-red"). + public string Value { get; } +} + /// /// Marks a command method or parameter as hidden from user-facing help and autocomplete suggestions. /// The command or parameter still parses and works correctly, and appears in __schema output diff --git a/tests/Nullean.Argh.IntegrationTests/Commands/EnumValueParseTests.cs b/tests/Nullean.Argh.IntegrationTests/Commands/EnumValueParseTests.cs new file mode 100644 index 0000000..a5540ba --- /dev/null +++ b/tests/Nullean.Argh.IntegrationTests/Commands/EnumValueParseTests.cs @@ -0,0 +1,47 @@ +using FluentAssertions; +using Nullean.Argh.IntegrationTests.Infrastructure; +using Xunit; + +namespace Nullean.Argh.IntegrationTests.Commands; + +public class EnumValueParseTests +{ + [Theory] + [InlineData("fire-red", "FireRed")] + [InlineData("FIRE-RED", "FireRed")] + [InlineData("ocean-blue", "OceanBlue")] + [InlineData("green", "Green")] + public void EnumValue_custom_cli_strings_parse_to_correct_member(string cliInput, string expectedMember) + { + var result = CliHostRunner.Run( + new Dictionary(StringComparer.Ordinal) { ["NO_COLOR"] = "1" }, + "enum-value-cmd", + "--palette", cliInput); + result.ExitCode.Should().Be(0); + ConsoleOutput.Normalize(CliHostRunner.StdoutText(result)).Should().Contain($"enum-value:{expectedMember}"); + } + + [Fact] + public void EnumValue_old_identifier_name_is_rejected() + { + var result = CliHostRunner.Run( + new Dictionary(StringComparer.Ordinal) { ["NO_COLOR"] = "1" }, + "enum-value-cmd", + "--palette", "firered"); + result.ExitCode.Should().Be(2); + var err = ConsoleOutput.Normalize(CliHostRunner.StderrText(result)); + err.Should().Contain("Error: invalid value for --palette: 'firered'."); + } + + [Fact] + public void EnumValue_invalid_value_reports_error_with_flag_name() + { + var result = CliHostRunner.Run( + new Dictionary(StringComparer.Ordinal) { ["NO_COLOR"] = "1" }, + "enum-value-cmd", + "--palette", "purple"); + result.ExitCode.Should().Be(2); + var err = ConsoleOutput.Normalize(CliHostRunner.StderrText(result)); + err.Should().Contain("Error: invalid value for --palette: 'purple'."); + } +} diff --git a/tests/Nullean.Argh.IntegrationTests/Help/EnumValueHelpTests.cs b/tests/Nullean.Argh.IntegrationTests/Help/EnumValueHelpTests.cs new file mode 100644 index 0000000..fd013da --- /dev/null +++ b/tests/Nullean.Argh.IntegrationTests/Help/EnumValueHelpTests.cs @@ -0,0 +1,32 @@ +using FluentAssertions; +using Nullean.Argh.IntegrationTests.Infrastructure; +using Xunit; + +namespace Nullean.Argh.IntegrationTests.Help; + +public class EnumValueHelpTests +{ + [Fact] + public void EnumValue_help_shows_kebab_case_cli_names() + { + var result = CliHostRunner.Run( + new Dictionary(StringComparer.Ordinal) { ["NO_COLOR"] = "1" }, + "enum-value-cmd", + "--help"); + result.ExitCode.Should().Be(0); + var text = ConsoleOutput.Normalize(CliHostRunner.StdoutText(result)); + text.Should().Contain("One of: "); + } + + [Fact] + public void EnumValue_help_shows_kebab_case_in_usage_type_hint() + { + var result = CliHostRunner.Run( + new Dictionary(StringComparer.Ordinal) { ["NO_COLOR"] = "1" }, + "enum-value-cmd", + "--help"); + result.ExitCode.Should().Be(0); + var text = ConsoleOutput.Normalize(CliHostRunner.StdoutText(result)); + text.Should().Contain("--palette "); + } +} diff --git a/tests/Nullean.Argh.IntegrationTests/Help/RootAndNamespaceHelpTests.cs b/tests/Nullean.Argh.IntegrationTests/Help/RootAndNamespaceHelpTests.cs index b529d8f..ca4b0f1 100644 --- a/tests/Nullean.Argh.IntegrationTests/Help/RootAndNamespaceHelpTests.cs +++ b/tests/Nullean.Argh.IntegrationTests/Help/RootAndNamespaceHelpTests.cs @@ -65,6 +65,8 @@ with an enum arg before it. (XML appears in help). dry-run-cmd enum-cmd Enum and short options. + enum-value-cmd Enum with custom CLI value strings via + EnumValueAttribute. ext-ns-as-params-echo Echo verbose and tag from an [AsParameters] DTO in an unrelated external namespace. diff --git a/tests/Nullean.Argh.IntegrationTests/Help/RootHelpFullTextTests.cs b/tests/Nullean.Argh.IntegrationTests/Help/RootHelpFullTextTests.cs index c26df68..2690336 100644 --- a/tests/Nullean.Argh.IntegrationTests/Help/RootHelpFullTextTests.cs +++ b/tests/Nullean.Argh.IntegrationTests/Help/RootHelpFullTextTests.cs @@ -66,6 +66,8 @@ with an enum arg before it. (XML appears in help). dry-run-cmd enum-cmd Enum and short options. + enum-value-cmd Enum with custom CLI value strings via + EnumValueAttribute. ext-ns-as-params-echo Echo verbose and tag from an [AsParameters] DTO in an unrelated external namespace. diff --git a/tests/Nullean.Argh.Tests/CliRegistrationModule.cs b/tests/Nullean.Argh.Tests/CliRegistrationModule.cs index 02f28b1..c1d0013 100644 --- a/tests/Nullean.Argh.Tests/CliRegistrationModule.cs +++ b/tests/Nullean.Argh.Tests/CliRegistrationModule.cs @@ -17,6 +17,7 @@ internal static void RegisterCommands() app.Map("hello", CliTestHandlers.Hello); app.Map("nin-hello", CliTestHandlers.NinHello); app.Map("enum-cmd", CliTestHandlers.EnumCmd); + app.Map("enum-value-cmd", CliTestHandlers.EnumValueCmd); app.Map("deploy", CliTestHandlers.Deploy); app.Map("as-params-with-ct", CliTestHandlers.AsParamsWithCt); app.Map("nullable-numeric-as-params", CliTestHandlers.NullableNumericAsParams); diff --git a/tests/Nullean.Argh.Tests/Fixtures/CliTestHandlers.cs b/tests/Nullean.Argh.Tests/Fixtures/CliTestHandlers.cs index 1ca63e5..f0f0569 100644 --- a/tests/Nullean.Argh.Tests/Fixtures/CliTestHandlers.cs +++ b/tests/Nullean.Argh.Tests/Fixtures/CliTestHandlers.cs @@ -15,6 +15,18 @@ internal enum TestColor Blue } +internal enum TestPalette +{ + /// A warm red hue. + [EnumValue("fire-red")] + FireRed, + /// A deep ocean blue. + [EnumValue("ocean-blue")] + OceanBlue, + /// Lush green. + Green +} + /// Sample record bound via . /// Deployment environment name. /// Listen port. @@ -98,6 +110,12 @@ public static void NinHello(string name) => public static void EnumCmd(TestGlobalCliOptions g, TestColor color, string name) => Console.Out.WriteLine($"ok:{color}:{name}"); + /// Enum with custom CLI value strings via EnumValueAttribute. + /// Injected global CLI options. + /// Pick a palette color using kebab-case CLI values. + public static void EnumValueCmd(TestGlobalCliOptions g, TestPalette palette) => + Console.Out.WriteLine($"enum-value:{palette}"); + public static void Deploy(TestGlobalCliOptions g, [AsParameters("app")] DeployCliArgs args) => Console.Out.WriteLine($"deploy:{args.Env}:{args.Port}");