Skip to content

Commit

Permalink
* Add ability to specify order of positional parameters to be differe…
Browse files Browse the repository at this point in the history
…nt from declaration.

* Change two instances of InternalErrorException to a specialized exception (UnsupportedTypeException and InvalidOrderOfPositionalParametersException).
  • Loading branch information
Timwi committed Oct 10, 2024
1 parent 0ba58e0 commit a0face6
Show file tree
Hide file tree
Showing 6 changed files with 143 additions and 21 deletions.
10 changes: 6 additions & 4 deletions Src/Attributes.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using RT.Util;
using RT.Util;
using RT.Util.Consoles;

namespace RT.CommandLine;
Expand All @@ -18,11 +18,13 @@ public sealed class OptionAttribute(params string[] names) : Attribute
/// <summary>
/// Use this to specify that a command-line parameter is positional, i.e. is not invoked by an option that starts with
/// "-".</summary>
/// <param name="order">
/// Optionally use this to re-order positional arguments differently from their declaration order.</param>
[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false), RummageKeepUsersReflectionSafe]
public sealed class IsPositionalAttribute : Attribute
public sealed class IsPositionalAttribute(double order = 0) : Attribute
{
/// <summary>Constructor.</summary>
public IsPositionalAttribute() { }
/// <summary>Optionally use this to re-order positional arguments differently from their declaration order.</summary>
public double Order { get; private set; } = order;
}

/// <summary>Use this to specify that a command-line parameter is mandatory.</summary>
Expand Down
54 changes: 37 additions & 17 deletions Src/CommandLineParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,9 @@ private sealed class PositionalParameterInfo
{
public Action ProcessParameter;
public Action ProcessEndOfParameters;
public double Order;
public bool IsMandatory;
public FieldInfo Field;
}

