From f29df73492f225bec57856688b7d63342d6e5657 Mon Sep 17 00:00:00 2001 From: Jon Sequeira Date: Thu, 2 Apr 2026 13:18:54 -0700 Subject: [PATCH] unify CustomParser and DefaultValueFactory via new SetValueFactory API --- ...ommandLine_api_is_not_changed.approved.txt | 6 + .../CustomParsingTests.cs | 6 +- .../ValueFactoryTests.cs | 986 ++++++++++++++++++ src/System.CommandLine/Argument{T}.cs | 106 +- src/System.CommandLine/Option{T}.cs | 15 + .../ValueFactoryInvocation.cs | 26 + 6 files changed, 1112 insertions(+), 33 deletions(-) create mode 100644 src/System.CommandLine.Tests/ValueFactoryTests.cs create mode 100644 src/System.CommandLine/ValueFactoryInvocation.cs diff --git a/src/System.CommandLine.ApiCompatibility.Tests/ApiCompatibilityApprovalTests.System_CommandLine_api_is_not_changed.approved.txt b/src/System.CommandLine.ApiCompatibility.Tests/ApiCompatibilityApprovalTests.System_CommandLine_api_is_not_changed.approved.txt index ba5ec1c066..4f40806426 100644 --- a/src/System.CommandLine.ApiCompatibility.Tests/ApiCompatibilityApprovalTests.System_CommandLine_api_is_not_changed.approved.txt +++ b/src/System.CommandLine.ApiCompatibility.Tests/ApiCompatibilityApprovalTests.System_CommandLine_api_is_not_changed.approved.txt @@ -15,6 +15,7 @@ public Func DefaultValueFactory { get; set; } public System.Boolean HasDefaultValue { get; } public System.Type ValueType { get; } + public System.Void SetValueFactory(Func valueFactory, ValueFactoryInvocation invocation = WhenTokensMatched) public struct ArgumentArity : System.ValueType, System.IEquatable public static ArgumentArity ExactlyOne { get; } public static ArgumentArity OneOrMore { get; } @@ -101,6 +102,7 @@ public Option AcceptLegalFilePathsOnly() public Option AcceptOnlyFromAmong(System.String[] values) public Option AcceptOnlyFromAmong(System.StringComparer comparer, System.String[] values) + public System.Void SetValueFactory(Func valueFactory, ValueFactoryInvocation invocation = WhenTokensMatched) public static class OptionValidation public static Option AcceptExistingOnly(this Option option) public static Option AcceptExistingOnly(this Option option) @@ -150,6 +152,10 @@ public System.Collections.Generic.IEnumerable Parents { get; } public System.Collections.Generic.IEnumerable GetCompletions(System.CommandLine.Completions.CompletionContext context) public System.String ToString() + public enum ValueFactoryInvocation : System.Enum, System.IComparable, System.IConvertible, System.IFormattable, System.ISpanFormattable + WhenTokensMatched=1 + WhenTokensNotMatched=2 + Always=3 public class VersionOption : Option .ctor() .ctor(System.String name, System.String[] aliases) diff --git a/src/System.CommandLine.Tests/CustomParsingTests.cs b/src/System.CommandLine.Tests/CustomParsingTests.cs index 7a1d14936d..0754d3b70d 100644 --- a/src/System.CommandLine.Tests/CustomParsingTests.cs +++ b/src/System.CommandLine.Tests/CustomParsingTests.cs @@ -13,6 +13,8 @@ namespace System.CommandLine.Tests; +#pragma warning disable CS0618 // Tests in this class intentionally exercise the obsolete legacy parsing API. + public class CustomParsingTests { [Fact] @@ -986,4 +988,6 @@ public void GetResult_by_name_can_be_used_recursively_within_custom_option_parse parseResult.GetValue("--second").Should().Be("two"); parseResult.GetValue("--third").Should().Be("three"); } -} \ No newline at end of file +} + +#pragma warning restore CS0618 \ No newline at end of file diff --git a/src/System.CommandLine.Tests/ValueFactoryTests.cs b/src/System.CommandLine.Tests/ValueFactoryTests.cs new file mode 100644 index 0000000000..08f515a85a --- /dev/null +++ b/src/System.CommandLine.Tests/ValueFactoryTests.cs @@ -0,0 +1,986 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using System.CommandLine.Parsing; +using System.CommandLine.Tests.Utility; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; + +namespace System.CommandLine.Tests; + +public class ValueFactoryTests +{ + [Fact] + public void HasDefaultValue_can_be_set_to_true_when_ValueFactory_handles_missing_tokens() + { + Func valueFactory = result => null; + var argument1 = new Argument("arg"); + argument1.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensNotMatched); + var argument = argument1; + + argument.HasDefaultValue + .Should() + .BeTrue(); + } + + [Fact] + public void HasDefaultValue_can_be_set_to_false_when_ValueFactory_only_parses_tokens() + { + Func valueFactory = result => null; + var argument1 = new Argument("arg"); + argument1.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + var argument = argument1; + + argument.HasDefaultValue + .Should() + .BeFalse(); + } + + [Fact] + public void SetValueFactory_configured_for_parsing_only_does_not_make_a_required_argument_optional() + { + Func valueFactory = result => int.Parse(result.Tokens.Single().Value); + var argument1 = new Argument("arg"); + argument1.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + var argument = argument1; + + new RootCommand { argument }.Parse("") + .Errors + .Should() + .ContainSingle(e => ((ArgumentResult)e.SymbolResult).Argument == argument); + } + + [Fact] + public void GetDefaultValue_returns_specified_value_when_ValueFactory_handles_missing_tokens() + { + Func valueFactory = result => "the-default"; + var argument1 = new Argument("arg"); + argument1.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensNotMatched); + var argument = argument1; + + argument.GetDefaultValue() + .Should() + .Be("the-default"); + } + + [Fact] + public void GetDefaultValue_can_return_null_when_ValueFactory_handles_missing_tokens() + { + Func valueFactory = result => null; + var argument1 = new Argument("arg"); + argument1.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensNotMatched); + var argument = argument1; + + argument.GetDefaultValue() + .Should() + .BeNull(); + } + + [Fact] + public void Validation_failure_message_can_be_specified_when_ValueFactory_parses_tokens() + { + Func valueFactory = result => + { + result.AddError("oops!"); + return null; + }; + var argument1 = new Argument("arg"); + argument1.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + var argument = argument1; + + new RootCommand { argument }.Parse("x") + .Errors + .Should() + .ContainSingle(e => ((ArgumentResult)e.SymbolResult).Argument == argument) + .Which + .Message + .Should() + .Be("oops!"); + } + + [Fact] + public void Validation_failure_message_can_be_specified_when_ValueFactory_evaluates_default_argument_value() + { + Func valueFactory = result => + { + result.AddError("oops!"); + return null; + }; + var argument1 = new Argument("arg"); + argument1.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensNotMatched); + var argument = argument1; + + new RootCommand { argument }.Parse("") + .Errors + .Should() + .ContainSingle(e => ((ArgumentResult)e.SymbolResult).Argument == argument) + .Which + .Message + .Should() + .Be("oops!"); + } + + [Fact] + public void Validation_failure_message_can_be_specified_when_ValueFactory_evaluates_default_option_value() + { + Func valueFactory = result => + { + result.AddError("oops!"); + return null; + }; + var option1 = new Option("-x", new string[0]); + option1.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensNotMatched); + var option = option1; + + new RootCommand { option }.Parse("") + .Errors + .Should() + .ContainSingle() + .Which + .Message + .Should() + .Be("oops!"); + } + + [Fact] + public void ValueFactory_can_parse_scalar_value_from_an_argument_with_one_token() + { + Func valueFactory = result => int.Parse(result.Tokens.Single().Value); + var argument1 = new Argument("arg"); + argument1.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + var argument = argument1; + + new RootCommand { argument }.Parse("123") + .GetValue(argument) + .Should() + .Be(123); + } + + [Fact] + public void ValueFactory_can_parse_sequence_value_from_an_argument_with_one_token() + { + Func> valueFactory = result => result.Tokens.Single().Value.Split(',').Select(int.Parse); + var argument1 = new Argument>("arg"); + argument1.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + var argument = argument1; + + new RootCommand { argument }.Parse("1,2,3") + .GetValue(argument) + .Should() + .BeEquivalentTo(new[] { 1, 2, 3 }); + } + + [Fact] + public void ValueFactory_can_parse_sequence_value_from_an_argument_with_multiple_tokens() + { + Func> valueFactory = result => result.Tokens.Select(t => int.Parse(t.Value)).ToArray(); + var argument1 = new Argument>("arg"); + argument1.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + var argument = argument1; + + new RootCommand { argument }.Parse("1 2 3") + .GetValue(argument) + .Should() + .BeEquivalentTo(new[] { 1, 2, 3 }); + } + + [Fact] + public void ValueFactory_can_parse_scalar_value_from_an_argument_with_multiple_tokens() + { + Func valueFactory = result => result.Tokens.Select(t => int.Parse(t.Value)).Sum(); + var argument1 = new Argument("arg"); + argument1.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + var argument = argument1; + argument.Arity = ArgumentArity.ZeroOrMore; + + new RootCommand { argument }.Parse("1 2 3") + .GetValue(argument) + .Should() + .Be(6); + } + + [Fact] + public void Option_ArgumentResult_Parent_is_set_correctly_when_token_is_implicit_for_ValueFactory() + { + ArgumentResult argumentResult = null; + + Func valueFactory = argResult => + { + argumentResult = argResult; + return null; + }; + var option1 = new Option("-x", new string[0]); + option1.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensNotMatched); + var option = option1; + + var command = new Command("the-command") + { + option + }; + + command.Parse(""); + + argumentResult + .Parent + .Should() + .BeOfType() + .Which + .Option + .Should() + .BeSameAs(command.Options.Single()); + } + + [Fact] + public void Option_ArgumentResult_parentage_to_root_symbol_is_set_correctly_when_token_is_implicit_for_ValueFactory() + { + ArgumentResult argumentResult = null; + + Func valueFactory = argResult => + { + argumentResult = argResult; + return null; + }; + var option1 = new Option("-x", new string[0]); + option1.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensNotMatched); + var option = option1; + + var command = new Command("the-command") + { + option + }; + + command.Parse(""); + + argumentResult + .Parent + .Parent + .Should() + .BeOfType() + .Which + .Command + .Should() + .BeSameAs(command); + } + + [Theory] + [InlineData("-x value-x -y value-y")] + [InlineData("-y value-y -x value-x")] + public void Symbol_can_be_found_without_explicitly_traversing_result_tree_from_ValueFactory(string commandLine) + { + SymbolResult resultForOptionX = null; + Func valueFactory = _ => string.Empty; + var option = new Option("-x", new string[0]); + option.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + var optionX = option; + + Func valueFactory1 = argResult => + { + resultForOptionX = argResult.GetResult(optionX); + return string.Empty; + }; + var option1 = new Option("-y", new string[0]); + option1.SetValueFactory(valueFactory1, ValueFactoryInvocation.WhenTokensMatched); + var optionY = option1; + + var command = new Command("the-command") + { + optionX, + optionY, + }; + + command.Parse(commandLine); + + resultForOptionX + .Should() + .BeOfType() + .Which + .Option + .Should() + .BeSameAs(optionX); + } + + [Fact] + public void Command_ArgumentResult_Parent_is_set_correctly_when_token_is_implicit_for_ValueFactory() + { + ArgumentResult argumentResult = null; + + Func valueFactory = argResult => + { + argumentResult = argResult; + return null; + }; + var argument1 = new Argument("arg"); + argument1.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensNotMatched); + var argument = argument1; + + var command = new Command("the-command") + { + argument + }; + + command.Parse(""); + + argumentResult + .Parent + .Should() + .BeOfType() + .Which + .Command + .Should() + .BeSameAs(command); + } + + [Fact] + public async Task ValueFactory_for_parsing_is_only_called_once() + { + var callCount = 0; + var handlerWasCalled = false; + + Func valueFactory = result => + { + callCount++; + return int.Parse(result.Tokens.Single().Value); + }; + var option1 = new Option("--value", new string[0]); + option1.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + var option = option1; + + var command = new RootCommand(); + command.SetAction((ctx) => handlerWasCalled = true); + command.Options.Add(option); + + await command.Parse("--value 42").InvokeAsync(); + + callCount.Should().Be(1); + handlerWasCalled.Should().BeTrue(); + } + + [Fact] + public void ValueFactory_can_be_used_together_for_default_value_and_custom_argument_parsing() + { + Func valueFactory = result => result.Tokens.Count == 0 ? 123 : 789; + + var argument1 = new Argument("arg"); + argument1.SetValueFactory(valueFactory, ValueFactoryInvocation.Always); + var argument = argument1; + + var result = new RootCommand { argument }.Parse(""); + + result.GetValue(argument) + .Should() + .Be(123); + } + + [Fact] + public void Multiple_arguments_can_have_ValueFactories() + { + Func valueFactory = argumentResult => + { + argumentResult.AddError("nope"); + return null; + }; + var argument = new Argument("from"); + argument.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + Func valueFactory1 = argumentResult => + { + argumentResult.AddError("UH UH"); + return null; + }; + var argument1 = new Argument("to"); + argument1.SetValueFactory(valueFactory1, ValueFactoryInvocation.WhenTokensMatched); + var root = new RootCommand + { + argument, + argument1 + }; + + root.Arguments[0].Arity = new ArgumentArity(0, 2); + root.Arguments[1].Arity = ArgumentArity.ExactlyOne; + + var result = root.Parse("a.txt b.txt /path/to/dir"); + + result.Errors.Select(e => e.Message).Should().Contain("nope"); + result.Errors.Select(e => e.Message).Should().Contain("UH UH"); + } + + [Theory] + [InlineData("--option-with-error 123 --depends-on-option-with-error")] + [InlineData("--depends-on-option-with-error --option-with-error 123")] + public void ValueFactory_can_check_another_option_result_for_custom_errors(string commandLine) + { + Func valueFactory = r => + { + r.AddError("one"); + return r.Tokens[0].Value; + }; + var option = new Option("--option-with-error", new string[0]); + option.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + var optionWithError = option; + + Func valueFactory1 = result => + { + if (result.GetResult(optionWithError) is { } optionWithErrorResult) + { + var otherOptionError = optionWithErrorResult.Errors.SingleOrDefault()?.Message; + result.AddError(otherOptionError + " " + "two"); + } + + return false; + }; + var option1 = new Option("--depends-on-option-with-error", new string[0]); + option1.SetValueFactory(valueFactory1, ValueFactoryInvocation.WhenTokensMatched); + var optionThatDependsOnOptionWithError = option1; + + var command = new Command("cmd") + { + optionWithError, + optionThatDependsOnOptionWithError + }; + + var parseResult = command.Parse(commandLine); + + parseResult.Errors + .Single(e => e.SymbolResult is OptionResult optResult && + optResult.Option == optionThatDependsOnOptionWithError) + .Message + .Should() + .Be("one two"); + } + + [Fact] + public void Validation_reports_all_parse_errors_when_ValueFactory_is_used() + { + Option firstOptionWithError = new("--first-option-with-error"); + firstOptionWithError.Validators.Add(optionResult => optionResult.AddError("first error")); + + Func valueFactory = r => + { + r.AddError("second error"); + return r.Tokens[0].Value; + }; + var option = new Option("--second-option-with-error", new string[0]); + option.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + var secondOptionWithError = option; + + Command command = new("cmd") + { + firstOptionWithError, + secondOptionWithError + }; + + ParseResult parseResult = command.Parse("cmd --first-option-with-error value1 --second-option-with-error value2"); + + OptionResult firstOptionResult = parseResult.GetResult(firstOptionWithError); + firstOptionResult.Errors.Single().Message.Should().Be("first error"); + + OptionResult secondOptionResult = parseResult.GetResult(secondOptionWithError); + secondOptionResult.Errors.Single().Message.Should().Be("second error"); + + parseResult.Errors.Should().Contain(error => error.SymbolResult == firstOptionResult); + parseResult.Errors.Should().Contain(error => error.SymbolResult == secondOptionResult); + } + + [Fact] + public void When_ValueFactory_conversion_fails_then_an_option_does_not_accept_further_arguments() + { + Func valueFactory = argResult => + { + argResult.AddError("nope"); + return default; + }; + var option1 = new Option("-x", new string[0]); + option1.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + var option = option1; + + var command = new Command("the-command") + { + new Argument("arg"), + option + }; + + var result = command.Parse("the-command -x nope yep"); + + result.CommandResult.Tokens.Count.Should().Be(1); + } + + [Fact] + public void When_argument_cannot_be_parsed_as_the_specified_type_then_getting_value_throws_when_ValueFactory_is_used() + { + Func valueFactory = argumentResult => + { + if (int.TryParse(argumentResult.Tokens.Select(t => t.Value).Single(), out var value)) + { + return value; + } + + argumentResult.AddError($"'{argumentResult.Tokens.Single().Value}' is not an integer"); + return default; + }; + string[] aliases = new[] { "-o" }; + var option1 = new Option("--one", aliases); + option1.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + var option = option1; + + var command = new Command("the-command") + { + option + }; + + var result = command.Parse("the-command -o not-an-int"); + + Action getValue = () => result.GetValue(option); + + getValue.Should() + .Throw() + .Which + .Message + .Should() + .Be("'not-an-int' is not an integer"); + } + + [Fact] + public void ValueFactory_is_called_once_per_parse_operation_when_input_is_provided() + { + var i = 0; + + Func valueFactory = result => ++i; + var option1 = new Option("-x", new string[0]); + option1.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + var option = option1; + + var command = new RootCommand + { + option + }; + + command.Parse("-x 123"); + command.Parse("-x 123"); + + i.Should().Be(2); + } + + [Fact] + public void ValueFactory_is_called_once_per_parse_operation_when_no_input_is_provided() + { + var i = 0; + + Func valueFactory = result => ++i; + var option1 = new Option("-x", new string[0]); + option1.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensNotMatched); + var option = option1; + + var command = new RootCommand + { + option + }; + + command.Parse(""); + command.Parse(""); + + i.Should().Be(2); + } + + [Theory] + [InlineData("", "option-is-implicit")] + [InlineData("--bananas", "argument-is-implicit")] + [InlineData("--bananas argument-is-specified", "argument-is-specified")] + public void ValueFactory_when_configured_as_Always_is_called_when_Option_Arity_allows_zero_tokens(string commandLine, string expectedValue) + { + Func both = result => + { + if (result.Tokens.Count == 0) + { + if (result.Parent is OptionResult { Implicit: true }) + { + return "option-is-implicit"; + } + + return "argument-is-implicit"; + } + + return result.Tokens[0].Value; + }; + + var option = new Option("--bananas", new string[0]); + option.SetValueFactory(both, ValueFactoryInvocation.Always); + var opt = option; + opt.Arity = ArgumentArity.ZeroOrOne; + + var rootCommand = new RootCommand + { + opt + }; + + rootCommand.Parse(commandLine).GetValue(opt).Should().Be(expectedValue); + } + + [Theory] + [InlineData("1 2 3 4 5 6 7 8")] + [InlineData("-o 999 1 2 3 4 5 6 7 8")] + [InlineData("1 2 3 -o 999 4 5 6 7 8")] + public void ValueFactory_can_pass_on_remaining_tokens(string commandLine) + { + Func valueFactory = result => + { + result.OnlyTake(3); + + return new[] + { + int.Parse(result.Tokens[0].Value), + int.Parse(result.Tokens[1].Value), + int.Parse(result.Tokens[2].Value) + }; + }; + var argument = new Argument("one"); + argument.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + var argument1 = argument; + + Func valueFactory1 = result => result.Tokens.Select(t => t.Value).Select(int.Parse).ToArray(); + var argument3 = new Argument("two"); + argument3.SetValueFactory(valueFactory1, ValueFactoryInvocation.WhenTokensMatched); + var argument2 = argument3; + + var command = new RootCommand + { + argument1, + argument2, + new Option("-o") + }; + + var parseResult = command.Parse(commandLine); + + parseResult.GetResult(argument1) + .GetValueOrDefault() + .Should() + .BeEquivalentTo(new[] { 1, 2, 3 }, options => options.WithStrictOrdering()); + + parseResult.GetResult(argument2) + .GetValueOrDefault() + .Should() + .BeEquivalentTo(new[] { 4, 5, 6, 7, 8 }, options => options.WithStrictOrdering()); + } + + [Fact] + public void ValueFactory_can_return_null() + { + Func valueFactory = argumentResult => + { + string value = argumentResult.Tokens.Last().Value; + if (IPAddress.TryParse(value, out var address)) + { + return address; + } + + argumentResult.AddError($"'{value}' is not a valid value"); + return null; + }; + var option1 = new Option("-ip", new string[0]); + option1.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + var option = option1; + + ParseResult parseResult = new RootCommand() { option }.Parse("-ip a.b.c.d"); + + parseResult.Errors.Should().Contain(error => error.Message == "'a.b.c.d' is not a valid value"); + } + + [Fact] + public void When_tokens_are_passed_on_by_ValueFactory_on_last_argument_then_they_become_unmatched_tokens() + { + Func valueFactory = result => + { + result.OnlyTake(3); + + return new[] + { + int.Parse(result.Tokens[0].Value), + int.Parse(result.Tokens[1].Value), + int.Parse(result.Tokens[2].Value) + }; + }; + var argument = new Argument("one"); + argument.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + var argument1 = argument; + + var command = new RootCommand + { + argument1 + }; + + var parseResult = command.Parse("1 2 3 4 5 6 7 8"); + + parseResult.UnmatchedTokens + .Should() + .BeEquivalentTo(new[] { "4", "5", "6", "7", "8" }, options => options.WithStrictOrdering()); + } + + [Fact] + public void When_ValueFactory_passes_on_tokens_the_argument_result_tokens_reflect_the_change() + { + Func valueFactory = result => + { + result.OnlyTake(3); + + return new[] + { + int.Parse(result.Tokens[0].Value), + int.Parse(result.Tokens[1].Value), + int.Parse(result.Tokens[2].Value) + }; + }; + var argument = new Argument("one"); + argument.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + var argument1 = argument; + Func valueFactory1 = result => result.Tokens.Select(t => t.Value).Select(int.Parse).ToArray(); + var argument3 = new Argument("two"); + argument3.SetValueFactory(valueFactory1, ValueFactoryInvocation.WhenTokensMatched); + var argument2 = argument3; + + var command = new RootCommand + { + argument1, + argument2 + }; + + var parseResult = command.Parse("1 2 3 4 5 6 7 8"); + + parseResult.GetResult(argument1) + .Tokens + .Select(t => t.Value) + .Should() + .BeEquivalentTo(new[] { "1", "2", "3" }, options => options.WithStrictOrdering()); + + parseResult.GetResult(argument2) + .Tokens + .Select(t => t.Value) + .Should() + .BeEquivalentTo(new[] { "4", "5", "6", "7", "8" }, options => options.WithStrictOrdering()); + } + + [Fact] + public void OnlyTake_throws_when_called_with_a_negative_value_from_ValueFactory() + { + Func valueFactory = result => + { + result.OnlyTake(-1); + return null; + }; + var argument1 = new Argument("one"); + argument1.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + var argument = argument1; + + argument.Invoking(a => new RootCommand { a }.Parse("1 2 3")) + .Should() + .Throw() + .Which + .Message + .Should() + .ContainAll("Value must be at least 1.", "Actual value was -1."); + } + + [Fact] + public void OnlyTake_throws_when_called_twice_from_ValueFactory() + { + Func valueFactory = result => + { + result.OnlyTake(1); + result.OnlyTake(1); + return null; + }; + var argument1 = new Argument("one"); + argument1.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + var argument = argument1; + + argument.Invoking(a => new RootCommand { a }.Parse("1 2 3")) + .Should() + .Throw() + .Which + .Message + .Should() + .Be("OnlyTake can only be called once."); + } + + [Fact] + public void OnlyTake_can_pass_on_all_tokens_from_one_multiple_arity_argument_to_another_when_using_ValueFactory() + { + Func valueFactory = result => + { + result.OnlyTake(0); + return null; + }; + var argument = new Argument("arg1"); + argument.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + var argument1 = argument; + argument1.Arity = ArgumentArity.ZeroOrMore; + + var argument2 = new Argument("arg2"); + + var command = new RootCommand + { + argument1, + argument2 + }; + + var result = command.Parse("1 2 3"); + + result.GetValue(argument1).Should().BeEmpty(); + result.GetValue(argument2).Should().BeEquivalentSequenceTo(1, 2, 3); + } + + [Fact] + public void OnlyTake_can_pass_on_all_tokens_from_a_single_arity_argument_to_another_when_using_ValueFactory() + { + Func valueFactory = ctx => + { + ctx.OnlyTake(0); + return null; + }; + var argument = new Argument("arg"); + argument.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + var scalar = argument; + + Argument multiple = new("args"); + + var command = new RootCommand + { + scalar, + multiple + }; + + var result = command.Parse("1 2 3"); + + result.GetValue(scalar).Should().BeNull(); + result.GetValue(multiple).Should().BeEquivalentSequenceTo(1, 2, 3); + } + + [Fact] + public void OnlyTake_can_pass_on_all_tokens_from_a_single_arity_argument_to_another_that_also_passes_them_all_on_when_using_ValueFactory() + { + Func valueFactory = ctx => + { + ctx.OnlyTake(0); + return null; + }; + var argument = new Argument("first"); + argument.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + var first = argument; + first.Arity = ArgumentArity.ZeroOrOne; + + Func valueFactory1 = ctx => + { + ctx.OnlyTake(0); + return null; + }; + var argument1 = new Argument("second"); + argument1.SetValueFactory(valueFactory1, ValueFactoryInvocation.WhenTokensMatched); + var second = argument1; + second.Arity = ArgumentArity.ZeroOrMore; + + Func valueFactory2 = ctx => + { + ctx.OnlyTake(3); + return new[] { "1", "2", "3" }; + }; + var argument2 = new Argument("third"); + argument2.SetValueFactory(valueFactory2, ValueFactoryInvocation.WhenTokensMatched); + var third = argument2; + third.Arity = ArgumentArity.ZeroOrMore; + + var command = new RootCommand + { + first, + second, + third + }; + + var result = command.Parse("1 2 3"); + + result.GetValue(first).Should().BeNull(); + result.GetValue(second).Should().BeEmpty(); + result.GetValue(third).Should().BeEquivalentSequenceTo("1", "2", "3"); + } + + [Fact] + public void GetResult_by_name_can_be_used_recursively_within_argument_ValueFactories() + { + ArgumentResult firstResult = null; + ArgumentResult secondResult = null; + + Func valueFactory = ctx => + { + secondResult = (ArgumentResult)ctx.GetResult("second"); + return ctx.Tokens.SingleOrDefault()?.Value; + }; + var argument = new Argument("first"); + argument.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + var first = argument; + + Func valueFactory1 = ctx => + { + firstResult = (ArgumentResult)ctx.GetResult("first"); + return ctx.Tokens.SingleOrDefault()?.Value; + }; + var argument1 = new Argument("second"); + argument1.SetValueFactory(valueFactory1, ValueFactoryInvocation.WhenTokensMatched); + var second = argument1; + + var command = new RootCommand + { + first, + second, + new Argument("third") + }; + + var result = command.Parse("one two three"); + + result.GetValue("third").Should().Be("three"); + firstResult.GetValueOrDefault().Should().Be("one"); + secondResult.GetValueOrDefault().Should().Be("two"); + } + + [Theory] + [InlineData("--first one --second two --third three")] + [InlineData("--third three --second two --first one")] + public void GetResult_by_name_can_be_used_recursively_within_option_ValueFactories(string commandLine) + { + OptionResult firstOptionResult = null; + OptionResult secondOptionResult = null; + + Func valueFactory = ctx => + { + secondOptionResult = (OptionResult)ctx.GetResult("--second"); + return ctx.Tokens.SingleOrDefault()?.Value; + }; + var option = new Option("--first", new string[0]); + option.SetValueFactory(valueFactory, ValueFactoryInvocation.WhenTokensMatched); + var first = option; + + Func valueFactory1 = ctx => + { + firstOptionResult = (OptionResult)ctx.GetResult("--first"); + return ctx.Tokens.SingleOrDefault()?.Value; + }; + var option1 = new Option("--second", new string[0]); + option1.SetValueFactory(valueFactory1, ValueFactoryInvocation.WhenTokensMatched); + var second = option1; + + var command = new RootCommand + { + first, + second, + new Option("--third") + }; + + var parseResult = command.Parse(commandLine); + + firstOptionResult.GetValueOrDefault().Should().Be("one"); + secondOptionResult.GetValueOrDefault().Should().Be("two"); + parseResult.GetValue("--first").Should().Be("one"); + parseResult.GetValue("--second").Should().Be("two"); + parseResult.GetValue("--third").Should().Be("three"); + } +} diff --git a/src/System.CommandLine/Argument{T}.cs b/src/System.CommandLine/Argument{T}.cs index 999ac5604f..e9854a012e 100644 --- a/src/System.CommandLine/Argument{T}.cs +++ b/src/System.CommandLine/Argument{T}.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Collections.Generic; +using System.CommandLine.Binding; using System.CommandLine.Parsing; using System.Diagnostics.CodeAnalysis; namespace System.CommandLine @@ -20,6 +21,65 @@ public Argument(string name) : base(name) { } + private static TryConvertArgument CreateConverter(Func valueFactory) + { + return (ArgumentResult argumentResult, out object? parsedValue) => + { + int errorsBefore = argumentResult.SymbolResultTree.ErrorCount; + var result = valueFactory(argumentResult); + + if (errorsBefore == argumentResult.SymbolResultTree.ErrorCount) + { + parsedValue = result; + return true; + } + else + { + parsedValue = default(T)!; + return false; + } + }; + } + + private void SetCustomParser(Func? value) + { + _customParser = value; + ConvertArguments = value is null ? null : CreateConverter(value); + } + + /// + /// Sets a delegate that will be invoked to produce the argument's value. + /// + /// The delegate to invoke to produce the value. + /// + /// Specifies when the delegate should be invoked. Use + /// to handle both explicit values and missing-value defaults with the same delegate. + /// + public void SetValueFactory( + Func valueFactory, + ValueFactoryInvocation invocation = ValueFactoryInvocation.WhenTokensMatched) + { + if (valueFactory is null) + { + throw new ArgumentNullException(nameof(valueFactory)); + } + + if (invocation == 0 || + (invocation & ~ValueFactoryInvocation.Always) != 0) + { + throw new ArgumentOutOfRangeException(nameof(invocation)); + } + + _defaultValueFactory = (invocation & ValueFactoryInvocation.WhenTokensNotMatched) != 0 + ? valueFactory + : null; + + SetCustomParser( + (invocation & ValueFactoryInvocation.WhenTokensMatched) != 0 + ? result => valueFactory(result) + : null); + } + /// /// Gets or sets the delegate to invoke to create the default value. /// @@ -28,17 +88,16 @@ public Argument(string name) : base(name) /// The same instance can be set as . In that case, /// the delegate is also invoked when an input was provided. /// + [Obsolete($"Use SetValueFactory(..., {nameof(ValueFactoryInvocation)}.{nameof(ValueFactoryInvocation.WhenTokensNotMatched)}) instead.")] public Func? DefaultValueFactory { get { - if (_defaultValueFactory is null) + if (_defaultValueFactory is null && this is Argument) { - if (this is Argument boolArgument) - { - boolArgument.DefaultValueFactory = _ => false; - } + _defaultValueFactory = _ => (T)(object)false; } + return _defaultValueFactory; } set => _defaultValueFactory = value; @@ -52,49 +111,32 @@ public Func? DefaultValueFactory /// The same instance can be set as ; in that case, /// the delegate is also invoked when no input was provided. /// + [Obsolete($"Use SetValueFactory(..., {nameof(ValueFactoryInvocation)}.{nameof(ValueFactoryInvocation.WhenTokensMatched)}) instead.")] public Func? CustomParser { get => _customParser; - set - { - _customParser = value; - - if (value is not null) - { - ConvertArguments = (ArgumentResult argumentResult, out object? parsedValue) => - { - int errorsBefore = argumentResult.SymbolResultTree.ErrorCount; - var result = value(argumentResult); - - if (errorsBefore == argumentResult.SymbolResultTree.ErrorCount) - { - parsedValue = result; - return true; - } - else - { - parsedValue = default(T)!; - return false; - } - }; - } - } + set => SetCustomParser(value); } /// public override Type ValueType => typeof(T); /// - public override bool HasDefaultValue => DefaultValueFactory is not null; + public override bool HasDefaultValue => _defaultValueFactory is not null || this is Argument; internal override object? GetDefaultValue(ArgumentResult argumentResult) { - if (DefaultValueFactory is null) + if (_defaultValueFactory is null) { + if (this is Argument) + { + return false; + } + throw new InvalidOperationException($"Argument \"{Name}\" does not have a default value"); } - return DefaultValueFactory.Invoke(argumentResult); + return _defaultValueFactory.Invoke(argumentResult); } [UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050", Justification = "https://github.com/dotnet/command-line-api/issues/1638")] diff --git a/src/System.CommandLine/Option{T}.cs b/src/System.CommandLine/Option{T}.cs index e612dde011..040b9eed92 100644 --- a/src/System.CommandLine/Option{T}.cs +++ b/src/System.CommandLine/Option{T}.cs @@ -28,7 +28,21 @@ private protected Option(string name, string[] aliases, Argument argument) _argument = argument; } + /// + /// Sets a delegate that will be invoked to produce the option's value. + /// + /// The delegate to invoke to produce the value. + /// + /// Specifies when the delegate should be invoked. Use + /// to handle both explicit values and missing-value defaults with the same delegate. + /// + public void SetValueFactory( + Func valueFactory, + ValueFactoryInvocation invocation = ValueFactoryInvocation.WhenTokensMatched) => + _argument.SetValueFactory(valueFactory, invocation); + /// + [Obsolete($"Use SetValueFactory(..., {nameof(ValueFactoryInvocation)}.{nameof(ValueFactoryInvocation.WhenTokensNotMatched)}) instead.")] public Func? DefaultValueFactory { get => _argument.DefaultValueFactory; @@ -36,6 +50,7 @@ public Func? DefaultValueFactory } /// + [Obsolete($"Use SetValueFactory(..., {nameof(ValueFactoryInvocation)}.{nameof(ValueFactoryInvocation.WhenTokensMatched)}) instead.")] public Func? CustomParser { get => _argument.CustomParser; diff --git a/src/System.CommandLine/ValueFactoryInvocation.cs b/src/System.CommandLine/ValueFactoryInvocation.cs new file mode 100644 index 0000000000..fb70f58281 --- /dev/null +++ b/src/System.CommandLine/ValueFactoryInvocation.cs @@ -0,0 +1,26 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace System.CommandLine; + +/// +/// Specifies when a value factory should be invoked. +/// +[Flags] +public enum ValueFactoryInvocation +{ + /// + /// Invoke the value factory when tokens were provided for the argument. + /// + WhenTokensMatched = 1, + + /// + /// Invoke the value factory when no tokens were provided for the argument. + /// + WhenTokensNotMatched = 2, + + /// + /// Invoke the value factory whether or not tokens were provided for the argument. + /// + Always = WhenTokensMatched | WhenTokensNotMatched +} \ No newline at end of file