From 28f105855b36e7a06db86a6e7d98ea2e06dc120a Mon Sep 17 00:00:00 2001 From: yakovypg Date: Sun, 15 Feb 2026 16:18:42 +0300 Subject: [PATCH 1/6] Add the ability to use the converter function for `MultipleValueConverter` that returns a collection --- Core/NetArgumentParser/Converters/MultipleValueConverter.cs | 3 +++ Tests/NetArgumentParser.Tests/OptionSetTests.cs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Core/NetArgumentParser/Converters/MultipleValueConverter.cs b/Core/NetArgumentParser/Converters/MultipleValueConverter.cs index e1caadf..510c179 100644 --- a/Core/NetArgumentParser/Converters/MultipleValueConverter.cs +++ b/Core/NetArgumentParser/Converters/MultipleValueConverter.cs @@ -7,4 +7,7 @@ public class MultipleValueConverter : ValueConverter> { public MultipleValueConverter(Func singleValueConverter) : base(t => [singleValueConverter(t)]) { } + + public MultipleValueConverter(Func> multipleValueConverter) + : base(t => multipleValueConverter(t)) { } } diff --git a/Tests/NetArgumentParser.Tests/OptionSetTests.cs b/Tests/NetArgumentParser.Tests/OptionSetTests.cs index 54d250b..9f3d5ee 100644 --- a/Tests/NetArgumentParser.Tests/OptionSetTests.cs +++ b/Tests/NetArgumentParser.Tests/OptionSetTests.cs @@ -423,7 +423,7 @@ public void RemoveConverter_NotExistingConverter_ReturnsFalse() Assert.False(optionSet.RemoveConverter(new ValueConverter>(_ => []))); Assert.False(optionSet.RemoveConverter(new ValueConverter>(_ => []))); Assert.False(optionSet.RemoveConverter(new MultipleValueConverter(_ => 0))); - Assert.False(optionSet.RemoveConverter(new MultipleValueConverter(_ => []))); + Assert.False(optionSet.RemoveConverter(new MultipleValueConverter(_ => [0]))); Assert.False(optionSet.RemoveConverter(new EnumValueConverter(false))); Assert.False(optionSet.RemoveConverter(new EnumValueConverter(true))); } From a88f88c08d0f3e65874c0a224ea7677377a443dd Mon Sep 17 00:00:00 2001 From: yakovypg Date: Sun, 15 Feb 2026 16:18:58 +0300 Subject: [PATCH 2/6] Update examples --- .../Program.cs | 67 ++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/Examples/NetArgumentParser.Examples.AllUseCases/Program.cs b/Examples/NetArgumentParser.Examples.AllUseCases/Program.cs index cf66139..908e55f 100644 --- a/Examples/NetArgumentParser.Examples.AllUseCases/Program.cs +++ b/Examples/NetArgumentParser.Examples.AllUseCases/Program.cs @@ -98,6 +98,18 @@ contextCapture: new FixedContextCapture(3), choices: [["Max", "Robert", "Tom"], ["David", "John", "Richard"]]), + new MultipleValueOption( + longName: "ranges", + shortName: string.Empty, + description: "ranges of pages that should be processed", + afterValueParsingAction: t => resultValues.PageRanges.AddRange(t)), + + new MultipleValueOption( + longName: "fonts", + shortName: string.Empty, + description: "font sizes for pages", + afterValueParsingAction: t => resultValues.PageFontSizes = [.. t]), + new ValueOption( longName: "angle", shortName: "a", @@ -167,7 +179,10 @@ _ => throw new FormatException() }; - }) + }), + + new MultipleValueConverter(PageRange.Parse), + new MultipleValueConverter(PageFontSize.ParseMany) }; var descriptionGenerator = new ApplicationDescriptionGenerator(parser) @@ -226,12 +241,52 @@ Console.WriteLine($"Time: {resultValues.Time}"); Console.WriteLine($"File mode: {resultValues.FileMode}"); Console.WriteLine($"Input files: {string.Join(" ", resultValues.InputFiles)}"); +Console.WriteLine($"Page ranges: {string.Join(" ", resultValues.PageRanges)}"); +Console.WriteLine($"Page font sizes: {string.Join(" ", resultValues.PageFontSizes)}"); Console.WriteLine($"Date: {resultValues.Date?.ToLongDateString()}"); Console.WriteLine($"Name: {resultValues.Name}"); Console.WriteLine($"Width: {resultValues.Width}"); Console.WriteLine($"Height: {resultValues.Height}"); #pragma warning disable +internal sealed record PageRange(int Start, int End) +{ + public static PageRange Parse(string data) + { + int[] parts = data.Split('-').Select(int.Parse).ToArray(); + + return parts.Length == 2 + ? new PageRange(parts[0], parts[1]) + : throw new ArgumentException("Incorrect format"); + } +} + +internal sealed record PageFontSize(int PageNumber, int FontSize) +{ + public static PageFontSize Parse(string data) + { + int[] parts = data.Split(';').Select(int.Parse).ToArray(); + + return parts.Length == 2 + ? new PageFontSize(parts[0], parts[1]) + : throw new ArgumentException("Incorrect format"); + } + + public static PageFontSize[] ParseMany(string data) + { + string[] parts = data.Split(':'); + + if (parts.Length != 2) + throw new ArgumentException("Incorrect format"); + + PageRange pageRange = PageRange.Parse(parts[0]); + IEnumerable pages = Enumerable.Range(pageRange.Start, pageRange.End - pageRange.Start + 1); + int fontSize = int.Parse(parts[1]); + + return pages.Select(t => $"{t};{fontSize}").Select(Parse).ToArray(); + } +} + internal sealed class ResultValues { public bool Verbose { get; set; } @@ -241,9 +296,19 @@ internal sealed class ResultValues public TimeSpan? Time { get; set; } public FileMode? FileMode { get; set; } public List InputFiles { get; set; } = []; + public List PageRanges { get; set; } = [new PageRange(1, 1)]; + public List PageFontSizes { get; set; } = []; public DateTime? Date { get; set; } public string? Name { get; set; } public int? Width { get; set; } public int? Height { get; set; } } #pragma warning restore + +/* +./NetArgumentParser.Examples.AllUseCases -n Name --verbose -q \ + --input ./NetArgumentParser.Examples.AllUseCases ./NetArgumentParser.dll \ + --persons David John Richard --ranges 2-5 6-8 --fonts 1-2:12 3-7:16 -a 45 \ + -VVV --time 1,2,3,4,5 --file-mode open --date 2026 02 15 \ + extra1 --extra2 extra3 +*/ From 83440332ca1e5d49dfecfe47d55ba4282e43281d Mon Sep 17 00:00:00 2001 From: yakovypg Date: Sun, 15 Feb 2026 16:19:10 +0300 Subject: [PATCH 3/6] Update documentation --- Documentation/CustomConverters.md | 90 ++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/Documentation/CustomConverters.md b/Documentation/CustomConverters.md index 64fd059..729ada2 100644 --- a/Documentation/CustomConverters.md +++ b/Documentation/CustomConverters.md @@ -4,10 +4,11 @@ Custom converters allow you to easily work with options whose values are non-sta ## Table of Contents * [Converter Types](#converter-types) * [Value Converter](#value-converter) + * [Multiple Value Converter](#multiple-value-converter) * [Converter Set](#converter-set) ## Converter Types -There are three converter types: value converter, multiple value converter and enum value converter. They are used in value options, multiple value options and enum value options. However, in the vast majority of situations it is sufficient to use only the value converter. +There are three converter types: value converter, multiple value converter and enum value converter. They are used in value options, multiple value options and enum value options. However, in the vast majority of situations it is sufficient to use only the value converter and multiple value converter. Furthermore, you can create your own converters. To do this you need to inherit your class from the `IValueConverter` interface and implement it. You can also use an existing option class as a base class. See examples of this kind of inheritance, for example, by looking at the implementation of the `MultipleValueConverter` and `EnumValueConverter` classes. Next, you can use this class in the same way as the standard ones. @@ -101,6 +102,93 @@ parser.Parse(new string[] { "--first", "name", "subcommand", "--second", "name" // secondName: NAME ``` +### Multiple Value Converter +To create a multiple value converter for type T, you need a function that takes a string and returns T or T[]. Pass this function to the constructor of the appropriate class as shown in the example below. + +Here is an example of creating multiple value converter and using it in the parser: + +```cs +var ranges = new List(); + +var rangesOption = new MultipleValueOption("ranges", + afterValueParsingAction: t => ranges.AddRange(t)); + +var converter = new MultipleValueConverter(PageRange.Parse); +var parser = new ArgumentParser(); + +parser.AddOptions(rangesOption); +parser.AddConverters(converter); + +parser.Parse(new string[] { "--ranges", "1-2", "5-7" }); +// ranges: [PageRange { Start = 1, End = 2 }, PageRange { Start = 5, End = 7 }] + +record PageRange(int Start, int End) +{ + public static PageRange Parse(string data) + { + int[] parts = data.Split('-').Select(int.Parse).ToArray(); + return new PageRange(parts[0], parts[1]); + } +} +``` + +A similar example of using a converter with a function that returns T[] is given in the example below: + +```cs +var fontSizes = new List(); + +var fontSizesOption = new MultipleValueOption("fonts", + afterValueParsingAction: t => fontSizes = new List(t)); + +var converter = new MultipleValueConverter(PageFontSize.ParseMany); +var parser = new ArgumentParser(); + +parser.AddOptions(fontSizesOption); +parser.AddConverters(converter); + +parser.Parse(new string[] { "--fonts", "1-2:12", "5-5:16" }); +/* ranges: [ + * PageFontSize { PageNumber = 1, FontSize = 12 }, + * PageFontSize { PageNumber = 2, FontSize = 12 }, + * PageFontSize { PageNumber = 5, FontSize = 16 }, + * ] + */ + +record PageRange(int Start, int End) +{ + public static PageRange Parse(string data) + { + int[] parts = data.Split('-').Select(int.Parse).ToArray(); + return new PageRange(parts[0], parts[1]); + } +} + +record PageFontSize(int PageNumber, int FontSize) +{ + public static PageFontSize Parse(string data) + { + int[] parts = data.Split(';').Select(int.Parse).ToArray(); + return new PageFontSize(parts[0], parts[1]); + } + + public static PageFontSize[] ParseMany(string data) + { + string[] parts = data.Split(':'); + + PageRange pageRange = PageRange.Parse(parts[0]); + int fontSize = int.Parse(parts[1]); + + IEnumerable pages = Enumerable.Range( + pageRange.Start, + pageRange.End - pageRange.Start + 1); + + return pages.Select(t => $"{t};{fontSize}").Select(Parse).ToArray(); + } +} +``` + +All rules regarding the visibility level, adding converters, and using them in subcommands, as well as others, are the same as for the value converter. + ## Converter Set You can put just one converter for each type in converter set. ```cs From c6a9c7f20f5ce72144de71b0d41209ae94c6396b Mon Sep 17 00:00:00 2001 From: yakovypg Date: Sun, 15 Feb 2026 16:19:40 +0300 Subject: [PATCH 4/6] Update tests --- .../ArgumentParserTests.cs | 107 ++++++++++++++++++ .../NetArgumentParser.Tests/Models/Margin.cs | 22 ++++ .../Models/PageFontSize.cs | 104 +++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 Tests/NetArgumentParser.Tests/Models/PageFontSize.cs diff --git a/Tests/NetArgumentParser.Tests/ArgumentParserTests.cs b/Tests/NetArgumentParser.Tests/ArgumentParserTests.cs index 42c45a8..0fa9e0b 100644 --- a/Tests/NetArgumentParser.Tests/ArgumentParserTests.cs +++ b/Tests/NetArgumentParser.Tests/ArgumentParserTests.cs @@ -360,6 +360,113 @@ public void Parse_ValueOptions_SpecialConverterApplied() Assert.Empty(extraArguments); } + [Fact] + public void Parse_MultipleValueOptions_SpecialConverterApplied() + { + const int pageMarginLeft = 10; + const int pageMarginTop = -20; + const int pageMarginRight = 30; + const int pageMarginBottom = -40; + + const int contentMarginLeft = -15; + const int contentMarginTop = 25; + const int contentMarginRight = -35; + const int contentMarginBottom = 45; + + const int firstPageRangeStart = 1; + const int firstPageRangeEnd = 5; + const double firstPageRangeFontSize = 12.5; + + const int secondPageRangeStart = 6; + const int secondPageRangeEnd = 6; + const double secondPageRangeFontSize = 16; + + const int thirdPageRangeStart = 7; + const int thirdPageRangeEnd = 10; + const double thirdPageRangeFontSize = 24; + + List margins = []; + List pageFontSizes = []; + + var arguments = new string[] + { + "-m", + $"{pageMarginLeft},{pageMarginTop},{pageMarginRight},{pageMarginBottom}", + $"{contentMarginLeft},{contentMarginTop},{contentMarginRight},{contentMarginBottom}", + "-s", + $"{firstPageRangeStart}-{firstPageRangeEnd}:{firstPageRangeFontSize}", + $"{secondPageRangeStart}-{secondPageRangeEnd}:{secondPageRangeFontSize}", + $"{thirdPageRangeStart}-{thirdPageRangeEnd}:{thirdPageRangeFontSize}" + }; + + var options = new ICommonOption[] + { + new MultipleValueOption( + string.Empty, + "m", + afterValueParsingAction: t => margins.AddRange(t)), + + new MultipleValueOption( + string.Empty, + "s", + afterValueParsingAction: t => pageFontSizes.AddRange(t)) + }; + + var converters = new IValueConverter[] + { + new MultipleValueConverter(Margin.Parse), + new MultipleValueConverter(PageFontSize.ParseFromRange) + }; + + var parser = new ArgumentParser() + { + UseDefaultHelpOption = false + }; + + parser.AddOptions(options); + parser.AddConverters(converters); + + _ = parser.ParseKnownArguments(arguments, out IList extraArguments); + + var expectedPageMargin = new Margin( + pageMarginLeft, + pageMarginTop, + pageMarginRight, + pageMarginBottom); + + var expectedContentMargin = new Margin( + contentMarginLeft, + contentMarginTop, + contentMarginRight, + contentMarginBottom); + + IEnumerable expectedMargins = [expectedPageMargin, expectedContentMargin]; + + IEnumerable expectedPageFontSizesForFirstPageRange = Enumerable + .Range(firstPageRangeStart, firstPageRangeEnd - firstPageRangeStart + 1) + .Select(t => new PageFontSize(t, firstPageRangeFontSize)); + + IEnumerable expectedPageFontSizesForSecondPageRange = Enumerable + .Range(secondPageRangeStart, secondPageRangeEnd - secondPageRangeStart + 1) + .Select(t => new PageFontSize(t, secondPageRangeFontSize)); + + IEnumerable expectedPageFontSizesForThirdPageRange = Enumerable + .Range(thirdPageRangeStart, thirdPageRangeEnd - thirdPageRangeStart + 1) + .Select(t => new PageFontSize(t, thirdPageRangeFontSize)); + + IEnumerable expectedPageFontSizes = expectedPageFontSizesForFirstPageRange + .Concat(expectedPageFontSizesForSecondPageRange) + .Concat(expectedPageFontSizesForThirdPageRange); + + bool marginsCorrect = margins.SequenceEqual(expectedMargins); + bool pageFontSizesCorrect = pageFontSizes.SequenceEqual(expectedPageFontSizes); + + Assert.True(marginsCorrect); + Assert.True(pageFontSizesCorrect); + + Assert.Empty(extraArguments); + } + [Fact] public void Parse_ValueOptions_DefaultValueApplied() { diff --git a/Tests/NetArgumentParser.Tests/Models/Margin.cs b/Tests/NetArgumentParser.Tests/Models/Margin.cs index 5a553e4..16b151d 100644 --- a/Tests/NetArgumentParser.Tests/Models/Margin.cs +++ b/Tests/NetArgumentParser.Tests/Models/Margin.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; namespace NetArgumentParser.Tests.Models; @@ -17,6 +18,27 @@ internal Margin(int left, int top, int right, int bottom) internal int Right { get; } internal int Bottom { get; } + public static Margin Parse(string data) + { + ExtendedArgumentException.ThrowIfNullOrWhiteSpace(data, nameof(data)); + + string[] parts = data.Split(','); + + if (parts.Length != 4) + { + throw new ArgumentException( + $"Recieved data has incorrect format. Expected: L,T,R,B", + nameof(data)); + } + + int left = int.Parse(parts[0], CultureInfo.InvariantCulture); + int top = int.Parse(parts[1], CultureInfo.InvariantCulture); + int right = int.Parse(parts[2], CultureInfo.InvariantCulture); + int bottom = int.Parse(parts[3], CultureInfo.InvariantCulture); + + return new Margin(left, top, right, bottom); + } + public bool Equals(Margin? other) { return other is not null diff --git a/Tests/NetArgumentParser.Tests/Models/PageFontSize.cs b/Tests/NetArgumentParser.Tests/Models/PageFontSize.cs new file mode 100644 index 0000000..bc36cce --- /dev/null +++ b/Tests/NetArgumentParser.Tests/Models/PageFontSize.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace NetArgumentParser.Tests.Models; + +internal readonly struct PageFontSize : IEquatable +{ + private const string _expectedStringFormat = "Pages:FontSize"; + + internal PageFontSize(int pageNumber, double fontSize) + { + PageNumber = pageNumber; + FontSize = fontSize; + } + + internal double PageNumber { get; } + internal double FontSize { get; } + + public static PageFontSize Parse(string data) + { + ExtendedArgumentException.ThrowIfNullOrWhiteSpace(data, nameof(data)); + + string[] parts = data.Split(':'); + + if (parts.Length != 2) + { + throw new ArgumentException( + $"Recieved data has incorrect format. Expected: {_expectedStringFormat}", + nameof(data)); + } + + int pageNumber = int.Parse(parts[0], CultureInfo.CurrentCulture); + double fontSize = double.Parse(parts[1], CultureInfo.CurrentCulture); + + return new PageFontSize(pageNumber, fontSize); + } + + public static PageFontSize[] ParseFromRange(string data) + { + ExtendedArgumentException.ThrowIfNullOrWhiteSpace(data, nameof(data)); + + string[] parts = data.Split(':'); + + if (parts.Length != 2) + { + throw new ArgumentException( + $"Recieved data has incorrect format. Expected: {_expectedStringFormat}", + nameof(data)); + } + + IReadOnlyCollection pages; + string[] pageRangeParts = parts[0].Split('-'); + + if (pageRangeParts.Length == 1) + { + int value = int.Parse(data, CultureInfo.InvariantCulture); + pages = [value]; + } + else if (pageRangeParts.Length == 2) + { + int start = int.Parse(pageRangeParts[0], CultureInfo.InvariantCulture); + int end = int.Parse(pageRangeParts[1], CultureInfo.InvariantCulture); + + int min = Math.Min(start, end); + IEnumerable items = Enumerable.Range(min, Math.Abs(end - start) + 1); + + pages = start <= end + ? [.. items] + : [.. items.Reverse()]; + } + else + { + throw new ArgumentException( + $"Recieved data has incorrect format. Expected: {_expectedStringFormat}", + nameof(data)); + } + + return [.. pages + .Select(t => $"{t}:{parts[1]}") + .Select(Parse)]; + } + + public bool Equals(PageFontSize other) + { + return PageNumber == other.PageNumber && FontSize == other.FontSize; + } + + public override bool Equals(object? obj) + { + return obj is Point other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(PageNumber, FontSize); + } + + public override string ToString() + { + return $"{PageNumber}:{FontSize}"; + } +} From ef2c912d7d2c0ef1c471fceb70ed72c5325762a1 Mon Sep 17 00:00:00 2001 From: yakovypg Date: Sun, 15 Feb 2026 17:07:09 +0300 Subject: [PATCH 5/6] Fix examples --- Examples/NetArgumentParser.Examples.AllUseCases/Program.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Examples/NetArgumentParser.Examples.AllUseCases/Program.cs b/Examples/NetArgumentParser.Examples.AllUseCases/Program.cs index 908e55f..fb22dc5 100644 --- a/Examples/NetArgumentParser.Examples.AllUseCases/Program.cs +++ b/Examples/NetArgumentParser.Examples.AllUseCases/Program.cs @@ -96,7 +96,8 @@ ignoreCaseInChoices: true, ignoreOrderInChoices: true, contextCapture: new FixedContextCapture(3), - choices: [["Max", "Robert", "Tom"], ["David", "John", "Richard"]]), + choices: [["Max", "Robert", "Tom"], ["David", "John", "Richard"]], + afterValueParsingAction: t => resultValues.Persons = [.. t]), new MultipleValueOption( longName: "ranges", @@ -241,6 +242,7 @@ Console.WriteLine($"Time: {resultValues.Time}"); Console.WriteLine($"File mode: {resultValues.FileMode}"); Console.WriteLine($"Input files: {string.Join(" ", resultValues.InputFiles)}"); +Console.WriteLine($"Persons: {string.Join(" ", resultValues.Persons)}"); Console.WriteLine($"Page ranges: {string.Join(" ", resultValues.PageRanges)}"); Console.WriteLine($"Page font sizes: {string.Join(" ", resultValues.PageFontSizes)}"); Console.WriteLine($"Date: {resultValues.Date?.ToLongDateString()}"); @@ -296,6 +298,7 @@ internal sealed class ResultValues public TimeSpan? Time { get; set; } public FileMode? FileMode { get; set; } public List InputFiles { get; set; } = []; + public List Persons { get; set; } = []; public List PageRanges { get; set; } = [new PageRange(1, 1)]; public List PageFontSizes { get; set; } = []; public DateTime? Date { get; set; } From 0c7dd40ce9523085f07e8e2995b34328c3b29617 Mon Sep 17 00:00:00 2001 From: yakovypg Date: Sun, 15 Feb 2026 17:07:32 +0300 Subject: [PATCH 6/6] Update documentation --- Documentation/CustomConverters.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Documentation/CustomConverters.md b/Documentation/CustomConverters.md index 729ada2..92056d2 100644 --- a/Documentation/CustomConverters.md +++ b/Documentation/CustomConverters.md @@ -5,6 +5,7 @@ Custom converters allow you to easily work with options whose values are non-sta * [Converter Types](#converter-types) * [Value Converter](#value-converter) * [Multiple Value Converter](#multiple-value-converter) + * [Enum Value Converter](#enum-value-converter) * [Converter Set](#converter-set) ## Converter Types @@ -189,6 +190,31 @@ record PageFontSize(int PageNumber, int FontSize) All rules regarding the visibility level, adding converters, and using them in subcommands, as well as others, are the same as for the value converter. +### Enum Value Converter +To create an enum value converter for type T, you don't need any special functions. You only need to pass a flag indicating whether the converter should ignore case in the input string. + +Here is an example of creating enum value converter and using it in the parser: + +```cs +FileMode fileMode = FileMode.Create; + +var fileModeOption = new EnumValueOption("mode", + afterValueParsingAction: t => fileMode = t); + +var converter = new EnumValueConverter(ignoreCase: true); +var parser = new ArgumentParser(); + +parser.AddOptions(fileModeOption); +parser.AddConverters(converter); + +parser.Parse(new string[] { "--mode", "OpeN" }); +// fileMode: Open +``` + +If we had passed `false` as the value for `ignoreCase`, we would have encountered an error. + +All rules regarding the visibility level, adding converters, and using them in subcommands, as well as others, are the same as for the value converter. + ## Converter Set You can put just one converter for each type in converter set. ```cs