/// <summary>
Expand Down Expand Up @@ -215,31 +218,27 @@ private static object parseCommandLine(string[] args, Type type, int i, Func<Con
var positionals = new List<PositionalParameterInfo>();
var missingMandatories = new List<FieldInfo>();
FieldInfo swallowingField = null;
var haveSeenOptionalPositional = false;

foreach (var field in type.GetCommandLineFields())
{
var positional = field.IsDefined<IsPositionalAttribute>();
var positional = field.GetCustomAttribute<IsPositionalAttribute>()?.Order;
var option = field.GetCustomAttributes<OptionAttribute>().FirstOrDefault();
var mandatory = field.IsDefined<IsMandatoryAttribute>();

if (positional && mandatory && haveSeenOptionalPositional)
throw new InternalErrorException("Cannot have positional mandatory parameter after a positional optional one.");

if (positional && !mandatory)
haveSeenOptionalPositional = true;

if (mandatory)
missingMandatories.Add(field);

// ### ENUM fields
if (field.FieldType.IsEnum)
{
// ### ENUM fields, positional
if (positional)
if (positional is double order)
{
positionals.Add(new PositionalParameterInfo
{
Order = order,
IsMandatory = mandatory,
Field = field,
ProcessParameter = () =>
{
positionals.RemoveAt(0);
Expand Down Expand Up @@ -354,10 +353,13 @@ private static object parseCommandLine(string[] args, Type type, int i, Func<Con
else if (field.FieldType == typeof(string) || ExactConvert.IsTrueIntegerType(field.FieldType) || ExactConvert.IsTrueIntegerNullableType(field.FieldType) ||
field.FieldType == typeof(float) || field.FieldType == typeof(float?) || field.FieldType == typeof(double) || field.FieldType == typeof(double?))
{
if (positional)
if (positional is double order)
{
positionals.Add(new PositionalParameterInfo
{
Order = order,
IsMandatory = mandatory,
Field = field,
ProcessParameter = () =>
{
if (!convertStringAndSetField(args[i], ret, field))
Expand Down Expand Up @@ -396,10 +398,13 @@ private static object parseCommandLine(string[] args, Type type, int i, Func<Con
// ### STRING[] fields
else if (field.FieldType == typeof(string[]))
{
if (positional)
if (positional is double order)
{
positionals.Add(new PositionalParameterInfo
{
Order = order,
IsMandatory = mandatory,
Field = field,
ProcessParameter = () =>
{
missingMandatories.Remove(field);
Expand Down Expand Up @@ -443,6 +448,9 @@ private static object parseCommandLine(string[] args, Type type, int i, Func<Con
swallowingField = field;
positionals.Add(new PositionalParameterInfo
{
Order = positional ?? 0,
IsMandatory = mandatory,
Field = field,
ProcessParameter = () =>
{
missingMandatories.Remove(field);
Expand All @@ -465,9 +473,14 @@ private static object parseCommandLine(string[] args, Type type, int i, Func<Con
}
else
// This only happens if the post-build check didn't run
throw new InternalErrorException($"{type.FullName}.{field.Name} is not of a supported type.");
throw new UnsupportedTypeException($"{type.Name}.{field.Name}", getHelpGenerator(type, helpProcessor));
}

positionals = positionals.OrderBy(p => p.Order).ToList(); // Don’t use List<T>.Sort because it’s not a stable sort
for (var pIx = 0; pIx < positionals.Count - 1; pIx++)
if (positionals[pIx + 1].IsMandatory && !positionals[pIx].IsMandatory)
throw new InvalidOrderOfPositionalParametersException(positionals[pIx].Field, positionals[pIx + 1].Field, getHelpGenerator(type, helpProcessor));

bool suppressOptions = false;

while (i < args.Length)
Expand Down Expand Up @@ -500,16 +513,17 @@ private static object parseCommandLine(string[] args, Type type, int i, Func<Con

if (ret is ICommandLineValidatable v)
{
ConsoleColoredString error = null;
try
{
var error = v.Validate();
if (error != null)
throw new CommandLineValidationException(error, getHelpGenerator(type, helpProcessor));
error = v.Validate();
}
catch (CommandLineValidationException exc) when (exc.GenerateHelpFunc == null)
{
throw new CommandLineValidationException(exc.ColoredMessage, getHelpGenerator(type, helpProcessor));
error = exc.ColoredMessage;
}
if (error != null)
throw new CommandLineValidationException(error, getHelpGenerator(type, helpProcessor));
}

return ret;
Expand Down Expand Up @@ -747,13 +761,19 @@ private static void getFieldsForHelp(Type type, out List<FieldInfo> optionalOpti
optionalPositional = [];
mandatoryPositional = [];

foreach (var field in type.GetCommandLineFields().Where(f => !f.IsDefined<UndocumentedAttribute>()))
foreach (var field in type.GetCommandLineFields())
{
if (field.IsDefined<UndocumentedAttribute>())
continue;
var fieldInfos = field.IsDefined<IsMandatoryAttribute>()
? (field.IsDefined<IsPositionalAttribute>() ? mandatoryPositional : mandatoryOptions)
: (field.IsDefined<IsPositionalAttribute>() ? optionalPositional : optionalOptions);
fieldInfos.Add(field);
}

// Don’t use List<T>.Sort because it’s not a stable sort
mandatoryPositional = mandatoryPositional.OrderBy(f => f.GetCustomAttribute<IsPositionalAttribute>().Order).ToList();
optionalPositional = optionalPositional.OrderBy(f => f.GetCustomAttribute<IsPositionalAttribute>().Order).ToList();
}

private static ConsoleColoredString getDocumentation(MemberInfo member, Func<ConsoleColoredString, ConsoleColoredString> helpProcessor) =>
Expand Down
22 changes: 22 additions & 0 deletions Src/Exceptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,28 @@ public sealed class InvalidNumericParameterException(string fieldName, Func<int,
public string FieldName { get; private set; } = fieldName;
}

/// <summary>Specifies that a field in the class declaration has a type not supported by <see cref="CommandLineParser"/>.</summary>
[Serializable]
public sealed class UnsupportedTypeException(string fieldName, Func<int, ConsoleColoredString> helpGenerator, Exception inner = null)
: CommandLineParseException("The field {0} is of an unsupported type.".ToConsoleColoredString().Fmt("<".Color(CmdLineColor.FieldBrackets) + fieldName.Color(CmdLineColor.Field) + ">".Color(CmdLineColor.FieldBrackets)), helpGenerator, inner)
{
/// <summary>Contains the name of the field pertaining to the parameter that was passed an invalid value.</summary>
public string FieldName { get; private set; } = fieldName;
}

/// <summary>Indicates that a mandatory positional parameter is defined to come after an optional positional parameter, which is not possible.</summary>
[Serializable]
public sealed class InvalidOrderOfPositionalParametersException(FieldInfo fieldOptional, FieldInfo fieldMandatory, Func<int, ConsoleColoredString> helpGenerator, Exception inner = null)
: CommandLineParseException("The positional parameter {0} is optional, but is followed by positional parameter {1} which is mandatory. Either mark {0} as mandatory or {1} as optional.".ToConsoleColoredString().Fmt(colorizedFieldName(fieldOptional), colorizedFieldName(fieldMandatory)), helpGenerator, inner)
{
private static ConsoleColoredString colorizedFieldName(FieldInfo f) => "<".Color(CmdLineColor.FieldBrackets) + f.DeclaringType.Name.Color(CmdLineColor.Field) + ".".Color(CmdLineColor.FieldBrackets) + f.Name.Color(CmdLineColor.Field) + ">".Color(CmdLineColor.FieldBrackets);

/// <summary>Contains the name of the optional positional parameter that was followed by a mandatory positional parameter.</summary>
public FieldInfo FieldOptional { get; private set; } = fieldOptional;
/// <summary>Contains the name of the mandatory positional parameter that followed an optional positional parameter.</summary>
public FieldInfo FieldMandatory { get; private set; } = fieldMandatory;
}

/// <summary>Indicates that the arguments specified by the user on the command-line do not pass the custom validation check.</summary>
[Serializable]
public sealed class CommandLineValidationException : CommandLineParseException
Expand Down
34 changes: 34 additions & 0 deletions Tests/CommandLineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,40 @@ public static void TestPostBuild()
CommandLineParser.PostBuildStep<Test3Cmd>(reporter);
}

[Fact]
public static void TestPositionalOrder()
{
static void Test<T>(string helpPart, int[] oneTwoThree)
{
try { CommandLineParser.Parse<T>([]); }
catch (CommandLineParseException e) { Assert.Matches($@"\AUsage: .* {helpPart}", e.GenerateHelp().ToString()); }
dynamic cmd = CommandLineParser.Parse<T>(["1", "2", "3"]);
Assert.Equal(oneTwoThree[0], (int) cmd.One);
Assert.Equal(oneTwoThree[1], (int) cmd.Two);
Assert.Equal(oneTwoThree[2], (int) cmd.Three);
}

Test<Test4Cmd1>("<One> <Two> <Three>", [1, 2, 3]);
Test<Test4Cmd2>("<Three> <One> <Two>", [2, 3, 1]);
Test<Test4Cmd3>("<Two> <One> <Three>", [2, 1, 3]);
}

[Fact]
public static void TestPositionalMandatory()
{
// Mandatory, then optional — allowed
var cmd = CommandLineParser.Parse<Test5Cmd1>(["1", "2"]);
Assert.Equal(2, cmd.One);
Assert.Equal(1, cmd.Two);
var cmd2 = CommandLineParser.Parse<Test5Cmd1>(["8472"]);
Assert.Equal(47, cmd2.One);
Assert.Equal(8472, cmd2.Two);

// Optional, then mandatory — expect exception
var exc = Assert.Throws<InvalidOrderOfPositionalParametersException>(() => CommandLineParser.Parse<Test5Cmd2>(["1", "2"]));
Assert.Equal("The positional parameter <Test5Cmd2.Two> is optional, but is followed by positional parameter <Test5Cmd2.One> which is mandatory. Either mark <Test5Cmd2.Two> as mandatory or <Test5Cmd2.One> as optional.", exc.Message);
}

class Reporter : IPostBuildReporter
{
public void Error(string message, params string[] tokens) => throw new Exception(message);
Expand Down
27 changes: 27 additions & 0 deletions Tests/Test4.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace RT.CommandLine.Tests;

#pragma warning disable CS0649 // Field is never assigned to and will always have its default value

class Test4Cmd1
{
// Expected order: one, two, three
[IsPositional, IsMandatory] public int One;
[IsPositional, IsMandatory] public int Two;
[IsPositional, IsMandatory] public int Three;
}

class Test4Cmd2
{
// Expected order: three, one, two
[IsPositional(1), IsMandatory] public int One;
[IsPositional(1), IsMandatory] public int Two;
[IsPositional(0), IsMandatory] public int Three;
}

class Test4Cmd3
{
// Expected order: two, one, three
[IsPositional(1), IsMandatory] public int One;
[IsPositional(0), IsMandatory] public int Two;
[IsPositional(1), IsMandatory] public int Three;
}
17 changes: 17 additions & 0 deletions Tests/Test5.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace RT.CommandLine.Tests;

#pragma warning disable CS0649 // Field is never assigned to and will always have its default value

class Test5Cmd1
{
// Mandatory before optional should be allowed
[IsPositional(1)] public int One = 47;
[IsPositional(0), IsMandatory] public int Two;
}

class Test5Cmd2
{
// Optional before mandatory should trigger an error
[IsPositional(1), IsMandatory] public int One;
[IsPositional(0)] public int Two;
}

0 comments on commit a0face6

Please sign in to comment.