Skip to content

Commit a0face6

Browse files
committed
* Add ability to specify order of positional parameters to be different from declaration.
* Change two instances of InternalErrorException to a specialized exception (UnsupportedTypeException and InvalidOrderOfPositionalParametersException).
1 parent 0ba58e0 commit a0face6

File tree

6 files changed

+143
-21
lines changed

6 files changed

+143
-21
lines changed

Src/Attributes.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using RT.Util;
1+
using RT.Util;
22
using RT.Util.Consoles;
33

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

2830
/// <summary>Use this to specify that a command-line parameter is mandatory.</summary>

Src/CommandLineParser.cs

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,9 @@ private sealed class PositionalParameterInfo
184184
{
185185
public Action ProcessParameter;
186186
public Action ProcessEndOfParameters;
187+
public double Order;
188+
public bool IsMandatory;
189+
public FieldInfo Field;
187190
}
188191

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

220222
foreach (var field in type.GetCommandLineFields())
221223
{
222-
var positional = field.IsDefined<IsPositionalAttribute>();
224+
var positional = field.GetCustomAttribute<IsPositionalAttribute>()?.Order;
223225
var option = field.GetCustomAttributes<OptionAttribute>().FirstOrDefault();
224226
var mandatory = field.IsDefined<IsMandatoryAttribute>();
225227

226-
if (positional && mandatory && haveSeenOptionalPositional)
227-
throw new InternalErrorException("Cannot have positional mandatory parameter after a positional optional one.");
228-
229-
if (positional && !mandatory)
230-
haveSeenOptionalPositional = true;
231-
232228
if (mandatory)
233229
missingMandatories.Add(field);
234230

235231
// ### ENUM fields
236232
if (field.FieldType.IsEnum)
237233
{
238234
// ### ENUM fields, positional
239-
if (positional)
235+
if (positional is double order)
240236
{
241237
positionals.Add(new PositionalParameterInfo
242238
{
239+
Order = order,
240+
IsMandatory = mandatory,
241+
Field = field,
243242
ProcessParameter = () =>
244243
{
245244
positionals.RemoveAt(0);
@@ -354,10 +353,13 @@ private static object parseCommandLine(string[] args, Type type, int i, Func<Con
354353
else if (field.FieldType == typeof(string) || ExactConvert.IsTrueIntegerType(field.FieldType) || ExactConvert.IsTrueIntegerNullableType(field.FieldType) ||
355354
field.FieldType == typeof(float) || field.FieldType == typeof(float?) || field.FieldType == typeof(double) || field.FieldType == typeof(double?))
356355
{
357-
if (positional)
356+
if (positional is double order)
358357
{
359358
positionals.Add(new PositionalParameterInfo
360359
{
360+
Order = order,
361+
IsMandatory = mandatory,
362+
Field = field,
361363
ProcessParameter = () =>
362364
{
363365
if (!convertStringAndSetField(args[i], ret, field))
@@ -396,10 +398,13 @@ private static object parseCommandLine(string[] args, Type type, int i, Func<Con
396398
// ### STRING[] fields
397399
else if (field.FieldType == typeof(string[]))
398400
{
399-
if (positional)
401+
if (positional is double order)
400402
{
401403
positionals.Add(new PositionalParameterInfo
402404
{
405+
Order = order,
406+
IsMandatory = mandatory,
407+
Field = field,
403408
ProcessParameter = () =>
404409
{
405410
missingMandatories.Remove(field);
@@ -443,6 +448,9 @@ private static object parseCommandLine(string[] args, Type type, int i, Func<Con
443448
swallowingField = field;
444449
positionals.Add(new PositionalParameterInfo
445450
{
451+
Order = positional ?? 0,
452+
IsMandatory = mandatory,
453+
Field = field,
446454
ProcessParameter = () =>
447455
{
448456
missingMandatories.Remove(field);
@@ -465,9 +473,14 @@ private static object parseCommandLine(string[] args, Type type, int i, Func<Con
465473
}
466474
else
467475
// This only happens if the post-build check didn't run
468-
throw new InternalErrorException($"{type.FullName}.{field.Name} is not of a supported type.");
476+
throw new UnsupportedTypeException($"{type.Name}.{field.Name}", getHelpGenerator(type, helpProcessor));
469477
}
470478

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

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

501514
if (ret is ICommandLineValidatable v)
502515
{
516+
ConsoleColoredString error = null;
503517
try
504518
{
505-
var error = v.Validate();
506-
if (error != null)
507-
throw new CommandLineValidationException(error, getHelpGenerator(type, helpProcessor));
519+
error = v.Validate();
508520
}
509521
catch (CommandLineValidationException exc) when (exc.GenerateHelpFunc == null)
510522
{
511-
throw new CommandLineValidationException(exc.ColoredMessage, getHelpGenerator(type, helpProcessor));
523+
error = exc.ColoredMessage;
512524
}
525+
if (error != null)
526+
throw new CommandLineValidationException(error, getHelpGenerator(type, helpProcessor));
513527
}
514528

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

750-
foreach (var field in type.GetCommandLineFields().Where(f => !f.IsDefined<UndocumentedAttribute>()))
764+
foreach (var field in type.GetCommandLineFields())
751765
{
766+
if (field.IsDefined<UndocumentedAttribute>())
767+
continue;
752768
var fieldInfos = field.IsDefined<IsMandatoryAttribute>()
753769
? (field.IsDefined<IsPositionalAttribute>() ? mandatoryPositional : mandatoryOptions)
754770
: (field.IsDefined<IsPositionalAttribute>() ? optionalPositional : optionalOptions);
755771
fieldInfos.Add(field);
756772
}
773+
774+
// Don’t use List<T>.Sort because it’s not a stable sort
775+
mandatoryPositional = mandatoryPositional.OrderBy(f => f.GetCustomAttribute<IsPositionalAttribute>().Order).ToList();
776+
optionalPositional = optionalPositional.OrderBy(f => f.GetCustomAttribute<IsPositionalAttribute>().Order).ToList();
757777
}
758778

759779
private static ConsoleColoredString getDocumentation(MemberInfo member, Func<ConsoleColoredString, ConsoleColoredString> helpProcessor) =>

Src/Exceptions.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,28 @@ public sealed class InvalidNumericParameterException(string fieldName, Func<int,
169169
public string FieldName { get; private set; } = fieldName;
170170
}
171171

172+
/// <summary>Specifies that a field in the class declaration has a type not supported by <see cref="CommandLineParser"/>.</summary>
173+
[Serializable]
174+
public sealed class UnsupportedTypeException(string fieldName, Func<int, ConsoleColoredString> helpGenerator, Exception inner = null)
175+
: CommandLineParseException("The field {0} is of an unsupported type.".ToConsoleColoredString().Fmt("<".Color(CmdLineColor.FieldBrackets) + fieldName.Color(CmdLineColor.Field) + ">".Color(CmdLineColor.FieldBrackets)), helpGenerator, inner)
176+
{
177+
/// <summary>Contains the name of the field pertaining to the parameter that was passed an invalid value.</summary>
178+
public string FieldName { get; private set; } = fieldName;
179+
}
180+
181+
/// <summary>Indicates that a mandatory positional parameter is defined to come after an optional positional parameter, which is not possible.</summary>
182+
[Serializable]
183+
public sealed class InvalidOrderOfPositionalParametersException(FieldInfo fieldOptional, FieldInfo fieldMandatory, Func<int, ConsoleColoredString> helpGenerator, Exception inner = null)
184+
: 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)
185+
{
186+
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);
187+
188+
/// <summary>Contains the name of the optional positional parameter that was followed by a mandatory positional parameter.</summary>
189+
public FieldInfo FieldOptional { get; private set; } = fieldOptional;
190+
/// <summary>Contains the name of the mandatory positional parameter that followed an optional positional parameter.</summary>
191+
public FieldInfo FieldMandatory { get; private set; } = fieldMandatory;
192+
}
193+
172194
/// <summary>Indicates that the arguments specified by the user on the command-line do not pass the custom validation check.</summary>
173195
[Serializable]
174196
public sealed class CommandLineValidationException : CommandLineParseException

Tests/CommandLineTests.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,40 @@ public static void TestPostBuild()
150150
CommandLineParser.PostBuildStep<Test3Cmd>(reporter);
151151
}
152152

153+
[Fact]
154+
public static void TestPositionalOrder()
155+
{
156+
static void Test<T>(string helpPart, int[] oneTwoThree)
157+
{
158+
try { CommandLineParser.Parse<T>([]); }
159+
catch (CommandLineParseException e) { Assert.Matches($@"\AUsage: .* {helpPart}", e.GenerateHelp().ToString()); }
160+
dynamic cmd = CommandLineParser.Parse<T>(["1", "2", "3"]);
161+
Assert.Equal(oneTwoThree[0], (int) cmd.One);
162+
Assert.Equal(oneTwoThree[1], (int) cmd.Two);
163+
Assert.Equal(oneTwoThree[2], (int) cmd.Three);
164+
}
165+
166+
Test<Test4Cmd1>("<One> <Two> <Three>", [1, 2, 3]);
167+
Test<Test4Cmd2>("<Three> <One> <Two>", [2, 3, 1]);
168+
Test<Test4Cmd3>("<Two> <One> <Three>", [2, 1, 3]);
169+
}
170+
171+
[Fact]
172+
public static void TestPositionalMandatory()
173+
{
174+
// Mandatory, then optional — allowed
175+
var cmd = CommandLineParser.Parse<Test5Cmd1>(["1", "2"]);
176+
Assert.Equal(2, cmd.One);
177+
Assert.Equal(1, cmd.Two);
178+
var cmd2 = CommandLineParser.Parse<Test5Cmd1>(["8472"]);
179+
Assert.Equal(47, cmd2.One);
180+
Assert.Equal(8472, cmd2.Two);
181+
182+
// Optional, then mandatory — expect exception
183+
var exc = Assert.Throws<InvalidOrderOfPositionalParametersException>(() => CommandLineParser.Parse<Test5Cmd2>(["1", "2"]));
184+
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);
185+
}
186+
153187
class Reporter : IPostBuildReporter
154188
{
155189
public void Error(string message, params string[] tokens) => throw new Exception(message);

Tests/Test4.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
namespace RT.CommandLine.Tests;
2+
3+
#pragma warning disable CS0649 // Field is never assigned to and will always have its default value
4+
5+
class Test4Cmd1
6+
{
7+
// Expected order: one, two, three
8+
[IsPositional, IsMandatory] public int One;
9+
[IsPositional, IsMandatory] public int Two;
10+
[IsPositional, IsMandatory] public int Three;
11+
}
12+
13+
class Test4Cmd2
14+
{
15+
// Expected order: three, one, two
16+
[IsPositional(1), IsMandatory] public int One;
17+
[IsPositional(1), IsMandatory] public int Two;
18+
[IsPositional(0), IsMandatory] public int Three;
19+
}
20+
21+
class Test4Cmd3
22+
{
23+
// Expected order: two, one, three
24+
[IsPositional(1), IsMandatory] public int One;
25+
[IsPositional(0), IsMandatory] public int Two;
26+
[IsPositional(1), IsMandatory] public int Three;
27+
}

Tests/Test5.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace RT.CommandLine.Tests;
2+
3+
#pragma warning disable CS0649 // Field is never assigned to and will always have its default value
4+
5+
class Test5Cmd1
6+
{
7+
// Mandatory before optional should be allowed
8+
[IsPositional(1)] public int One = 47;
9+
[IsPositional(0), IsMandatory] public int Two;
10+
}
11+
12+
class Test5Cmd2
13+
{
14+
// Optional before mandatory should trigger an error
15+
[IsPositional(1), IsMandatory] public int One;
16+
[IsPositional(0)] public int Two;
17+
}

0 commit comments

Comments
 (0)