diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/CliScraperTraversalTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/CliScraperTraversalTests.cs index 406f8ab0b71..9c1be2661fb 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/CliScraperTraversalTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Cli/CliScraperTraversalTests.cs @@ -620,6 +620,30 @@ fake execute [flags] await Assert.That(tag.CSharpType).IsEqualTo("IEnumerable?"); } + [Test] + [Arguments("Accepts multiple values")] + [Arguments("One or more label selectors")] + public async Task SharedShapeInference_Preserves_Common_Repeatability_Phrases(string description) + { + var helpText = $""" + Execute a command. + + Usage: + fake execute [flags] + + Flags: + --tag string {description} + """; + var scraper = new TestCobraScraper(new StubExecutor( + new Dictionary(StringComparer.OrdinalIgnoreCase))); + + var command = await scraper.Parse(["fake", "execute"], helpText); + var tag = command!.Options.Single(); + + await Assert.That(tag.AcceptsMultipleValues).IsTrue(); + await Assert.That(tag.CSharpType).IsEqualTo("IEnumerable?"); + } + [Test] public async Task SharedShapeInference_Models_Optional_Cobra_Option_Values() { @@ -663,13 +687,42 @@ fake [flags] { ["--help"] = helpText, }); - var scraper = new ShapeMismatchScraper(executor); + var scraper = new OptionShapeScraper(executor); var commands = await ScrapeAsync(scraper); await Assert.That(commands).IsEmpty(); } + [Test] + public async Task SharedTraversal_Preserves_Boolean_Value_Options_With_Repeatability_Prose() + { + const string helpText = """ + Execute a command. + + Usage: + fake [flags] + + Flags: + --tag= May be specified multiple times + """; + var executor = new StubExecutor(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["--help"] = helpText, + }); + var scraper = new OptionShapeScraper(executor, "bool?"); + + var command = (await ScrapeAsync(scraper)).Single(); + var option = command.Options.Single(); + + using (Assert.Multiple()) + { + await Assert.That(option.CSharpType).IsEqualTo("bool?"); + await Assert.That(option.IsFlag).IsFalse(); + await Assert.That(option.AcceptsMultipleValues).IsFalse(); + } + } + [Test] public async Task SharedTraversal_Propagates_Invalid_Operand_Coverage() { @@ -694,7 +747,7 @@ await Assert.That(Scrape) [Test] public async Task Shared_Skip_Filter_Preserves_Uppercase_Subcommands() { - var scraper = new ShapeMismatchScraper(new StubExecutor( + var scraper = new OptionShapeScraper(new StubExecutor( new Dictionary(StringComparer.OrdinalIgnoreCase))); await Assert.That(scraper.Skips("SSH")).IsFalse(); @@ -875,14 +928,17 @@ public Task IsAvailableAsync( Task.FromResult(true); } - private sealed class ShapeMismatchScraper : CliScraperBase + private sealed class OptionShapeScraper : CliScraperBase { - public ShapeMismatchScraper(ICliCommandExecutor executor) + private readonly string _csharpType; + + public OptionShapeScraper(ICliCommandExecutor executor, string csharpType = "string?") : base( executor, new HelpTextCache(NullLogger.Instance), - NullLogger.Instance) + NullLogger.Instance) { + _csharpType = csharpType; } public override string ToolName => "fake"; @@ -914,7 +970,7 @@ public ShapeMismatchScraper(ICliCommandExecutor executor) { SwitchName = "--tag", PropertyName = "Tag", - CSharpType = "string?", + CSharpType = _csharpType, Description = "May be specified multiple times", }, ], diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs new file mode 100644 index 00000000000..aeaec591051 --- /dev/null +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/RepeatableOptionAdapterTests.cs @@ -0,0 +1,262 @@ +using Microsoft.Extensions.Logging.Abstractions; +using ModularPipelines.OptionsGenerator.Models; +using ModularPipelines.OptionsGenerator.Scrapers.Cli; +using ModularPipelines.OptionsGenerator.TypeDetection; + +namespace ModularPipelines.OptionsGenerator.Tests.Scrapers; + +public class RepeatableOptionAdapterTests +{ + private static ICliCommandExecutor Executor { get; } = + new ProcessCliCommandExecutor(NullLogger.Instance); + + private static IHelpTextCache Cache { get; } = + new HelpTextCache(NullLogger.Instance); + + [Test] + public async Task Terraform_Recognizes_Multiple_Times_Prose() + { + const string helpText = """ + Usage: terraform apply [options] + + Options: + -var-file=path Set variables from a file. This flag can be used multiple times. + """; + var command = await new TestTerraformCliScraper().Parse(["terraform", "apply"], helpText); + + await AssertRepeatable(command, "-var-file"); + } + + [Test] + public async Task Pip_Recognizes_Multiline_Multiple_Times_Prose() + { + const string helpText = """ + Usage: pip freeze [options] + + General Options: + -r, --requirement Install from the given requirements file. + This option can be used multiple times. + """; + var command = await new TestPipCliScraper().Parse(["pip", "freeze"], helpText); + + await AssertRepeatable(command, "--requirement"); + } + + [Test] + public async Task Packer_Recognizes_Repeatable_Prose() + { + const string helpText = """ + Usage: packer build [options] + + Options: + -var-file=path Set a variable file; repeatable for additional files. + """; + var command = await new TestPackerCliScraper().Parse(["packer", "build"], helpText); + + await AssertRepeatable(command, "--var-file"); + } + + [Test] + public async Task Packer_Recognizes_Quoted_Repeatable_Value() + { + const string helpText = """ + Usage: packer build [options] + + Options: + -var 'key=value' Set a variable. Can be repeated. + """; + var command = await new TestPackerCliScraper().Parse(["packer", "build"], helpText); + + await AssertRepeatable(command, "--var"); + } + + [Test] + public async Task Packer_Recognizes_Multiline_Repeatable_Prose() + { + const string helpText = """ + Usage: packer build [options] + + Options: + -var-file=path Set a variable file. + Can be repeated for additional files. + """; + var command = await new TestPackerCliScraper().Parse(["packer", "build"], helpText); + + await AssertRepeatable(command, "--var-file"); + } + + [Test] + public async Task Packer_Does_Not_Treat_Operational_Repetition_As_Repeatable() + { + const string helpText = """ + Usage: packer build [options] + + Options: + -retry-count=count Retry the operation multiple times before failing. + """; + var command = await new TestPackerCliScraper().Parse(["packer", "build"], helpText); + var option = command!.Options.Single(item => item.SwitchName == "--retry-count"); + + using (Assert.Multiple()) + { + await Assert.That(option.AcceptsMultipleValues).IsFalse(); + await Assert.That(option.CSharpType).IsEqualTo("string?"); + } + } + + [Test] + [Arguments("This argument must be repeated for each entry.", true)] + [Arguments("The value is repeated across runs.", true)] + [Arguments("Provide one or more paths to scan.", true)] + [Arguments("Validates multiple values against a schema.", false)] + [Arguments("Retry the operation one or more times before failing.", false)] + [Arguments("This operation runs one or more attempts depending on configuration.", false)] + [Arguments("One or more attempts are made before failure.", false)] + [Arguments("One or more times may be required.", false)] + [Arguments("One or more retries may be attempted.", false)] + [Arguments("Provide one or more attempts before failing.", false)] + [Arguments("Accepts one or more retries.", false)] + [Arguments("Give one or more times to retry.", false)] + [Arguments("One or more retry attempts before failing.", false)] + [Arguments("Zero or more retry attempts may be made.", false)] + [Arguments("One or more connection retries before giving up.", false)] + [Arguments("One or more polling attempts before timing out.", false)] + [Arguments("This option can be specified one or more times.", true)] + [Arguments("May be provided zero or more times.", true)] + [Arguments("This option should be used more than once.", true)] + [Arguments("Supports multiple values.", true)] + [Arguments("Takes multiple values.", true)] + [Arguments("Value can contain multiple values.", true)] + public async Task Packer_Classifies_Explicit_Repeatability_Prose( + string description, + bool expected) + { + var helpText = $""" + Usage: packer build [options] + + Options: + -var-file=path {description} + """; + var command = await new TestPackerCliScraper().Parse(["packer", "build"], helpText); + var option = command!.Options.Single(item => item.SwitchName == "--var-file"); + + await Assert.That(option.AcceptsMultipleValues).IsEqualTo(expected); + } + + [Test] + public async Task Snyk_Recognizes_Repeated_Prose() + { + const string helpText = """ + Usage: snyk monitor [] + + Options + --project-environment= + Set the project environment. Can be repeated. + """; + var command = await new TestSnykCliScraper().Parse(["snyk", "monitor"], helpText); + + await AssertRepeatable(command, "--project-environment"); + } + + [Test] + public async Task Snyk_Preserves_Numeric_Element_Type_For_Repeatable_Options() + { + const string helpText = """ + Usage: snyk test [] + + Options + --max-depth= + Set the maximum dependency depth. Can be repeated. + """; + var command = await new TestSnykCliScraper().Parse(["snyk", "test"], helpText); + + await AssertRepeatable(command, "--max-depth", "IEnumerable?"); + } + + [Test] + public async Task Snyk_Preserves_Enum_Element_Type_For_Repeatable_Options() + { + const string helpText = """ + Usage: snyk test [] + + Options + --severity-threshold= + Report only vulnerabilities at the selected threshold. Can be repeated. + """; + var command = await new TestSnykCliScraper().Parse(["snyk", "test"], helpText); + + await AssertRepeatable( + command, + "--severity-threshold", + "IEnumerable?"); + } + + private static async Task AssertRepeatable( + CliCommandDefinition? command, + string switchName, + string expectedType = "IEnumerable?") + { + var option = command!.Options.Single(item => item.SwitchName == switchName); + using (Assert.Multiple()) + { + await Assert.That(option.AcceptsMultipleValues).IsTrue(); + await Assert.That(option.CSharpType).IsEqualTo(expectedType); + } + } + + private sealed class TestTerraformCliScraper() + : TerraformCliScraper( + RepeatableOptionAdapterTests.Executor, + RepeatableOptionAdapterTests.Cache, + NullLogger.Instance) + { + public Task Parse(string[] commandPath, string helpText) => + ParseCommandAsync( + commandPath, + helpText, + ParseUsageSynopsis(commandPath, helpText), + CancellationToken.None); + } + + private sealed class TestPipCliScraper() + : PipCliScraper( + RepeatableOptionAdapterTests.Executor, + RepeatableOptionAdapterTests.Cache, + NullLogger.Instance) + { + public Task Parse(string[] commandPath, string helpText) => + ParseCommandAsync( + commandPath, + helpText, + ParseUsageSynopsis(commandPath, helpText), + CancellationToken.None); + } + + private sealed class TestPackerCliScraper() + : PackerCliScraper( + RepeatableOptionAdapterTests.Executor, + RepeatableOptionAdapterTests.Cache, + NullLogger.Instance) + { + public Task Parse(string[] commandPath, string helpText) => + ParseCommandAsync( + commandPath, + helpText, + ParseUsageSynopsis(commandPath, helpText), + CancellationToken.None); + } + + private sealed class TestSnykCliScraper() + : SnykCliScraper( + RepeatableOptionAdapterTests.Executor, + RepeatableOptionAdapterTests.Cache, + NullLogger.Instance) + { + public Task Parse(string[] commandPath, string helpText) => + ParseCommandAsync( + commandPath, + helpText, + ParseUsageSynopsis(commandPath, helpText), + CancellationToken.None); + } +} diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/WinGetCliScraperTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/WinGetCliScraperTests.cs index bfc90695c52..1f613a87f8e 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/WinGetCliScraperTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/WinGetCliScraperTests.cs @@ -49,6 +49,31 @@ List installed packages. } } + [Test] + public async Task Explicit_Repeatability_Takes_Precedence_Over_Boolean_Heuristics() + { + const string helpText = """ + List installed packages. + + usage: winget list [] + + The following options are available: + --source Accepts multiple values + """; + + var command = await new TestWinGetCliScraper().Parse( + ["winget", "list"], + helpText); + var source = command!.Options.Single(option => option.SwitchName == "--source"); + + using (Assert.Multiple()) + { + await Assert.That(source.IsFlag).IsFalse(); + await Assert.That(source.AcceptsMultipleValues).IsTrue(); + await Assert.That(source.CSharpType).IsEqualTo("IEnumerable?"); + } + } + [Test] public async Task Does_Not_Mark_Boolean_Flags_As_Repeatable_Values() { diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs index 2061ee64935..57ae62a37a6 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/CliScraperBase.cs @@ -971,6 +971,23 @@ protected static bool HelpDeclaresRepeatableOption( protected static bool DescriptionDeclaresRepeatableOption(string description) => RepeatableValuePattern().IsMatch(description); + /// + /// Returns whether a value-taking, non-Boolean option is described as repeatable. + /// + protected static bool IsRepeatableValueOption( + string description, + bool isFlag, + bool isBoolean = false) => + !isFlag && !isBoolean && DescriptionDeclaresRepeatableOption(description); + + /// + /// Converts a scalar C# type into its repeatable collection representation when needed. + /// + protected static string AsCSharpType(string scalarType, bool acceptsMultipleValues) => + acceptsMultipleValues + ? $"IEnumerable<{scalarType.TrimEnd('?')}>?" + : scalarType; + /// /// Parses indentation-based argument declarations into a reusable nested group model. /// The adapter only recognizes one tool-specific declaration line; traversal, @@ -986,6 +1003,7 @@ private static void ValidateOptionShapes(CliCommandDefinition command, string he foreach (var option in command.Options) { var description = option.Description ?? string.Empty; + var isBoolean = option.CSharpType is "bool" or "bool?"; if (HelpDeclaresExplicitBooleanValue(description) && option.IsFlag) { throw new InvalidOperationException( @@ -994,6 +1012,7 @@ private static void ValidateOptionShapes(CliCommandDefinition command, string he } if (!option.IsFlag + && !isBoolean && HelpDeclaresRepeatableOption(helpText, option.SwitchName, description) && !option.AcceptsMultipleValues) { @@ -1050,9 +1069,27 @@ private static void ValidateArgumentGroups(CliCommandDefinition command) RegexOptions.IgnoreCase)] private static partial Regex ExplicitBooleanValuePattern(); - [GeneratedRegex( - @"\b(?:one\s+or\s+more|zero\s+or\s+more|multiple\s+(?:times|values)|more\s+than\s+once|repeat(?:able|ed|edly)?)\b", - RegexOptions.IgnoreCase)] + private const string OperationalCountPhrasePattern = + @"(?:[\w-]+\s+){0,2}(?:attempts?|times?|retries?)\b"; + + private const string RepeatableItemCountPattern = + @"(?:one|zero)\s+or\s+more\s+(?!" + OperationalCountPhrasePattern + @")[\w-]+"; + + private const string RepeatableValueRegex = + @"\b(?:" + + @"repeatable" + + @"|(?:can|may|must|should)\s+be\s+repeated" + + @"|(?:is|are)\s+repeated" + + @"|\A" + RepeatableItemCountPattern + + @"|(?:can|may|must|should)\s+be\s+" + + @"(?:specified|supplied|provided|used|passed|set|given)\s+" + + @"(?:(?:one|zero)\s+or\s+more\s+times|multiple\s+times|more\s+than\s+once)" + + @"|(?:accepts?|specify|supply|provide|use|pass|set|give|supports?|takes?|contains?)\s+" + + @"(?:multiple\s+times|more\s+than\s+once|" + + RepeatableItemCountPattern + + @"|multiple\s+[\w-]+))\b"; + + [GeneratedRegex(RepeatableValueRegex, RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace)] private static partial Regex RepeatableValuePattern(); [GeneratedRegex(@"\x1B(?:\][^\x07\x1B]*(?:\x07|\x1B\\)|\[[0-?]*[ -/]*[@-~])")] diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PackerCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PackerCliScraper.cs index a9e9d488032..a7880b7983f 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PackerCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PackerCliScraper.cs @@ -179,8 +179,9 @@ private List ParseOptions(string helpText) var lines = section.Split('\n'); - foreach (var line in lines) + for (var i = 0; i < lines.Length; i++) { + var line = lines[i]; var match = PackerOptionPattern().Match(line); if (!match.Success) { @@ -205,14 +206,20 @@ private List ParseOptions(string helpText) seenOptions.Add(longForm); + i = AccumulateMultiLineDescription(lines, i, ref description); + var propertyName = NormalizePropertyName(longForm); if (propertyName is null) { continue; } - var isFlag = string.IsNullOrEmpty(valueHint) || valueHint.Contains("true") || valueHint.Contains("false"); - var csharpType = isFlag ? "bool?" : "string?"; + var isFlag = string.IsNullOrEmpty(valueHint) + || valueHint.Equals("true", StringComparison.OrdinalIgnoreCase) + || valueHint.Equals("false", StringComparison.OrdinalIgnoreCase); + var acceptsMultipleValues = IsRepeatableValueOption(description, isFlag); + var scalarType = isFlag ? "bool?" : "string?"; + var csharpType = AsCSharpType(scalarType, acceptsMultipleValues); options.Add(new CliOptionDefinition { @@ -223,7 +230,7 @@ private List ParseOptions(string helpText) Description = description, IsFlag = isFlag, IsRequired = false, - AcceptsMultipleValues = false, + AcceptsMultipleValues = acceptsMultipleValues, IsKeyValue = false, IsNumeric = false, ValueSeparator = "=", @@ -235,6 +242,38 @@ private List ParseOptions(string helpText) return options; } + private static int AccumulateMultiLineDescription( + string[] lines, + int currentIndex, + ref string description) + { + var descriptionParts = new List(); + if (!string.IsNullOrEmpty(description)) + { + descriptionParts.Add(description); + } + + var optionIndent = lines[currentIndex].Length - lines[currentIndex].TrimStart().Length; + var nextIndex = currentIndex + 1; + while (nextIndex < lines.Length) + { + var nextLine = lines[nextIndex]; + var trimmedNext = nextLine.Trim(); + if (string.IsNullOrWhiteSpace(trimmedNext) + || trimmedNext.StartsWith('-') + || nextLine.Length - nextLine.TrimStart().Length <= optionIndent) + { + break; + } + + descriptionParts.Add(trimmedNext); + nextIndex++; + } + + description = string.Join(" ", descriptionParts); + return nextIndex - 1; + } + /// /// Checks if help text indicates the command has options. /// @@ -269,7 +308,7 @@ protected override bool HasOptions(string helpText) /// -debug Debug mode enabled /// -var 'key=value' Variable for templates /// - [GeneratedRegex(@"^\s+(?-[\w-]+)(?:=(?\S+)|\s+'[^']+')?\s{2,}(?.*)$", RegexOptions.Multiline)] + [GeneratedRegex(@"^\s+(?-[\w-]+)(?:=(?\S+)|\s+'(?[^']+)')?\s{2,}(?.*)$", RegexOptions.Multiline)] private static partial Regex PackerOptionPattern(); #endregion diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PipCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PipCliScraper.cs index b5b1e7432ed..0987ad4c5f1 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PipCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/PipCliScraper.cs @@ -274,7 +274,9 @@ private List ParseOptions(string helpText, string[] command } var isFlag = string.IsNullOrEmpty(valueHint) && IsBooleanOption(longForm, description); - var csharpType = isFlag ? "bool?" : "string?"; + var acceptsMultipleValues = IsRepeatableValueOption(description, isFlag); + var scalarType = isFlag ? "bool?" : "string?"; + var csharpType = AsCSharpType(scalarType, acceptsMultipleValues); options.Add(new CliOptionDefinition { @@ -285,7 +287,7 @@ private List ParseOptions(string helpText, string[] command Description = description, IsFlag = isFlag, IsRequired = false, - AcceptsMultipleValues = false, + AcceptsMultipleValues = acceptsMultipleValues, IsKeyValue = false, IsNumeric = false, ValueSeparator = isFlag ? " " : " ", diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/SnykCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/SnykCliScraper.cs index 0b0a7ceded0..281fa516162 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/SnykCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/SnykCliScraper.cs @@ -321,6 +321,10 @@ private void ParseOptionLines( && !isNumeric && !ValueOptionsWithoutHelpPlaceholders.Contains(longForm); var isBoolean = IsBooleanValueHint(valueHint); + var acceptsMultipleValues = IsRepeatableValueOption( + description ?? string.Empty, + isFlag, + isBoolean); var csharpType = isFlag || isBoolean ? "bool?" : isNumeric ? "int?" : "string?"; CliEnumDefinition? enumDef = null; @@ -345,6 +349,8 @@ private void ParseOptionLines( } } + csharpType = AsCSharpType(csharpType, acceptsMultipleValues); + options.Add(new CliOptionDefinition { SwitchName = longForm, @@ -354,7 +360,7 @@ private void ParseOptionLines( Description = description, IsFlag = isFlag, IsRequired = description?.Contains("Required.", StringComparison.OrdinalIgnoreCase) == true, - AcceptsMultipleValues = false, + AcceptsMultipleValues = acceptsMultipleValues, IsKeyValue = false, IsNumeric = isNumeric, ValueSeparator = isFlag ? " " : "=", diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TerraformCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TerraformCliScraper.cs index c1d34c35bd8..6fe23534b9e 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TerraformCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/TerraformCliScraper.cs @@ -315,7 +315,8 @@ private List ParseOptions(string helpText, string[] command isFlag = true; } - var csharpType = isFlag ? "bool?" : (isInteger ? "int?" : "string?"); + var acceptsMultipleValues = IsRepeatableValueOption(description, isFlag, isBoolean); + var csharpType = GetCSharpType(isFlag, isInteger, acceptsMultipleValues); options.Add(new CliOptionDefinition { @@ -326,7 +327,7 @@ private List ParseOptions(string helpText, string[] command Description = description, IsFlag = isFlag, IsRequired = false, - AcceptsMultipleValues = false, + AcceptsMultipleValues = acceptsMultipleValues, IsKeyValue = false, IsNumeric = isInteger, ValueSeparator = isFlag ? " " : "=", @@ -338,6 +339,12 @@ private List ParseOptions(string helpText, string[] command return options; } + private static string GetCSharpType(bool isFlag, bool isInteger, bool acceptsMultipleValues) + { + var scalarType = isFlag ? "bool?" : isInteger ? "int?" : "string?"; + return AsCSharpType(scalarType, acceptsMultipleValues); + } + /// /// Checks if a value hint indicates a numeric type. /// diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/WinGetCliScraper.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/WinGetCliScraper.cs index 36d4dae8e7d..0977c25928a 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/WinGetCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/WinGetCliScraper.cs @@ -436,13 +436,12 @@ private List ParseArguments(string helpText, HashSet?" - : "string?"; + var declaresRepeatability = DescriptionDeclaresRepeatableOption(description); + var isFlag = IsKnownBooleanOption(className, longForm) + || (!declaresRepeatability && IsBooleanDescription(description)); + var acceptsMultipleValues = !isFlag && declaresRepeatability; + var scalarType = isFlag ? "bool?" : "string?"; + var csharpType = AsCSharpType(scalarType, acceptsMultipleValues); return new CliOptionDefinition {