diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/OptionEnumFactoryTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/OptionEnumFactoryTests.cs index e9c84215d64..3500ff4d1fa 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/OptionEnumFactoryTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Generators/OptionEnumFactoryTests.cs @@ -6,9 +6,17 @@ namespace ModularPipelines.OptionsGenerator.Tests.Generators; public class OptionEnumFactoryTests { [Test] - public async Task Syntax_Delimiters_Are_Rejected_Only_For_Inferred_Choices() + public async Task Empty_List_Default_Does_Not_Add_Type_Metadata() { - string[] values = ["[json", "yaml]"]; + await Assert.That(OptionEnumFactory.PreserveValueHint(null, "Set labels.", "[]")).IsEqualTo("Set labels."); + } + + [Test] + [Arguments("[json", "yaml]")] + [Arguments("(json", "yaml)")] + public async Task Syntax_Delimiters_Are_Rejected_Only_For_Inferred_Choices(string first, string second) + { + string[] values = [first, second]; await Assert.That(OptionEnumFactory.TryCreateFromHint("TestOptions", "Mode", "--mode", values)).IsNull(); var structured = OptionEnumFactory.TryCreate("TestOptions", "Mode", "--mode", values); await Assert.That(structured!.Values.Select(value => value.CliValue)).IsEquivalentTo(values); diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Base/CliDocumentationScraperBaseTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Base/CliDocumentationScraperBaseTests.cs index 512c99f6437..478476921f0 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Base/CliDocumentationScraperBaseTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/Base/CliDocumentationScraperBaseTests.cs @@ -18,6 +18,9 @@ public class CliDocumentationScraperBaseTests [Arguments("json|yaml]")] [Arguments("[json|yaml>")] [Arguments("[]")] + [Arguments("(json|yaml")] + [Arguments("json|yaml)")] + [Arguments("(json|yaml]")] public async Task DetectEnumValues_Rejects_Annotated_Or_Incomplete_Pipe_Hints(string hint) { await Assert.That(TestDocumentationScraper.DetectEnumValues(hint)).IsNull(); @@ -36,6 +39,8 @@ public async Task DetectEnumValues_Does_Not_Infer_Enums_From_Numeric_Hints(strin [Arguments("")] [Arguments("{json|yaml}")] [Arguments("[json|yaml]")] + [Arguments("(json|yaml)")] + [Arguments(" ( json|yaml ) ")] [Arguments(" [ json|yaml ] ")] [Arguments(" < json|yaml > ")] [Arguments(" { json|yaml } ")] diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/CobraCliScraperTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/CobraCliScraperTests.cs index b816336fe49..8e952083d6a 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/CobraCliScraperTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/CobraCliScraperTests.cs @@ -48,6 +48,7 @@ public async Task Type_Hint_Preserves_Long_Choices() [Arguments("")] [Arguments("{json|yaml}")] [Arguments("[json|yaml]")] + [Arguments("(json|yaml)")] public async Task Type_Hint_Strips_Choice_Wrappers(string hint) { var helpText = $"Usage: fake run [OPTIONS]\n\nOptions:\n --mode {hint} Select mode\n"; diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/DockerDocumentationScraperTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/DockerDocumentationScraperTests.cs index 557e5f0b5e5..65d54e22f61 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/DockerDocumentationScraperTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/DockerDocumentationScraperTests.cs @@ -6,6 +6,22 @@ namespace ModularPipelines.OptionsGenerator.Tests.Scrapers; public class DockerDocumentationScraperTests { + [Test] + public async Task Empty_List_Default_Does_Not_Become_Value_Type_Metadata() + { + const string html = """ +

docker build

+ +
OptionDescriptionDefault
--labelSet labels.[]
+ + """; + using var handler = new DocumentationHandler(html); + using var client = new HttpClient(handler); + var scraper = new DockerDocumentationScraper(client, NullLogger.Instance); + var tool = await scraper.ScrapeAsync(); + await Assert.That(tool.Commands.Single().Options.Single().Description).IsEqualTo("Set labels."); + } + [Test] [Arguments(2, "", "")] [Arguments(21, "", "")] diff --git a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/SnykCliScraperTests.cs b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/SnykCliScraperTests.cs index 574c08f71a4..a0e4fc44ab4 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/SnykCliScraperTests.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator.Tests/Scrapers/SnykCliScraperTests.cs @@ -7,6 +7,85 @@ namespace ModularPipelines.OptionsGenerator.Tests.Scrapers; public class SnykCliScraperTests { + [Test] + [Arguments("[json|yaml]")] + [Arguments("(json|yaml)")] + [Arguments("{json|yaml}")] + [Arguments("")] + public async Task Optional_Heading_Brackets_Do_Not_Become_Part_Of_The_Value_Hint(string hint) + { + var command = (await new TestSnykCliScraper().Parse(["snyk", "test"], + $"Options\n [--mode={hint}]\n Select mode."))!; + await Assert.That(command.Options.Single().EnumDefinition?.Values.Select(value => value.CliValue)) + .IsEquivalentTo(["json", "yaml"]); + } + + [Test] + [Arguments("[]")] + [Arguments("()")] + [Arguments("{}")] + [Arguments("<>")] + public async Task Optional_Headings_Preserve_Empty_Value_Placeholders(string hint) + { + var command = (await new TestSnykCliScraper().Parse(["snyk", "test"], + $"Options\n [--mode={hint}]\n Select mode."))!; + var option = command.Options.Single(); + await Assert.That(option.CSharpType).IsEqualTo("string?"); + await Assert.That(option.IsFlag).IsFalse(); + await Assert.That(option.Description).IsEqualTo($"Select mode. [value type: {hint}]"); + } + + [Test] + [Arguments("(json|yaml)")] + [Arguments("{json|yaml}")] + [Arguments("")] + public async Task Choice_Hints_Use_Shared_Wrapper_Parsing(string hint) + { + var command = (await new TestSnykCliScraper().Parse(["snyk", "test"], + $"Options\n --mode={hint}\n Select mode."))!; + await Assert.That(command.Options.Single().EnumDefinition!.Values.Select(value => value.CliValue)) + .IsEquivalentTo(["json", "yaml"]); + } + + [Test] + [Arguments("(true|false)")] + [Arguments("{true|false}")] + [Arguments("")] + public async Task Wrapped_Boolean_Choices_Remain_Value_Options(string hint) + { + var command = (await new TestSnykCliScraper().Parse(["snyk", "test"], + $"Options\n --reachable={hint}\n Select reachability."))!; + var option = command.Options.Single(); + await Assert.That(option.CSharpType).IsEqualTo("bool?"); + await Assert.That(option.IsFlag).IsFalse(); + await Assert.That(option.EnumDefinition).IsNull(); + } + + [Test] + [Arguments("(json|yaml")] + [Arguments("json|yaml)")] + [Arguments("json||yaml")] + [Arguments("true||false")] + [Arguments("(json||yaml)")] + [Arguments("()")] + [Arguments("[]")] + [Arguments("{}")] + [Arguments("<>")] + [Arguments("")] + [Arguments(" HintSyntaxDelimiters = SearchValues.Create("<>{}[]"); + private static readonly SearchValues HintSyntaxDelimiters = SearchValues.Create("<>{}[]()"); // Inferred hints need a lexical guard; authoritative structured choices may // contain numeric or symbolic literals and must retain their original values. @@ -22,7 +22,7 @@ public static string UnwrapChoiceHint(string hint) { var trimmed = hint.Trim(); return trimmed.Length >= 2 - && (trimmed[0], trimmed[^1]) is ('<', '>') or ('{', '}') or ('[', ']') + && (trimmed[0], trimmed[^1]) is ('<', '>') or ('{', '}') or ('[', ']') or ('(', ')') ? trimmed[1..^1].Trim() : trimmed; } 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 058ee90a8d2..686672aa633 100644 --- a/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/SnykCliScraper.cs +++ b/tools/ModularPipelines.OptionsGenerator/src/ModularPipelines.OptionsGenerator/Scrapers/Cli/SnykCliScraper.cs @@ -400,7 +400,12 @@ private void ParseOptionLines( HashSet seenOptions) { var longForm = match.Groups["long"].Value.Trim(); - var valueHint = match.Groups["value"].Value.Trim().Trim('<', '>', '[', ']'); + var rawValueHint = match.Groups["value"].Value; + if (match.Groups["optional"].Success && rawValueHint.EndsWith(']')) + { + rawValueHint = rawValueHint[..^1]; + } + var valueHint = OptionEnumFactory.UnwrapChoiceHint(rawValueHint); if (!seenOptions.Add(longForm)) { return null; @@ -412,7 +417,7 @@ private void ParseOptionLines( } var isNumeric = NumericOptions.Contains(longForm); - var isFlag = IsFlagOption(longForm, valueHint, isNumeric); + var isFlag = IsFlagOption(longForm, rawValueHint, isNumeric); var isBoolean = IsBooleanValueHint(valueHint); var acceptsMultipleValues = AcceptsMultipleValues( commandParts, @@ -422,6 +427,11 @@ private void ParseOptionLines( isBoolean); var enumDefinition = CreateEnumDefinition(propertyName, longForm, valueHint, isBoolean); var scalarType = GetScalarType(enumDefinition, isFlag, isBoolean, isNumeric); + if (rawValueHint.Length > 0 && valueHint.Length == 0) + { + // Here the token is an explicit value placeholder, not a table's default cell. + description = $"{description} [value type: {rawValueHint}]".Trim(); + } return new CliOptionDefinition { @@ -429,7 +439,7 @@ private void ParseOptionLines( ShortForm = null, PropertyName = propertyName, CSharpType = AsCSharpType(scalarType, acceptsMultipleValues), - Description = description, + Description = isBoolean ? description : OptionEnumFactory.PreserveValueHint(enumDefinition, description, rawValueHint), IsFlag = isFlag, IsRequired = description is not null && DescriptionDeclaresRequiredOption(description), AcceptsMultipleValues = acceptsMultipleValues, @@ -481,19 +491,8 @@ enumDefinition is not null return null; } - var values = valueHint.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - return values.Length < 2 - ? null - : new CliEnumDefinition - { - EnumName = $"Snyk{propertyName}", - Values = values.Select(value => new CliEnumValue - { - MemberName = GeneratorUtils.ToEnumMemberName(value), - CliValue = value, - }).ToList(), - Description = $"Allowed values for --{longForm.TrimStart('-')}", - }; + var values = valueHint.Split('|', StringSplitOptions.TrimEntries); + return OptionEnumFactory.TryCreateFromHint("Snyk", propertyName, longForm, values); } private static bool IsKnownScalarValueOption( @@ -582,7 +581,7 @@ private static void AddOptionIfMissing( private static bool IsBooleanValueHint(string valueHint) { - var values = valueHint.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var values = valueHint.Split('|', StringSplitOptions.TrimEntries); return values.Length == 2 && values.Contains("true", StringComparer.OrdinalIgnoreCase) && values.Contains("false", StringComparer.OrdinalIgnoreCase); @@ -833,7 +832,7 @@ protected override bool HasOptions(string helpText) /// --severity-threshold=<low|medium|high|critical> /// --json /// - [GeneratedRegex(@"(?--[\w-]+)(?:=(?:<(?[^>\s]+)>?|(?[^\s,]+)))?")] + [GeneratedRegex(@"(?\[)?(?--[\w-]+)(?:=(?:(?<[^>\s]+>?)|(?[^\s,]+)))?")] private static partial Regex SnykOptionPattern(); [GeneratedRegex(@"\bUse (?:the )?-d(?: option)?\b", RegexOptions.IgnoreCase)]