From a0face603d4f880cb20d62e608d0a2601b70a461 Mon Sep 17 00:00:00 2001 From: Timwi Date: Thu, 10 Oct 2024 12:45:50 +0200 Subject: [PATCH] * 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). --- Src/Attributes.cs | 10 +++++--- Src/CommandLineParser.cs | 54 +++++++++++++++++++++++++++------------ Src/Exceptions.cs | 22 ++++++++++++++++ Tests/CommandLineTests.cs | 34 ++++++++++++++++++++++++ Tests/Test4.cs | 27 ++++++++++++++++++++ Tests/Test5.cs | 17 ++++++++++++ 6 files changed, 143 insertions(+), 21 deletions(-) create mode 100644 Tests/Test4.cs create mode 100644 Tests/Test5.cs diff --git a/Src/Attributes.cs b/Src/Attributes.cs index e5fc97e..84b829c 100644 --- a/Src/Attributes.cs +++ b/Src/Attributes.cs @@ -1,4 +1,4 @@ -using RT.Util; +using RT.Util; using RT.Util.Consoles; namespace RT.CommandLine; @@ -18,11 +18,13 @@ public sealed class OptionAttribute(params string[] names) : Attribute /// /// Use this to specify that a command-line parameter is positional, i.e. is not invoked by an option that starts with /// "-". +/// +/// Optionally use this to re-order positional arguments differently from their declaration order. [AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false), RummageKeepUsersReflectionSafe] -public sealed class IsPositionalAttribute : Attribute +public sealed class IsPositionalAttribute(double order = 0) : Attribute { - /// Constructor. - public IsPositionalAttribute() { } + /// Optionally use this to re-order positional arguments differently from their declaration order. + public double Order { get; private set; } = order; } /// Use this to specify that a command-line parameter is mandatory. diff --git a/Src/CommandLineParser.cs b/Src/CommandLineParser.cs index 314596d..3ab7c21 100644 --- a/Src/CommandLineParser.cs +++ b/Src/CommandLineParser.cs @@ -184,6 +184,9 @@ private sealed class PositionalParameterInfo { public Action ProcessParameter; public Action ProcessEndOfParameters; + public double Order; + public bool IsMandatory; + public FieldInfo Field; } /// @@ -215,20 +218,13 @@ private static object parseCommandLine(string[] args, Type type, int i, Func(); var missingMandatories = new List(); FieldInfo swallowingField = null; - var haveSeenOptionalPositional = false; foreach (var field in type.GetCommandLineFields()) { - var positional = field.IsDefined(); + var positional = field.GetCustomAttribute()?.Order; var option = field.GetCustomAttributes().FirstOrDefault(); var mandatory = field.IsDefined(); - 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); @@ -236,10 +232,13 @@ private static object parseCommandLine(string[] args, Type type, int i, Func { positionals.RemoveAt(0); @@ -354,10 +353,13 @@ private static object parseCommandLine(string[] args, Type type, int i, Func { if (!convertStringAndSetField(args[i], ret, field)) @@ -396,10 +398,13 @@ private static object parseCommandLine(string[] args, Type type, int i, Func { missingMandatories.Remove(field); @@ -443,6 +448,9 @@ private static object parseCommandLine(string[] args, Type type, int i, Func { missingMandatories.Remove(field); @@ -465,9 +473,14 @@ private static object parseCommandLine(string[] args, Type type, int i, Func p.Order).ToList(); // Don’t use List.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) @@ -500,16 +513,17 @@ private static object parseCommandLine(string[] args, Type type, int i, Func optionalOpti optionalPositional = []; mandatoryPositional = []; - foreach (var field in type.GetCommandLineFields().Where(f => !f.IsDefined())) + foreach (var field in type.GetCommandLineFields()) { + if (field.IsDefined()) + continue; var fieldInfos = field.IsDefined() ? (field.IsDefined() ? mandatoryPositional : mandatoryOptions) : (field.IsDefined() ? optionalPositional : optionalOptions); fieldInfos.Add(field); } + + // Don’t use List.Sort because it’s not a stable sort + mandatoryPositional = mandatoryPositional.OrderBy(f => f.GetCustomAttribute().Order).ToList(); + optionalPositional = optionalPositional.OrderBy(f => f.GetCustomAttribute().Order).ToList(); } private static ConsoleColoredString getDocumentation(MemberInfo member, Func helpProcessor) => diff --git a/Src/Exceptions.cs b/Src/Exceptions.cs index aaf6b55..f56271c 100644 --- a/Src/Exceptions.cs +++ b/Src/Exceptions.cs @@ -169,6 +169,28 @@ public sealed class InvalidNumericParameterException(string fieldName, FuncSpecifies that a field in the class declaration has a type not supported by . +[Serializable] +public sealed class UnsupportedTypeException(string fieldName, Func 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) +{ + /// Contains the name of the field pertaining to the parameter that was passed an invalid value. + public string FieldName { get; private set; } = fieldName; +} + +/// Indicates that a mandatory positional parameter is defined to come after an optional positional parameter, which is not possible. +[Serializable] +public sealed class InvalidOrderOfPositionalParametersException(FieldInfo fieldOptional, FieldInfo fieldMandatory, Func 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); + + /// Contains the name of the optional positional parameter that was followed by a mandatory positional parameter. + public FieldInfo FieldOptional { get; private set; } = fieldOptional; + /// Contains the name of the mandatory positional parameter that followed an optional positional parameter. + public FieldInfo FieldMandatory { get; private set; } = fieldMandatory; +} + /// Indicates that the arguments specified by the user on the command-line do not pass the custom validation check. [Serializable] public sealed class CommandLineValidationException : CommandLineParseException diff --git a/Tests/CommandLineTests.cs b/Tests/CommandLineTests.cs index 5d65cf0..744b996 100644 --- a/Tests/CommandLineTests.cs +++ b/Tests/CommandLineTests.cs @@ -150,6 +150,40 @@ public static void TestPostBuild() CommandLineParser.PostBuildStep(reporter); } + [Fact] + public static void TestPositionalOrder() + { + static void Test(string helpPart, int[] oneTwoThree) + { + try { CommandLineParser.Parse([]); } + catch (CommandLineParseException e) { Assert.Matches($@"\AUsage: .* {helpPart}", e.GenerateHelp().ToString()); } + dynamic cmd = CommandLineParser.Parse(["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(" ", [1, 2, 3]); + Test(" ", [2, 3, 1]); + Test(" ", [2, 1, 3]); + } + + [Fact] + public static void TestPositionalMandatory() + { + // Mandatory, then optional — allowed + var cmd = CommandLineParser.Parse(["1", "2"]); + Assert.Equal(2, cmd.One); + Assert.Equal(1, cmd.Two); + var cmd2 = CommandLineParser.Parse(["8472"]); + Assert.Equal(47, cmd2.One); + Assert.Equal(8472, cmd2.Two); + + // Optional, then mandatory — expect exception + var exc = Assert.Throws(() => CommandLineParser.Parse(["1", "2"])); + Assert.Equal("The positional parameter is optional, but is followed by positional parameter which is mandatory. Either mark as mandatory or as optional.", exc.Message); + } + class Reporter : IPostBuildReporter { public void Error(string message, params string[] tokens) => throw new Exception(message); diff --git a/Tests/Test4.cs b/Tests/Test4.cs new file mode 100644 index 0000000..1ed6441 --- /dev/null +++ b/Tests/Test4.cs @@ -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; +} diff --git a/Tests/Test5.cs b/Tests/Test5.cs new file mode 100644 index 0000000..b3b761f --- /dev/null +++ b/Tests/Test5.cs @@ -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; +}