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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Nullean.Argh.Generator/CliParserGenerator.Schema.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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} }}");
}

Expand Down
97 changes: 83 additions & 14 deletions src/Nullean.Argh.Generator/CliParserGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -8130,20 +8143,26 @@ private static ImmutableArray<ValidationConstraint> OrderPathValidations(Immutab
return b.ToImmutable();
}

private static string ResolveEnumMemberCliName(ImmutableArray<string> cliNames, int index, string memberName)
=> !cliNames.IsDefaultOrEmpty ? cliNames[index] : memberName.ToLowerInvariant();

private static string? BuildValidationLine(ParameterModel p)
{
var tokens = new List<string>();

// 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<string>();
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) + ")");
}
Expand All @@ -8152,13 +8171,17 @@ private static ImmutableArray<ValidationConstraint> 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<string>();
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) + ")");
}
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -9362,6 +9384,8 @@ private sealed record ParameterModel(
string ElementTypeName = "string",
string? ElementEnumTypeFq = null,
ImmutableArray<string> ElementEnumMemberNames = default,
ImmutableArray<string> EnumMemberCliNames = default,
ImmutableArray<string> ElementEnumMemberCliNames = default,
string? ElementParserTypeFq = null,
string? ElementCustomValueTypeFq = null,
string? FullDeclaredTypeFq = null,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -9479,6 +9504,7 @@ private static ParameterModel BuildCollectionParameterModel(
ElementTypeName: elemTn,
ElementEnumTypeFq: eFq,
ElementEnumMemberNames: eMem,
ElementEnumMemberCliNames: eCliMem,
ElementParserTypeFq: pFq,
ElementCustomValueTypeFq: cFq,
FullDeclaredTypeFq: fq,
Expand Down Expand Up @@ -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(
Expand All @@ -9561,6 +9588,7 @@ public static ParameterModel From(IParameterSymbol p, SourceProductionContext? r
"",
null,
ImmutableArray<string>.Empty,
EnumMemberCliNames: enumCliNames,
EnumMemberDocs: enumDocs,
ExpandUserProfileBeforeBind: expandProf,
Validations: validations,
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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),
Expand Down Expand Up @@ -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(
Expand All @@ -9763,6 +9796,7 @@ public static ParameterModel FromAsParametersCtorParameter(
desc,
null,
ImmutableArray<string>.Empty,
EnumMemberCliNames: enumCliNames,
AsParametersOwnerParamName: methodParamName,
AsParametersMemberOrder: memberOrder,
AsParametersTypeFq: typeFq,
Expand Down Expand Up @@ -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(
Expand All @@ -9859,6 +9894,7 @@ public static ParameterModel FromAsParametersInitProperty(
doc.Description,
doc.ShortOpt,
doc.Aliases,
EnumMemberCliNames: enumCliNames,
AsParametersOwnerParamName: methodParamName,
AsParametersMemberOrder: memberOrder,
AsParametersTypeFq: typeFq,
Expand Down Expand Up @@ -10061,6 +10097,39 @@ private static ImmutableArray<string> GetEnumMemberNames(INamedTypeSymbol enumTy
return b.ToImmutable();
}

private static ImmutableArray<string> GetEnumMemberCliNames(INamedTypeSymbol enumType)
{
var hasAny = false;
var b = ImmutableArray.CreateBuilder<string>();
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<string> 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<string, string> GetEnumMemberDocs(INamedTypeSymbol enumType)
{
var b = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
Expand Down
14 changes: 14 additions & 0 deletions src/Nullean.Argh.Interfaces/Annotations/Attributes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,20 @@ public sealed class RejectSymbolicLinksAttribute : Attribute;
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)]
public sealed class ExpandUserProfileAttribute : Attribute;

/// <summary>
/// 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. <c>MyValue</c> → <c>myvalue</c>).
/// The value is matched case-insensitively at parse time.
/// </summary>
[AttributeUsage(AttributeTargets.Field)]
public sealed class EnumValueAttribute : Attribute
{
public EnumValueAttribute(string value) => Value = value;

/// <summary>The CLI string users type on the command line (e.g. <c>"fire-red"</c>).</summary>
public string Value { get; }
}

/// <summary>
/// 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 <c>__schema</c> output
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string>(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<string, string>(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<string, string>(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'.");
}
}
32 changes: 32 additions & 0 deletions tests/Nullean.Argh.IntegrationTests/Help/EnumValueHelpTests.cs
Original file line number Diff line number Diff line change
@@ -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<string, string>(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: <fire-red|ocean-blue|green>");
}

[Fact]
public void EnumValue_help_shows_kebab_case_in_usage_type_hint()
{
var result = CliHostRunner.Run(
new Dictionary<string, string>(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 <enum>");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions tests/Nullean.Argh.Tests/CliRegistrationModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading