diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000000..3e2257721b --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,8 @@ +# .git-blame-ignore-revs +# To configure git to use this file, run +# git config blame.ignoreRevsFile .git-blame-ignore-revs +# +# Testing git-blame-ingore-revs on small scale +# before considering broader use, by reverting +# bad autoformatter changes +1aca22a0350e800b72f2275df12a58f0a1f72101 diff --git a/Directory.Build.props b/Directory.Build.props index 62f3090979..41b9b11ba1 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -7,7 +7,7 @@ $(NoWarn);NU5125;CS0618 MIT - 10.0 + 12.0 @@ -15,7 +15,7 @@ - net7.0 + net8.0 diff --git a/OpenQuestions.md b/OpenQuestions.md new file mode 100644 index 0000000000..9dd1770bd4 --- /dev/null +++ b/OpenQuestions.md @@ -0,0 +1,59 @@ +# Open questions + +Please also include TODO in appropriate locations. This is intended as a wrap up. + +Also, include whether the question is to add or remove something, and add date/initials + +## NotNulWhen on TryGetValue in ValueSource and ValueProvider + +Things to consider: + +* Within System.CommandLine all TryGetValue should probably be the same +* TryGetValue on dictionary can return null +* For nullable values, the actual value can be null +* For nullable or non-nullable ref types, the default for the type is null +* Allowing null out values keeps a single meaning to "not found" and allows "found but null". Conflating these blocks expressing which happened + +The recovery is the same as with Dictionary.TryGetValue. The first line of the block that handles the return Boolean is a guard. + +## The extensibility story for ValueSource + +The proposal and current code seal our value sources and expect people to make additional ones based on ValueSource. The classes are public and sealed, the constructors are internal. + +Reasons to reconsider: Aggregate value source has a logical precedence or an as entered one. If someone adds a new value source, it is always last in the logic precedence.There are likely to be other similar cases. + +Possible resolution: Have this be case by case and allow aggregate values to be unsealed and have a mechanism for overriding. Providing a non-inheritance based solution could make this look like a normal operation when it is a rare one. + +## Contexts [RESOLVED] + +We had two different philosophies at different spots in subsystems. "Give folks everything they might need" and "Give folks only what we know they need". + +The first probably means we pass around `PipelineResult`. The second means that each purpose needs a special context. Sharing contexts is likely to mean that something will be added to one context that is unneeded by the other. Known expected contexts are: + +- `AnnotationProviderContext` +- `ValueSourceContext` +- `ValidationContext` (includes ability to report diagnostics) +- `CompletionContext` +- `HelpContext` + +## Which contexts should allow diagnostic reporting? + +## Should we have both Validators and IValidator on Conditions? [RESOLVED] + +We started with `Validators` and then added the IValidator interface to allow conditions to do validation because they have the strong type. Checking for this first also avoids a dictionary lookup. + +Our default validations will be on the Condition for the shortcut. Users can offer alternatives by creaing custom validators. The dictionary for custom validators will be lazy, and lookups will be pay for play when the user has custom validators. (This is not yet implemented.) + +When present, custom validators have precedence. There is no cost when they are not present. + +## Should conditions be public + +Since there are factory methods and validators could still access them, current behavior could be supported with internal conditions. + +However, the point of conditions is that they are a statement about the symbol and not an implementation. They are known to be used by completions and completions are expected to be extended. Thus, to get the values held in the condition (such as environment variable name) need to be available outside the external scope. + +Suggestion: Use internal constructors and leave conditions public + +## Should `ValueCondition` be called `Condition`? + +They may apply to commands. \ No newline at end of file diff --git a/StringCase.cs b/StringCase.cs new file mode 100644 index 0000000000..de1c6bf578 --- /dev/null +++ b/StringCase.cs @@ -0,0 +1,77 @@ +// 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.CommandLine.Parsing; +using System.CommandLine.Validation; +using System.CommandLine.ValueSources; +using System.Globalization; + +namespace System.CommandLine.ValueConditions; + +///// +///// Declares the string casing condition for the option or argument. +///// +///// +///// The non-generic version is used by the +/// +/// The type of the symbol the range applies to. +public class StringCase : ValueCondition, IValueValidator +{ + internal StringCase(ValueSource? casing) : base(nameof(StringCase)) + { + Casing = casing; + } + + /// + public void Validate(object? value, + CliValueSymbol valueSymbol, + CliValueResult? valueResult, + ValueCondition valueCondition, + ValidationContext validationContext) + { + if (valueCondition != this) throw new InvalidOperationException("Unexpected value condition type"); + if (value is not string stringValue) throw new InvalidOperationException("Unexpected value type"); + + if (stringValue is null) return; // nothing to do + + // TODO: Replace the strings we are comparing with a diagnostic ID when we update ParseError + if (Casing is not null + && validationContext.TryGetTypedValue(Casing, out var casingValue)) + { + if (casingValue is null) return; + if (casingValue == "lower" + && !stringValue.Equals(stringValue.ToLower(CultureInfo.CurrentCulture))) + { + validationContext.AddError(new ParseError($"The value for '{valueSymbol.Name}' is not in lower case.")); + } + if (casingValue == "upper" + && !stringValue.Equals(stringValue.ToUpper(CultureInfo.CurrentCulture))) + { + validationContext.AddError(new ParseError($"The value for '{valueSymbol.Name}' is not in upper case.")); + } + } + } + + /// + /// The expected casing. + /// + public ValueSource? Casing { get; init; } +} diff --git a/StringValidationTests.cs b/StringValidationTests.cs new file mode 100644 index 0000000000..c957e3af2a --- /dev/null +++ b/StringValidationTests.cs @@ -0,0 +1,129 @@ +// 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 FluentAssertions; +using System.CommandLine.Parsing; +using System.CommandLine.ValueSources; +using Xunit; + +namespace System.CommandLine.Subsystems.Tests; + +public class StringValidationTests +{ + private PipelineResult ExecutedPipelineResultForCommand(CliCommand command, string input) + { + var validationSubsystem = ValidationSubsystem.Create(); + var parseResult = CliParser.Parse(command, input, new CliConfiguration(command)); + var pipelineResult = new PipelineResult(parseResult, input, Pipeline.CreateEmpty()); + validationSubsystem.Execute(pipelineResult); + return pipelineResult; + } + + [Fact] + public void StringValidationShouldPassWhenInputInLowerCase() + { + var option = new CliOption("--opt"); + option.SetCasing("lower"); + var command = new CliCommand("cmd"); + command.Options.Add(option); + + var input = "--opt letuswin"; + + var pipelineResult = ExecutedPipelineResultForCommand(command, input); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().BeEmpty(); + } + + [Fact] + public void StringValidationShouldFailWhenInputNotInLowerCase() + { + var option = new CliOption("--opt"); + option.SetCasing("lower"); + var command = new CliCommand("cmd"); + command.Options.Add(option); + + var input = "--opt LetUsWin"; + + var pipelineResult = ExecutedPipelineResultForCommand(command, input); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().HaveCount(1); + } + + [Fact] + public void StringValidationShouldFailWhenInputNotInUpperCase() + { + var option = new CliOption("--opt"); + option.SetCasing("lower"); + var command = new CliCommand("cmd"); + command.Options.Add(option); + + var input = "--opt LetUsWin"; + + var pipelineResult = ExecutedPipelineResultForCommand(command, input); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().HaveCount(1); + } + + [Fact] + public void StringValidationShouldPassWhenInputInUpperCase() + { + var option = new CliOption("--opt"); + option.SetCasing("upper"); + var command = new CliCommand("cmd"); + command.Options.Add(option); + + var input = "--opt GOODWORK!"; + + var pipelineResult = ExecutedPipelineResultForCommand(command, input); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().BeEmpty(); + } + + [Fact] + public void StringValidationShouldThrowExceptionWhenInputIsEmptyAndCasingIsSet() + { + var option = new CliOption("--opt"); + option.SetCasing("lower"); + var command = new CliCommand("cmd"); + command.Options.Add(option); + + var input = "--opt "; + + Assert.Throws(() => ExecutedPipelineResultForCommand(command, input)); + } + + [Fact] + public void StringValidationShouldPassWhenInputIsAllNumericWithLower() + { + var option = new CliOption("--opt"); + option.SetCasing("lower"); + var command = new CliCommand("cmd"); + command.Options.Add(option); + + var input = "--opt fuzzy123"; + + var pipelineResult = ExecutedPipelineResultForCommand(command, input); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().BeEmpty(); + } + + [Fact] + public void StringValidationShouldPassWhenInputIsAllNumericWithUpper() + { + var option = new CliOption("--opt"); + option.SetCasing("upper"); + var command = new CliCommand("cmd"); + command.Options.Add(option); + + var input = "--opt FUZZY123"; + + var pipelineResult = ExecutedPipelineResultForCommand(command, input); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().BeEmpty(); + } +} diff --git a/System.CommandLine.sln b/System.CommandLine.sln index 2b4452b1d5..b3d9488c91 100644 --- a/System.CommandLine.sln +++ b/System.CommandLine.sln @@ -16,6 +16,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Directory.Packages.props = Directory.Packages.props global.json = global.json LICENSE.md = LICENSE.md + OpenQuestions.md = OpenQuestions.md README.md = README.md restore.cmd = restore.cmd restore.sh = restore.sh @@ -29,37 +30,21 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.CommandLine", "src\S EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.CommandLine.Tests", "src\System.CommandLine.Tests\System.CommandLine.Tests.csproj", "{F843CCCA-4CC9-422C-A881-3AE6A998B53F}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{6749FB3E-39DE-4321-A39E-525278E9408D}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DragonFruit", "samples\DragonFruit\DragonFruit.csproj", "{8A592CB0-5FB9-4E70-A68A-BE5B5BE23C00}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.CommandLine.DragonFruit", "src\System.CommandLine.DragonFruit\System.CommandLine.DragonFruit.csproj", "{EEC30462-078F-45EB-AA70-12E3170CD51E}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.CommandLine.DragonFruit.Tests", "src\System.CommandLine.DragonFruit.Tests\System.CommandLine.DragonFruit.Tests.csproj", "{1F4B2074-F651-4A02-A860-7DDA74B2CC5F}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-suggest", "src\System.CommandLine.Suggest\dotnet-suggest.csproj", "{E23C760E-B826-4B4F-BE76-916D86BAD2DB}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-suggest.Tests", "src\System.CommandLine.Suggest.Tests\dotnet-suggest.Tests.csproj", "{E41F0471-B14D-4FA0-9D8B-1E7750695AE9}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RenderingPlayground", "samples\RenderingPlayground\RenderingPlayground.csproj", "{8D9A8DCB-DC74-4B3A-B1C6-046C9C4F458E}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.CommandLine.Rendering", "src\System.CommandLine.Rendering\System.CommandLine.Rendering.csproj", "{27E3BFFC-4412-4E4C-A656-B9D35B8A0F3E}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.CommandLine.Rendering.Tests", "src\System.CommandLine.Rendering.Tests\System.CommandLine.Rendering.Tests.csproj", "{9E574595-A9CD-441A-9328-1D4DD5B531E8}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.CommandLine.Hosting", "src\System.CommandLine.Hosting\System.CommandLine.Hosting.csproj", "{644C4B4A-4A32-4307-9F71-C3BF901FFB66}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.CommandLine.Hosting.Tests", "src\System.CommandLine.Hosting.Tests\System.CommandLine.Hosting.Tests.csproj", "{39483140-BC26-4CAD-BBAE-3DC76C2F16CF}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.CommandLine.ApiCompatibility.Tests", "src\System.CommandLine.ApiCompatibility.Tests\System.CommandLine.ApiCompatibility.Tests.csproj", "{A54EE328-D456-4BAF-A180-84E77E6409AC}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HostingPlayground", "samples\HostingPlayground\HostingPlayground.csproj", "{0BF6958D-9EE3-4623-B3D6-4DA77EAC1906}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.CommandLine.Extended", "src\System.CommandLine.Extended\System.CommandLine.Extended.csproj", "{D77D8EE4-7FBA-425C-AEE6-D6908998E228}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.CommandLine.Generator.CommandHandler", "src\System.CommandLine.Generator.CommandHandler\System.CommandLine.Generator.CommandHandler.csproj", "{591EF370-7AD7-4624-8B9D-FD15010CA657}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.CommandLine.Extended.Tests", "src\System.CommandLine.Extended.Tests\System.CommandLine.Extended.Tests.csproj", "{9E93F66A-6099-4675-AF53-FC10DE01925B}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.CommandLine.NamingConventionBinder", "src\System.CommandLine.NamingConventionBinder\System.CommandLine.NamingConventionBinder.csproj", "{10DFE204-B027-49DA-BD77-08ECA18DD357}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.CommandLine.Subsystems", "src\System.CommandLine.Subsystems\System.CommandLine.Subsystems.csproj", "{D750F504-DEBB-47B1-89AC-BB12B796E7B9}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.CommandLine.NamingConventionBinder.Tests", "src\System.CommandLine.NamingConventionBinder.Tests\System.CommandLine.NamingConventionBinder.Tests.csproj", "{789A05F2-5EF6-4FE8-9609-4706207E047E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.CommandLine.Subsystems.Tests", "src\System.CommandLine.Subsystems.Tests\System.CommandLine.Subsystems.Tests.csproj", "{7D6F74A4-28E4-4B57-8A4B-415A533729A7}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.CommandLine.ApiCompatibility.Tests", "src\System.CommandLine.ApiCompatibility.Tests\System.CommandLine.ApiCompatibility.Tests.csproj", "{A54EE328-D456-4BAF-A180-84E77E6409AC}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EndToEndTestApp", "src\System.CommandLine.Suggest.Tests\EndToEndTestApp\EndToEndTestApp.csproj", "{8DAAEC0E-0B7C-4BEB-BD04-E197820E3568}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -95,42 +80,6 @@ Global {F843CCCA-4CC9-422C-A881-3AE6A998B53F}.Release|x64.Build.0 = Release|Any CPU {F843CCCA-4CC9-422C-A881-3AE6A998B53F}.Release|x86.ActiveCfg = Release|Any CPU {F843CCCA-4CC9-422C-A881-3AE6A998B53F}.Release|x86.Build.0 = Release|Any CPU - {8A592CB0-5FB9-4E70-A68A-BE5B5BE23C00}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8A592CB0-5FB9-4E70-A68A-BE5B5BE23C00}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8A592CB0-5FB9-4E70-A68A-BE5B5BE23C00}.Debug|x64.ActiveCfg = Debug|Any CPU - {8A592CB0-5FB9-4E70-A68A-BE5B5BE23C00}.Debug|x64.Build.0 = Debug|Any CPU - {8A592CB0-5FB9-4E70-A68A-BE5B5BE23C00}.Debug|x86.ActiveCfg = Debug|Any CPU - {8A592CB0-5FB9-4E70-A68A-BE5B5BE23C00}.Debug|x86.Build.0 = Debug|Any CPU - {8A592CB0-5FB9-4E70-A68A-BE5B5BE23C00}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8A592CB0-5FB9-4E70-A68A-BE5B5BE23C00}.Release|Any CPU.Build.0 = Release|Any CPU - {8A592CB0-5FB9-4E70-A68A-BE5B5BE23C00}.Release|x64.ActiveCfg = Release|Any CPU - {8A592CB0-5FB9-4E70-A68A-BE5B5BE23C00}.Release|x64.Build.0 = Release|Any CPU - {8A592CB0-5FB9-4E70-A68A-BE5B5BE23C00}.Release|x86.ActiveCfg = Release|Any CPU - {8A592CB0-5FB9-4E70-A68A-BE5B5BE23C00}.Release|x86.Build.0 = Release|Any CPU - {EEC30462-078F-45EB-AA70-12E3170CD51E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {EEC30462-078F-45EB-AA70-12E3170CD51E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {EEC30462-078F-45EB-AA70-12E3170CD51E}.Debug|x64.ActiveCfg = Debug|Any CPU - {EEC30462-078F-45EB-AA70-12E3170CD51E}.Debug|x64.Build.0 = Debug|Any CPU - {EEC30462-078F-45EB-AA70-12E3170CD51E}.Debug|x86.ActiveCfg = Debug|Any CPU - {EEC30462-078F-45EB-AA70-12E3170CD51E}.Debug|x86.Build.0 = Debug|Any CPU - {EEC30462-078F-45EB-AA70-12E3170CD51E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {EEC30462-078F-45EB-AA70-12E3170CD51E}.Release|Any CPU.Build.0 = Release|Any CPU - {EEC30462-078F-45EB-AA70-12E3170CD51E}.Release|x64.ActiveCfg = Release|Any CPU - {EEC30462-078F-45EB-AA70-12E3170CD51E}.Release|x64.Build.0 = Release|Any CPU - {EEC30462-078F-45EB-AA70-12E3170CD51E}.Release|x86.ActiveCfg = Release|Any CPU - {EEC30462-078F-45EB-AA70-12E3170CD51E}.Release|x86.Build.0 = Release|Any CPU - {1F4B2074-F651-4A02-A860-7DDA74B2CC5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1F4B2074-F651-4A02-A860-7DDA74B2CC5F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1F4B2074-F651-4A02-A860-7DDA74B2CC5F}.Debug|x64.ActiveCfg = Debug|Any CPU - {1F4B2074-F651-4A02-A860-7DDA74B2CC5F}.Debug|x64.Build.0 = Debug|Any CPU - {1F4B2074-F651-4A02-A860-7DDA74B2CC5F}.Debug|x86.ActiveCfg = Debug|Any CPU - {1F4B2074-F651-4A02-A860-7DDA74B2CC5F}.Debug|x86.Build.0 = Debug|Any CPU - {1F4B2074-F651-4A02-A860-7DDA74B2CC5F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1F4B2074-F651-4A02-A860-7DDA74B2CC5F}.Release|Any CPU.Build.0 = Release|Any CPU - {1F4B2074-F651-4A02-A860-7DDA74B2CC5F}.Release|x64.ActiveCfg = Release|Any CPU - {1F4B2074-F651-4A02-A860-7DDA74B2CC5F}.Release|x64.Build.0 = Release|Any CPU - {1F4B2074-F651-4A02-A860-7DDA74B2CC5F}.Release|x86.ActiveCfg = Release|Any CPU - {1F4B2074-F651-4A02-A860-7DDA74B2CC5F}.Release|x86.Build.0 = Release|Any CPU {E23C760E-B826-4B4F-BE76-916D86BAD2DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E23C760E-B826-4B4F-BE76-916D86BAD2DB}.Debug|Any CPU.Build.0 = Debug|Any CPU {E23C760E-B826-4B4F-BE76-916D86BAD2DB}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -155,114 +104,6 @@ Global {E41F0471-B14D-4FA0-9D8B-1E7750695AE9}.Release|x64.Build.0 = Release|Any CPU {E41F0471-B14D-4FA0-9D8B-1E7750695AE9}.Release|x86.ActiveCfg = Release|Any CPU {E41F0471-B14D-4FA0-9D8B-1E7750695AE9}.Release|x86.Build.0 = Release|Any CPU - {8D9A8DCB-DC74-4B3A-B1C6-046C9C4F458E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8D9A8DCB-DC74-4B3A-B1C6-046C9C4F458E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8D9A8DCB-DC74-4B3A-B1C6-046C9C4F458E}.Debug|x64.ActiveCfg = Debug|Any CPU - {8D9A8DCB-DC74-4B3A-B1C6-046C9C4F458E}.Debug|x64.Build.0 = Debug|Any CPU - {8D9A8DCB-DC74-4B3A-B1C6-046C9C4F458E}.Debug|x86.ActiveCfg = Debug|Any CPU - {8D9A8DCB-DC74-4B3A-B1C6-046C9C4F458E}.Debug|x86.Build.0 = Debug|Any CPU - {8D9A8DCB-DC74-4B3A-B1C6-046C9C4F458E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8D9A8DCB-DC74-4B3A-B1C6-046C9C4F458E}.Release|Any CPU.Build.0 = Release|Any CPU - {8D9A8DCB-DC74-4B3A-B1C6-046C9C4F458E}.Release|x64.ActiveCfg = Release|Any CPU - {8D9A8DCB-DC74-4B3A-B1C6-046C9C4F458E}.Release|x64.Build.0 = Release|Any CPU - {8D9A8DCB-DC74-4B3A-B1C6-046C9C4F458E}.Release|x86.ActiveCfg = Release|Any CPU - {8D9A8DCB-DC74-4B3A-B1C6-046C9C4F458E}.Release|x86.Build.0 = Release|Any CPU - {27E3BFFC-4412-4E4C-A656-B9D35B8A0F3E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {27E3BFFC-4412-4E4C-A656-B9D35B8A0F3E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {27E3BFFC-4412-4E4C-A656-B9D35B8A0F3E}.Debug|x64.ActiveCfg = Debug|Any CPU - {27E3BFFC-4412-4E4C-A656-B9D35B8A0F3E}.Debug|x64.Build.0 = Debug|Any CPU - {27E3BFFC-4412-4E4C-A656-B9D35B8A0F3E}.Debug|x86.ActiveCfg = Debug|Any CPU - {27E3BFFC-4412-4E4C-A656-B9D35B8A0F3E}.Debug|x86.Build.0 = Debug|Any CPU - {27E3BFFC-4412-4E4C-A656-B9D35B8A0F3E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {27E3BFFC-4412-4E4C-A656-B9D35B8A0F3E}.Release|Any CPU.Build.0 = Release|Any CPU - {27E3BFFC-4412-4E4C-A656-B9D35B8A0F3E}.Release|x64.ActiveCfg = Release|Any CPU - {27E3BFFC-4412-4E4C-A656-B9D35B8A0F3E}.Release|x64.Build.0 = Release|Any CPU - {27E3BFFC-4412-4E4C-A656-B9D35B8A0F3E}.Release|x86.ActiveCfg = Release|Any CPU - {27E3BFFC-4412-4E4C-A656-B9D35B8A0F3E}.Release|x86.Build.0 = Release|Any CPU - {9E574595-A9CD-441A-9328-1D4DD5B531E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9E574595-A9CD-441A-9328-1D4DD5B531E8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9E574595-A9CD-441A-9328-1D4DD5B531E8}.Debug|x64.ActiveCfg = Debug|Any CPU - {9E574595-A9CD-441A-9328-1D4DD5B531E8}.Debug|x64.Build.0 = Debug|Any CPU - {9E574595-A9CD-441A-9328-1D4DD5B531E8}.Debug|x86.ActiveCfg = Debug|Any CPU - {9E574595-A9CD-441A-9328-1D4DD5B531E8}.Debug|x86.Build.0 = Debug|Any CPU - {9E574595-A9CD-441A-9328-1D4DD5B531E8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9E574595-A9CD-441A-9328-1D4DD5B531E8}.Release|Any CPU.Build.0 = Release|Any CPU - {9E574595-A9CD-441A-9328-1D4DD5B531E8}.Release|x64.ActiveCfg = Release|Any CPU - {9E574595-A9CD-441A-9328-1D4DD5B531E8}.Release|x64.Build.0 = Release|Any CPU - {9E574595-A9CD-441A-9328-1D4DD5B531E8}.Release|x86.ActiveCfg = Release|Any CPU - {9E574595-A9CD-441A-9328-1D4DD5B531E8}.Release|x86.Build.0 = Release|Any CPU - {644C4B4A-4A32-4307-9F71-C3BF901FFB66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {644C4B4A-4A32-4307-9F71-C3BF901FFB66}.Debug|Any CPU.Build.0 = Debug|Any CPU - {644C4B4A-4A32-4307-9F71-C3BF901FFB66}.Debug|x64.ActiveCfg = Debug|Any CPU - {644C4B4A-4A32-4307-9F71-C3BF901FFB66}.Debug|x64.Build.0 = Debug|Any CPU - {644C4B4A-4A32-4307-9F71-C3BF901FFB66}.Debug|x86.ActiveCfg = Debug|Any CPU - {644C4B4A-4A32-4307-9F71-C3BF901FFB66}.Debug|x86.Build.0 = Debug|Any CPU - {644C4B4A-4A32-4307-9F71-C3BF901FFB66}.Release|Any CPU.ActiveCfg = Release|Any CPU - {644C4B4A-4A32-4307-9F71-C3BF901FFB66}.Release|Any CPU.Build.0 = Release|Any CPU - {644C4B4A-4A32-4307-9F71-C3BF901FFB66}.Release|x64.ActiveCfg = Release|Any CPU - {644C4B4A-4A32-4307-9F71-C3BF901FFB66}.Release|x64.Build.0 = Release|Any CPU - {644C4B4A-4A32-4307-9F71-C3BF901FFB66}.Release|x86.ActiveCfg = Release|Any CPU - {644C4B4A-4A32-4307-9F71-C3BF901FFB66}.Release|x86.Build.0 = Release|Any CPU - {39483140-BC26-4CAD-BBAE-3DC76C2F16CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {39483140-BC26-4CAD-BBAE-3DC76C2F16CF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {39483140-BC26-4CAD-BBAE-3DC76C2F16CF}.Debug|x64.ActiveCfg = Debug|Any CPU - {39483140-BC26-4CAD-BBAE-3DC76C2F16CF}.Debug|x64.Build.0 = Debug|Any CPU - {39483140-BC26-4CAD-BBAE-3DC76C2F16CF}.Debug|x86.ActiveCfg = Debug|Any CPU - {39483140-BC26-4CAD-BBAE-3DC76C2F16CF}.Debug|x86.Build.0 = Debug|Any CPU - {39483140-BC26-4CAD-BBAE-3DC76C2F16CF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {39483140-BC26-4CAD-BBAE-3DC76C2F16CF}.Release|Any CPU.Build.0 = Release|Any CPU - {39483140-BC26-4CAD-BBAE-3DC76C2F16CF}.Release|x64.ActiveCfg = Release|Any CPU - {39483140-BC26-4CAD-BBAE-3DC76C2F16CF}.Release|x64.Build.0 = Release|Any CPU - {39483140-BC26-4CAD-BBAE-3DC76C2F16CF}.Release|x86.ActiveCfg = Release|Any CPU - {39483140-BC26-4CAD-BBAE-3DC76C2F16CF}.Release|x86.Build.0 = Release|Any CPU - {0BF6958D-9EE3-4623-B3D6-4DA77EAC1906}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0BF6958D-9EE3-4623-B3D6-4DA77EAC1906}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0BF6958D-9EE3-4623-B3D6-4DA77EAC1906}.Debug|x64.ActiveCfg = Debug|Any CPU - {0BF6958D-9EE3-4623-B3D6-4DA77EAC1906}.Debug|x64.Build.0 = Debug|Any CPU - {0BF6958D-9EE3-4623-B3D6-4DA77EAC1906}.Debug|x86.ActiveCfg = Debug|Any CPU - {0BF6958D-9EE3-4623-B3D6-4DA77EAC1906}.Debug|x86.Build.0 = Debug|Any CPU - {0BF6958D-9EE3-4623-B3D6-4DA77EAC1906}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0BF6958D-9EE3-4623-B3D6-4DA77EAC1906}.Release|Any CPU.Build.0 = Release|Any CPU - {0BF6958D-9EE3-4623-B3D6-4DA77EAC1906}.Release|x64.ActiveCfg = Release|Any CPU - {0BF6958D-9EE3-4623-B3D6-4DA77EAC1906}.Release|x64.Build.0 = Release|Any CPU - {0BF6958D-9EE3-4623-B3D6-4DA77EAC1906}.Release|x86.ActiveCfg = Release|Any CPU - {0BF6958D-9EE3-4623-B3D6-4DA77EAC1906}.Release|x86.Build.0 = Release|Any CPU - {591EF370-7AD7-4624-8B9D-FD15010CA657}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {591EF370-7AD7-4624-8B9D-FD15010CA657}.Debug|Any CPU.Build.0 = Debug|Any CPU - {591EF370-7AD7-4624-8B9D-FD15010CA657}.Debug|x64.ActiveCfg = Debug|Any CPU - {591EF370-7AD7-4624-8B9D-FD15010CA657}.Debug|x64.Build.0 = Debug|Any CPU - {591EF370-7AD7-4624-8B9D-FD15010CA657}.Debug|x86.ActiveCfg = Debug|Any CPU - {591EF370-7AD7-4624-8B9D-FD15010CA657}.Debug|x86.Build.0 = Debug|Any CPU - {591EF370-7AD7-4624-8B9D-FD15010CA657}.Release|Any CPU.ActiveCfg = Release|Any CPU - {591EF370-7AD7-4624-8B9D-FD15010CA657}.Release|Any CPU.Build.0 = Release|Any CPU - {591EF370-7AD7-4624-8B9D-FD15010CA657}.Release|x64.ActiveCfg = Release|Any CPU - {591EF370-7AD7-4624-8B9D-FD15010CA657}.Release|x64.Build.0 = Release|Any CPU - {591EF370-7AD7-4624-8B9D-FD15010CA657}.Release|x86.ActiveCfg = Release|Any CPU - {591EF370-7AD7-4624-8B9D-FD15010CA657}.Release|x86.Build.0 = Release|Any CPU - {10DFE204-B027-49DA-BD77-08ECA18DD357}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {10DFE204-B027-49DA-BD77-08ECA18DD357}.Debug|Any CPU.Build.0 = Debug|Any CPU - {10DFE204-B027-49DA-BD77-08ECA18DD357}.Debug|x64.ActiveCfg = Debug|Any CPU - {10DFE204-B027-49DA-BD77-08ECA18DD357}.Debug|x64.Build.0 = Debug|Any CPU - {10DFE204-B027-49DA-BD77-08ECA18DD357}.Debug|x86.ActiveCfg = Debug|Any CPU - {10DFE204-B027-49DA-BD77-08ECA18DD357}.Debug|x86.Build.0 = Debug|Any CPU - {10DFE204-B027-49DA-BD77-08ECA18DD357}.Release|Any CPU.ActiveCfg = Release|Any CPU - {10DFE204-B027-49DA-BD77-08ECA18DD357}.Release|Any CPU.Build.0 = Release|Any CPU - {10DFE204-B027-49DA-BD77-08ECA18DD357}.Release|x64.ActiveCfg = Release|Any CPU - {10DFE204-B027-49DA-BD77-08ECA18DD357}.Release|x64.Build.0 = Release|Any CPU - {10DFE204-B027-49DA-BD77-08ECA18DD357}.Release|x86.ActiveCfg = Release|Any CPU - {10DFE204-B027-49DA-BD77-08ECA18DD357}.Release|x86.Build.0 = Release|Any CPU - {789A05F2-5EF6-4FE8-9609-4706207E047E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {789A05F2-5EF6-4FE8-9609-4706207E047E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {789A05F2-5EF6-4FE8-9609-4706207E047E}.Debug|x64.ActiveCfg = Debug|Any CPU - {789A05F2-5EF6-4FE8-9609-4706207E047E}.Debug|x64.Build.0 = Debug|Any CPU - {789A05F2-5EF6-4FE8-9609-4706207E047E}.Debug|x86.ActiveCfg = Debug|Any CPU - {789A05F2-5EF6-4FE8-9609-4706207E047E}.Debug|x86.Build.0 = Debug|Any CPU - {789A05F2-5EF6-4FE8-9609-4706207E047E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {789A05F2-5EF6-4FE8-9609-4706207E047E}.Release|Any CPU.Build.0 = Release|Any CPU - {789A05F2-5EF6-4FE8-9609-4706207E047E}.Release|x64.ActiveCfg = Release|Any CPU - {789A05F2-5EF6-4FE8-9609-4706207E047E}.Release|x64.Build.0 = Release|Any CPU - {789A05F2-5EF6-4FE8-9609-4706207E047E}.Release|x86.ActiveCfg = Release|Any CPU - {789A05F2-5EF6-4FE8-9609-4706207E047E}.Release|x86.Build.0 = Release|Any CPU {A54EE328-D456-4BAF-A180-84E77E6409AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A54EE328-D456-4BAF-A180-84E77E6409AC}.Debug|Any CPU.Build.0 = Debug|Any CPU {A54EE328-D456-4BAF-A180-84E77E6409AC}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -275,6 +116,66 @@ Global {A54EE328-D456-4BAF-A180-84E77E6409AC}.Release|x64.Build.0 = Release|Any CPU {A54EE328-D456-4BAF-A180-84E77E6409AC}.Release|x86.ActiveCfg = Release|Any CPU {A54EE328-D456-4BAF-A180-84E77E6409AC}.Release|x86.Build.0 = Release|Any CPU + {D77D8EE4-7FBA-425C-AEE6-D6908998E228}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D77D8EE4-7FBA-425C-AEE6-D6908998E228}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D77D8EE4-7FBA-425C-AEE6-D6908998E228}.Debug|x64.ActiveCfg = Debug|Any CPU + {D77D8EE4-7FBA-425C-AEE6-D6908998E228}.Debug|x64.Build.0 = Debug|Any CPU + {D77D8EE4-7FBA-425C-AEE6-D6908998E228}.Debug|x86.ActiveCfg = Debug|Any CPU + {D77D8EE4-7FBA-425C-AEE6-D6908998E228}.Debug|x86.Build.0 = Debug|Any CPU + {D77D8EE4-7FBA-425C-AEE6-D6908998E228}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D77D8EE4-7FBA-425C-AEE6-D6908998E228}.Release|Any CPU.Build.0 = Release|Any CPU + {D77D8EE4-7FBA-425C-AEE6-D6908998E228}.Release|x64.ActiveCfg = Release|Any CPU + {D77D8EE4-7FBA-425C-AEE6-D6908998E228}.Release|x64.Build.0 = Release|Any CPU + {D77D8EE4-7FBA-425C-AEE6-D6908998E228}.Release|x86.ActiveCfg = Release|Any CPU + {D77D8EE4-7FBA-425C-AEE6-D6908998E228}.Release|x86.Build.0 = Release|Any CPU + {9E93F66A-6099-4675-AF53-FC10DE01925B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9E93F66A-6099-4675-AF53-FC10DE01925B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9E93F66A-6099-4675-AF53-FC10DE01925B}.Debug|x64.ActiveCfg = Debug|Any CPU + {9E93F66A-6099-4675-AF53-FC10DE01925B}.Debug|x64.Build.0 = Debug|Any CPU + {9E93F66A-6099-4675-AF53-FC10DE01925B}.Debug|x86.ActiveCfg = Debug|Any CPU + {9E93F66A-6099-4675-AF53-FC10DE01925B}.Debug|x86.Build.0 = Debug|Any CPU + {9E93F66A-6099-4675-AF53-FC10DE01925B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9E93F66A-6099-4675-AF53-FC10DE01925B}.Release|Any CPU.Build.0 = Release|Any CPU + {9E93F66A-6099-4675-AF53-FC10DE01925B}.Release|x64.ActiveCfg = Release|Any CPU + {9E93F66A-6099-4675-AF53-FC10DE01925B}.Release|x64.Build.0 = Release|Any CPU + {9E93F66A-6099-4675-AF53-FC10DE01925B}.Release|x86.ActiveCfg = Release|Any CPU + {9E93F66A-6099-4675-AF53-FC10DE01925B}.Release|x86.Build.0 = Release|Any CPU + {D750F504-DEBB-47B1-89AC-BB12B796E7B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D750F504-DEBB-47B1-89AC-BB12B796E7B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D750F504-DEBB-47B1-89AC-BB12B796E7B9}.Debug|x64.ActiveCfg = Debug|Any CPU + {D750F504-DEBB-47B1-89AC-BB12B796E7B9}.Debug|x64.Build.0 = Debug|Any CPU + {D750F504-DEBB-47B1-89AC-BB12B796E7B9}.Debug|x86.ActiveCfg = Debug|Any CPU + {D750F504-DEBB-47B1-89AC-BB12B796E7B9}.Debug|x86.Build.0 = Debug|Any CPU + {D750F504-DEBB-47B1-89AC-BB12B796E7B9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D750F504-DEBB-47B1-89AC-BB12B796E7B9}.Release|Any CPU.Build.0 = Release|Any CPU + {D750F504-DEBB-47B1-89AC-BB12B796E7B9}.Release|x64.ActiveCfg = Release|Any CPU + {D750F504-DEBB-47B1-89AC-BB12B796E7B9}.Release|x64.Build.0 = Release|Any CPU + {D750F504-DEBB-47B1-89AC-BB12B796E7B9}.Release|x86.ActiveCfg = Release|Any CPU + {D750F504-DEBB-47B1-89AC-BB12B796E7B9}.Release|x86.Build.0 = Release|Any CPU + {7D6F74A4-28E4-4B57-8A4B-415A533729A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7D6F74A4-28E4-4B57-8A4B-415A533729A7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7D6F74A4-28E4-4B57-8A4B-415A533729A7}.Debug|x64.ActiveCfg = Debug|Any CPU + {7D6F74A4-28E4-4B57-8A4B-415A533729A7}.Debug|x64.Build.0 = Debug|Any CPU + {7D6F74A4-28E4-4B57-8A4B-415A533729A7}.Debug|x86.ActiveCfg = Debug|Any CPU + {7D6F74A4-28E4-4B57-8A4B-415A533729A7}.Debug|x86.Build.0 = Debug|Any CPU + {7D6F74A4-28E4-4B57-8A4B-415A533729A7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7D6F74A4-28E4-4B57-8A4B-415A533729A7}.Release|Any CPU.Build.0 = Release|Any CPU + {7D6F74A4-28E4-4B57-8A4B-415A533729A7}.Release|x64.ActiveCfg = Release|Any CPU + {7D6F74A4-28E4-4B57-8A4B-415A533729A7}.Release|x64.Build.0 = Release|Any CPU + {7D6F74A4-28E4-4B57-8A4B-415A533729A7}.Release|x86.ActiveCfg = Release|Any CPU + {7D6F74A4-28E4-4B57-8A4B-415A533729A7}.Release|x86.Build.0 = Release|Any CPU + {8DAAEC0E-0B7C-4BEB-BD04-E197820E3568}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8DAAEC0E-0B7C-4BEB-BD04-E197820E3568}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8DAAEC0E-0B7C-4BEB-BD04-E197820E3568}.Debug|x64.ActiveCfg = Debug|Any CPU + {8DAAEC0E-0B7C-4BEB-BD04-E197820E3568}.Debug|x64.Build.0 = Debug|Any CPU + {8DAAEC0E-0B7C-4BEB-BD04-E197820E3568}.Debug|x86.ActiveCfg = Debug|Any CPU + {8DAAEC0E-0B7C-4BEB-BD04-E197820E3568}.Debug|x86.Build.0 = Debug|Any CPU + {8DAAEC0E-0B7C-4BEB-BD04-E197820E3568}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8DAAEC0E-0B7C-4BEB-BD04-E197820E3568}.Release|Any CPU.Build.0 = Release|Any CPU + {8DAAEC0E-0B7C-4BEB-BD04-E197820E3568}.Release|x64.ActiveCfg = Release|Any CPU + {8DAAEC0E-0B7C-4BEB-BD04-E197820E3568}.Release|x64.Build.0 = Release|Any CPU + {8DAAEC0E-0B7C-4BEB-BD04-E197820E3568}.Release|x86.ActiveCfg = Release|Any CPU + {8DAAEC0E-0B7C-4BEB-BD04-E197820E3568}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -282,21 +183,14 @@ Global GlobalSection(NestedProjects) = preSolution {0BE8E56E-7580-4526-BE24-D304E1779724} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} {F843CCCA-4CC9-422C-A881-3AE6A998B53F} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} - {8A592CB0-5FB9-4E70-A68A-BE5B5BE23C00} = {6749FB3E-39DE-4321-A39E-525278E9408D} - {EEC30462-078F-45EB-AA70-12E3170CD51E} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} - {1F4B2074-F651-4A02-A860-7DDA74B2CC5F} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} {E23C760E-B826-4B4F-BE76-916D86BAD2DB} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} {E41F0471-B14D-4FA0-9D8B-1E7750695AE9} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} - {8D9A8DCB-DC74-4B3A-B1C6-046C9C4F458E} = {6749FB3E-39DE-4321-A39E-525278E9408D} - {27E3BFFC-4412-4E4C-A656-B9D35B8A0F3E} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} - {9E574595-A9CD-441A-9328-1D4DD5B531E8} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} - {644C4B4A-4A32-4307-9F71-C3BF901FFB66} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} - {39483140-BC26-4CAD-BBAE-3DC76C2F16CF} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} - {0BF6958D-9EE3-4623-B3D6-4DA77EAC1906} = {6749FB3E-39DE-4321-A39E-525278E9408D} - {591EF370-7AD7-4624-8B9D-FD15010CA657} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} - {10DFE204-B027-49DA-BD77-08ECA18DD357} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} - {789A05F2-5EF6-4FE8-9609-4706207E047E} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} {A54EE328-D456-4BAF-A180-84E77E6409AC} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} + {D77D8EE4-7FBA-425C-AEE6-D6908998E228} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} + {9E93F66A-6099-4675-AF53-FC10DE01925B} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} + {D750F504-DEBB-47B1-89AC-BB12B796E7B9} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} + {7D6F74A4-28E4-4B57-8A4B-415A533729A7} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} + {8DAAEC0E-0B7C-4BEB-BD04-E197820E3568} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5C159F93-800B-49E7-9905-EE09F8B8434A} diff --git a/ValidationSubsystemTests.cs b/ValidationSubsystemTests.cs new file mode 100644 index 0000000000..4d6d97e8bb --- /dev/null +++ b/ValidationSubsystemTests.cs @@ -0,0 +1,187 @@ +// 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 FluentAssertions; +using System.CommandLine.Parsing; +using System.CommandLine.ValueSources; +using Xunit; + +namespace System.CommandLine.Subsystems.Tests; + +public class ValidationSubsystemTests +{ + // Running exactly the same code is important here because missing a step will result in a false positive. Ask me how I know + private CliOption GetOptionWithSimpleRange(T lowerBound, T upperBound) + where T : IComparable + { + var option = new CliOption("--intOpt"); + option.SetRange(lowerBound, upperBound); + return option; + } + + private CliOption GetOptionWithRangeBounds(ValueSource lowerBound, ValueSource upperBound) + where T : IComparable + { + var option = new CliOption("--intOpt"); + option.SetRange(lowerBound, upperBound); + return option; + } + + private PipelineResult ExecutedPipelineResultForRangeOption(CliOption option, string input) + { + var command = new CliRootCommand { option }; + return ExecutedPipelineResultForCommand(command, input); + } + + private PipelineResult ExecutedPipelineResultForCommand(CliCommand command, string input) + { + var validationSubsystem = ValidationSubsystem.Create(); + var parseResult = CliParser.Parse(command, input, new CliConfiguration(command)); + var pipelineResult = new PipelineResult(parseResult, input, Pipeline.CreateEmpty()); + validationSubsystem.Execute(pipelineResult); + return pipelineResult; + } + + [Fact] + public void Int_values_in_specified_range_do_not_have_errors() + { + var option = GetOptionWithSimpleRange(0, 50); + + var pipelineResult = ExecutedPipelineResultForRangeOption(option, "--intOpt 42"); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().BeEmpty(); + } + + [Fact] + public void Int_values_above_upper_bound_report_error() + { + var option = GetOptionWithSimpleRange(0, 5); + + var pipelineResult = ExecutedPipelineResultForRangeOption(option, "--intOpt 42"); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().HaveCount(1); + var error = pipelineResult.GetErrors().First(); + // TODO: Create test mechanism for CliDiagnostics + } + + [Fact] + public void Int_below_lower_bound_report_error() + { + var option = GetOptionWithSimpleRange(0, 5); + + var pipelineResult = ExecutedPipelineResultForRangeOption(option, "--intOpt -42"); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().HaveCount(1); + var error = pipelineResult.GetErrors().First(); + // TODO: Create test mechanism for CliDiagnostics + } + + [Fact] + public void Int_values_on_lower_range_bound_do_not_report_error() + { + var option = GetOptionWithSimpleRange(42, 50); + + var pipelineResult = ExecutedPipelineResultForRangeOption(option, "--intOpt 42"); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().BeEmpty(); + } + + [Fact] + public void Int_values_on_upper_range_bound_do_not_report_error() + { + var option = GetOptionWithSimpleRange(0, 42); + + var pipelineResult = ExecutedPipelineResultForRangeOption(option, "--intOpt 42"); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().BeEmpty(); + } + + [Fact] + public void Values_below_calculated_lower_bound_report_error() + { + var option = GetOptionWithRangeBounds(ValueSource.Create(() => (true, 1)), 50); + + var pipelineResult = ExecutedPipelineResultForRangeOption(option, "--intOpt 0"); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().HaveCount(1); + var error = pipelineResult.GetErrors().First(); + // TODO: Create test mechanism for CliDiagnostics + } + + + [Fact] + public void Values_within_calculated_range_do_not_report_error() + { + var option = GetOptionWithRangeBounds(ValueSource.Create(() => (true, 1)), ValueSource.Create(() => (true, 50))); + + var pipelineResult = ExecutedPipelineResultForRangeOption(option, "--intOpt 42"); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().BeEmpty(); + } + + [Fact] + public void Values_above_calculated_upper_bound_report_error() + { + var option = GetOptionWithRangeBounds(0, ValueSource.Create(() => (true, 40))); + + var pipelineResult = ExecutedPipelineResultForRangeOption(option, "--intOpt 42"); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().HaveCount(1); + var error = pipelineResult.GetErrors().First(); + // TODO: Create test mechanism for CliDiagnostics + } + + [Fact] + public void Values_below_relative_lower_bound_report_error() + { + var otherOption = new CliOption("-all"); + var option = GetOptionWithRangeBounds(ValueSource.Create(otherOption, o => (true, (int)o + 1)), 50); + var command = new CliCommand("cmd") { option, otherOption }; + + var pipelineResult = ExecutedPipelineResultForCommand(command, "--intOpt 0 -all 0"); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().HaveCount(1); + var error = pipelineResult.GetErrors().First(); + // TODO: Create test mechanism for CliDiagnostics + } + + + [Fact] + public void Values_within_relative_range_do_not_report_error() + { + var otherOption = new CliOption("--all"); + var option = GetOptionWithRangeBounds(ValueSource.Create(otherOption, o => (true, (int)o + 1)), ValueSource.Create(otherOption, o => (true, (int)o + 10))); + var command = new CliCommand("cmd") { option, otherOption }; + + var pipelineResult = ExecutedPipelineResultForCommand(command, "--intOpt 11 --all 3"); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().BeEmpty(); + } + + [Fact] + public void Values_above_relative_upper_bound_report_error() + { + var otherOption = new CliOption("-all"); + var option = GetOptionWithRangeBounds(0, ValueSource.Create(otherOption, o => (true, (int)o + 10))); + var command = new CliCommand("cmd") { option, otherOption }; + + var pipelineResult = ExecutedPipelineResultForCommand(command, "--intOpt 9 -all -2"); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().HaveCount(1); + var error = pipelineResult.GetErrors().First(); + // TODO: Create test mechanism for CliDiagnostics + } + + +} diff --git a/ValueConditionAnnotationExtensions.cs b/ValueConditionAnnotationExtensions.cs new file mode 100644 index 0000000000..08adac9da4 --- /dev/null +++ b/ValueConditionAnnotationExtensions.cs @@ -0,0 +1,146 @@ +using System.CommandLine.Subsystems.Annotations; +using System.CommandLine.Validation; +using System.CommandLine.ValueConditions; +using System.CommandLine.ValueSources; + +namespace System.CommandLine; + +/// +/// Contains the extension methods that are used to create value conditions +/// +public static class ValueConditionAnnotationExtensions +{ + /// + /// Set the upper and/or lower bound values of the range. + /// + /// The type of the bounds. + /// The option or argument the range applies to. + /// The lower bound of the range. + /// The upper bound of the range. + // TODO: Add RangeBounds + // TODO: You should not have to set both...why not nullable? + public static void SetRange(this CliValueSymbol symbol, T lowerBound, T upperBound) + where T : IComparable + { + var range = new Range(lowerBound, upperBound); + + symbol.SetValueCondition(range); + } + + /// + /// Set the possible casing value for the string input value. + /// + /// The type of the bounds. + /// The option or argument the range applies to. + /// This could either be lower or upper. + public static void SetCasing(this CliValueSymbol symbol, string casing) + { + symbol.SetValueCondition(new StringCase(casing)); + } + + /// + /// Set the upper and/or lower bound via ValueSource. Implicit conversions means this + /// generally just works with any . + /// + /// The type of the bounds. + /// The option or argument the range applies to. + /// The that is the lower bound of the range. + /// The that is the upper bound of the range. + // TODO: Add RangeBounds + // TODO: You should not have to set both...why not nullable? + public static void SetRange(this CliValueSymbol symbol, ValueSource lowerBound, ValueSource upperBound) + where T : IComparable + // TODO: You should not have to set both...why not nullable? + { + var range = new Range(lowerBound, upperBound); + + symbol.SetValueCondition(range); + } + + /// + /// Indicates that there is an inclusive group of options and arguments for the command. All + /// members of an inclusive must be present, or none can be present. + /// + /// The command the inclusive group applies to. + /// The group of options and arguments that must all be present, or none can be present. + public static void SetInclusiveGroup(this CliCommand command, IEnumerable group) + => command.SetValueCondition(new InclusiveGroup(group)); + + // TODO: This should not be public if ValueConditions are not public + public static void SetValueCondition(this TValueSymbol symbol, TValueCondition valueCondition) + where TValueSymbol : CliValueSymbol + where TValueCondition : ValueCondition + { + if (!symbol.TryGetAnnotation>(ValueConditionAnnotations.ValueConditions, out var valueConditions)) + { + valueConditions = []; + symbol.SetAnnotation(ValueConditionAnnotations.ValueConditions, valueConditions); + } + valueConditions.Add(valueCondition); + } + + // TODO: This should not be public if ValueConditions are not public + public static void SetValueCondition(this CliCommand symbol, TCommandCondition commandCondition) + where TCommandCondition : CommandCondition + { + if (!symbol.TryGetAnnotation>(ValueConditionAnnotations.ValueConditions, out var valueConditions)) + { + valueConditions = []; + symbol.SetAnnotation(ValueConditionAnnotations.ValueConditions, valueConditions); + } + valueConditions.Add(commandCondition); + } + + /// + /// Gets a list of conditions on an option or argument. + /// + /// The option or argument to get the conditions for. + /// The conditions that have been applied to the option or argument. + /// + // TODO: This is public because it will be used by other subsystems we might not own. It could be an extension method the subsystem namespace + public static List? GetValueConditions(this CliValueSymbol symbol) + => symbol.TryGetAnnotation>(ValueConditionAnnotations.ValueConditions, out var valueConditions) + ? valueConditions + : null; + + /// + /// Gets a list of conditions on a command. + /// + /// The command to get the conditions for. + /// The conditions that have been applied to the command. + /// + // TODO: This is public because it will be used by other subsystems we might not own. It could be an extension method the subsystem namespace + public static List? GetCommandConditions(this CliCommand command) + => command.TryGetAnnotation>(ValueConditionAnnotations.ValueConditions, out var valueConditions) + ? valueConditions + : null; + + /// + /// Gets the condition that matches the type, if it exists on this option or argument. + /// + /// The type of condition to return. + /// The option or argument that may contain the condition. + /// The condition if it exists on the option or argument, otherwise null. + // This method feels useful because it clarifies that last should win and returns one, when only one should be applied + // TODO: Consider removing user facing naming, other than the base type, that is Value or CommandCondition and just use Condition + public static TCondition? GetValueCondition(this CliValueSymbol symbol) + where TCondition : ValueCondition + => !symbol.TryGetAnnotation(ValueConditionAnnotations.ValueConditions, out List? valueConditions) + ? null + : valueConditions.OfType().LastOrDefault(); + + /// + /// Gets the condition that matches the type, if it exists on this command. + /// + /// The type of condition to return. + /// The command that may contain the condition. + /// The condition if it exists on the command, otherwise null. + // This method feels useful because it clarifies that last should win and returns one, when only one should be applied + public static TCondition? GetCommandCondition(this CliCommand symbol) + where TCondition : CommandCondition + => !symbol.TryGetAnnotation(ValueConditionAnnotations.ValueConditions, out List? valueConditions) + ? null + : valueConditions.OfType().LastOrDefault(); + + +} diff --git a/docs/proposals/extensibility-overview.md b/docs/proposals/extensibility-overview.md new file mode 100644 index 0000000000..af6c6fd983 --- /dev/null +++ b/docs/proposals/extensibility-overview.md @@ -0,0 +1,369 @@ +# System.CommandLine Extensibility + +## Overview + +`System.CommandLine` is still in preview, yet it had 1.3 million downloads over the past 6 weeks, and is a key dependency of the .NET CLI. There is no other stable first-party story for handling command-line options. This is a problem for authors of command-line tools in the .NET team and in the community, as handling command-line input in a way that is correct and consistent with other tools in the ecosystem is nontrivial. + +However, the main reason `System.CommandLine` is in preview is that it has not passed API review and has some open questions and opinionated decisions that have not been fully validated with customers. Due to the large amount of functionality, this is currently difficult to resolve. Some functionality is certain to change, but this is commingled with the parser, which cannot change behavior without breaking scripts that use CLI tools. + +This document proposes a progressive approach to landing `System.CommandLine` by breaking it up into layered pieces that can be individually finalized. These pieces are intended to be composable, with tightly scoped default implementations. Developers with custom needs and developers of other command-line libraries (such as `Spectre.Console`) will be able to build on top of these pieces and/or provide richer drop-in replacements. + +## Layering + +### Parser + +Implementing command-line parsing in a complete and consistent way is difficult due to many details such as POSIX behavior, aliases, option bundling, escaping, arity, etc. `System.CommandLine` has a robust and well-validated CLI parser, but it is somewhat coupled with `System.CommandLine`’s implementation of higher-level concerns such as validation, help, and invocation. + +The parser has an API for constructing a “grammar”, a tree of nodes describing the CLI’s commands, options and arguments. The parser uses this grammar to parse the args array, producing a parse result. + +The parser layer is a low-level API that is not intended to be replaceable and is minimally extensible. It’s a foundational component for higher-level APIs, third-party command-line libraries and developers with custom needs, providing them with a command-line parsing implementation that is complete and correct. This will help promote consistent command-line parsing behavior across the .NET ecosystem. + +#### Parse Result + +The parser produces a ParseResult that is a structured representation of the argument array analogous to an AST. It contains `CommandResult`, `OptionResult` and `ArgumentResult` objects that represent the commands, arguments and options that were parsed from the args array and preserve the order from the args array. Commands, options and arguments that were defined in the grammar but were not found in the args array will not be present in the `ParseResult`. The result is purely a representation of what was parsed, and determining the value of a specific option or argument (including handling of default values) is the responsibility of higher layers. + +These nodes have `ArgsOffset` and `ArgsLength` properties that indicate the portion of the args array from which they were parsed. This allows advanced scenarios such as printing errors with a position marker. + +If an option has an optional value, such as bool options treating `--enabled` as equivalent to `--enabled=true`, then the `Option` will have a property indicating whether this optional value was provided or not. + +Determining the value of a specific option or argument may involve inspecting other options, or options on parent commands, or using a default value. This will generally be done via the subsystem extensibility layer. + +The result also contains a collection of any errors that were found during parsing. These errors use a Roslyn-like descriptor model, where the prototypical error that defines the format string and error code is separate from the error instance, which contains a reference to the descriptor along with position data and format data. + +#### Type Conversion + +The parser will support C# primitive types in its `Option` and `Argument`. We may also choose to add support for commonly used types such as `DateTime`, `FileInfo`, and `DirectoryInfo`. + +Developers will be able to use other types if they provide a converter. However, this type conversion will not use the current CustomParser extensibility model, as it exposes a low-level Symbol/Token API that we would like to keep internal for now. Instead, it will have a simple, reflection-free type converter that allows will allow `Option` and `Argument` to be used with non-primitive types, without exposing a complex symbol/token API. This model would allow converting any primitive type or array of primitive types: + +```csharp +delegate TOutput TypeConverter(TInput input); + +Option locationOpt = new Option(“--location”) { + TypeConverter = (int[] s) => new Point(s[0],s[1])) +} +``` + +A standard `ParserErrorException` exception type would be used for returning user-visible error messages, and the parser would catch these and add them to its error collection. For convenience, the parser would also collect all other exceptions from type conversion and add a generic “Invalid format” error. + +Type converters are only expected to perform parsing and construction. They are not expected to provide any other kind of validation, as that should be handled in the validation subsystem, which is also responsible for printing any `ParserErrors` in the `ParseResult`. + +#### Reusable Type Converters + +Perhaps we could also allow registering these type converters on the RootCommand so that they do not need to be provided for every option/argument that uses the same type. + +```csharp + +var rootCommand = new RootCommand() + .WithTypeConverter(s => new Point(s[0],s[1])); +``` + +This would also allow libraries to provide helper methods to register converters on the RootCommand: + +```csharp +static RootCommand WithPointConverter(this RootCommand rootCommand) + => root.WithTypeConverter(s => new Point(s[0],s[1])); +``` + +Alternatively, developers using might choose to subclass the option or argument type: + +```csharp +class PointOption : Option { + public PointOption(string name) : base(name) { + TypeConverter = s => new Point(s[0],s[1])); + } +} +``` + +### Subsystems + +A subsystem operates on the parse result and performs some action based on the parser grammar and parse result. The core subsystems are help, validation, invocation, and completion. Developers using the low-level parser API may use any or all the subsystems directly, and higher-level command-line APIs may use subsystems internally. + +The subsystems envisaged are: + +* Error handling: print formatted errors. +* Help: set help information, handle the `--help` option, and print formatted help. +* Completion: handle the completion option. +* Validation: set constraints, and check values respect constraints. +* Invocation: set handler delegates, and dispatch to the correct one. +* Defaults: set default values, and determine values for all arguments and options that take these defaults into account. + +Subsystems are intended to allow drop-in replacements that go beyond the functionality of the default implementations. For example, an alternate help subsystem may format the help output differently. A developer may obtain an alternate subsystem from a NuGet package or implement their own. Higher-level command-line APIs are expected to use relevant subsystems internally and allow developers to optionally override them with alternate subsystems. + +Subsystems may require or be influenced by additional information associated with parser nodes. For example: + +* The invocation subsystem requires handler delegates to be attached to command grammar nodes so they can be invoked when that command is present in the parse result. +* The help subsystem’s output can be enriched by adding help descriptions to the command, option and argument grammar nodes. +* An alternate help subsystem may support additional information such as examples or hyperlinks. +* The existing System.CommandLine.NamingConventionBinder could become an alternate invocation layer, allowing strongly typed command handler delegates with parameters corresponding to the command’s options and arguments, and binding them automatically when the command is invoked. + +These subsystem annotations do not influence parsing so do not need to be coupled with the parser layer. High-performance scenarios may wish to lazily provide some annotations, such as only loading help descriptions when help is invoked, and alternate subsystems may define and use additional annotations. For this reason, an extensible model for subsystem annotations will be provided at the subsystem layer and is detailed later in this document. + +Although alternate subsystems may have additional annotations, they are expected to use the annotations of the default subsystems where possible, so that when alternate subsystems are dropped into an existing application, they use any relevant information that the developer has already provided. + +### Binding/Model + +A binding/model layer wraps the parser and subsystem layers in a high-level, user-friendly API. In the long term, most developers of CLI apps should be using a model/binding layer. A binding/model layer is by nature opinionated, and there are many different idioms it could use. However, they can all build on top of the parser and subsystems layers, and allow drop-in replacement of subsystems. + +An example of a model/binding layer is `System.CommandLine`’s `DragonFruit` API, which allows developers to define their CLI by writing a Main method with strongly typed parameters corresponding to the CLI’s options and arguments. Using a Roslyn generator, DragonFruit internally constructs a parser grammar using the method’s signature, converting default parameter values into default option values, and converting doc comments into help descriptions. On invocation it binds the command-line options and arguments to the method’s arguments and invokes the method. + +## Stabilization Status + +The parser layer’s API is almost shovel-ready for inclusion in the BCL in .NET 9, and we should be able to re-use much of the implementation and tests from System.CommandLine. + +The next target will be to stabilize the subsystems. The subsystems may not become part of the BCL but will be a stable package. When the .NET CLI migrates to this package, this will eliminate its preview dependency, as it does not use a binding/model layer. +The default subsystems will be a straightforward transformation of the existing functionality used by the .NET CLI. However, the subsystem API and annotation APIs will require some discussion. + +The model/binding layer requires more experimentation with different forms of binding/model layer to validate with developers before committing to using one for the Console App templates in Visual Studio and the .NET SDK. We may end up with multiple, and there will no doubt be third party ones. + +## Subsystem API Pattern + +The subsystems follow a common pattern for initialization, annotations, and invocation. + +### Initialization + +All subsystems have an initialization call to create the subsystem and apply any settings. This initialization call may require a RootCommand instance so it can add options, such as the --help option required by the help subsystem. + +One question here is whether subsystems should be locals or should be attached to the `RootCommand` or some other collection such as a new `CliPipeline` class. Ideally the subsystems would not be stored on the `RootCommand` as that would require either putting the concepts of subsystems in the parser layer (e.g. an abstract `CliSubsystem` class and a `Dictionary` on `RootCommand`) or having a completely generic `PropertyBag`-like object storage mechanism on `RootCommand`, which is not generally considered a good pattern for the BCL. As developers will need to create an instance of a subsystem to opt into using that subsystem, it seems reasonable to store them in locals. The value of storing the subsystem instances in a standard place would be if that made it easier for extension methods to locate the subsystem instance. + +The developer must be able to provide an instance of an alternate subsystem, or an instance of the subsystem configured with custom options. The subsystem may also have an optional parameter for an annotation provider, which allows performance-sensitive developers to perform lazy lookup of annotations instead of setting them upfront. + +For example, a local-based initialization call might simply be a constructor call: + +```csharp +var help = new MyAlternateHelpSubsystem(helpAnnotationProvider); +``` + +### Annotations + +#### Annotation Storage + +A subsystem must also provide methods to annotate `CliSymbol` grammar nodes (`CliCommand`, `CliOption` and `CLiArgument`) with arbitrary string-keyed data. An open question is how this data should be attached. + +It would be desirable to allow setting symbol-specific annotations directly on the symbol, e.g. + +```csharp +command.SetDescription("This is a description"); +``` + +However, this would require the parser layer to be aware of the concept of subsystem annotations and to expose a `PropertyBag`-like model for storage of arbitrary data, which is very unlikely to pass BCL API review. Alternatively the subsystem layer could add subclasses for all the `CliSymbol` derived classes to store this data, but this creates a bifurcation in the usage of the parser API. The last option would be to use a hidden static `ConditionalWeakTable` to associate annotation data with symbol instances, but magically storing instance data in a hidden static field is not a good pattern, and has problematic implications around performance and threading. + +Instead, we could make each subsystem responsible for storing its own annotation data. For example, the base `CliSubsystem` could expose the following annotation API: + +```csharp +void SetAnnotation(CliSymbol symbol, string id, T value); +T GetAnnotation(CliSymbol symbol, string id); +``` + +These would internally store the annotation values on a dictionary keyed on the symbol and the annotation ID. + +#### Annotation Accessors + +Developers would not expected to use these base annotation accessors directly unless they are writing an alternate subsystem that has its own additional annotations. The default subsystem and alternate subsystems should provider wrapper methods for specific annotations. + +For example, for help descriptions, the `HelpSubsystem` could have the following accessors: + +```csharp +void SetDescription(CliSymbol symbol, string description) + => SetAnnotation(symbol, HelpAnnotations.Description, description); +string GetDescription(CliSymbol symbol) + => GetAnnotation(symbol, HelpAnnotations.Description); +``` + +There would also be static classes defining the IDs of well-known annotations for use by subsystems and annotation providers, such as `HelpAnnotations.Description`. + +#### Fluent Annotations + +Unfortunately it is not easy to add fluent helpers such as `command.WithHelpDescription(“Some description”)` as such an extension method would not be able to locate the annotation storage unless the annotations were stored on the symbol or accessible via a hidden static `ConditionalWeakTable`, which are problematic for the reasons described earlier. + +Even storing the subsystem on the `RootCommand` would not help with this, as in the following example, the `SetHelpDescription` extension methods would not have access to the `RootCommand` instance as the `Command`’s parent is not set until after the `WithHelpDescription` extension method is called: + +```csharp +rootCommand.Add( + new Command(“--hello”) + .WithHelpDescription(“Hello”)); +``` + +However, a different approach to annotation wrappers would enable a pattern for fluently setting annotations on grammar nodes when constructing the grammar. + +The following `AnnotationAccessor` wrapper struct encapsulates a reference to the subsystem and the annotation ID: + +```csharp +record struct AnnotationAccessor (Subsystem Subsystem, string Id) { + public void Set(CliSymbol symbol, T value) => subsystem.SetAnnotation(symbol, Id, value); + public T Get(CliSymbol symbol) => subsystem.GetAnnotation(symbol, Id); +} +``` + +Subsystems would be expected to provide properties that expose instances of this wrapper for individual annotations: + +```csharp +AnnotationAccessor Description => new (this, HelpAnnotations.Description); +``` + +This would allow setting an annotation value for a node via these annotation wrappers with the following pattern, replacing the earlier `SetDescription` wrapper method pattern: + +```csharp +help.Description.Set(thingCommand, “This is a thing”); +``` + +Using this pattern instead of the earlier `Set`/`GetDescription` style wrappers would allow the implementation of the following extension method on the grammar nodes: + +```csharp +static Command With(this CliSymbol symbol, AnnotationAccessor accessor, T value) + => accessor.SetValue(symbol, value); +``` + +This extension method would allow fluently setting the help description in a relatively discoverable and easily readable way: + +```csharp +var rootCommand = new RootCommand(); +var help = new HelpSubsystem(); +rootCommand.Add ( + new Command (“greet”) + .With(help.Description, “Greet the user”) +); +``` + +#### Annotation Providers + +The annotation provider model allows performance-sensitive developers to opt into lazily fetching annotations when needed. Developers may provide an instance of this provider to a subsystem when initializing the subsystem. + +```csharp +interface AnnotationProvider { + Get(CliSymbol symbol, string id, object value); +} +``` + +An implementation of one of these methods might look as follows: + +```csharp +GetCommand command, string id, object value) + => (command.Name, id) switch { + (“greet”, HelpAnnotation.Description) => “Greet the user”, + _ => null +}; +``` + +It would even be possible to implement a source generator for optimizing CLI apps by converting fluent annotations into lazy annotations. It would collect values passed to the `With(Annotation,T)` extension method, generate annotation provider implementations that provide those value lazily, and elide the `With` method calls with an interceptor. + +### Subsystem Invocation + +Subsystems provide a method that must be called after parsing to invoke the subsystem. Invocation uses the `ParseResult`, subsystem annotations, and any settings provided when initializing the subsystem. + +When invoked subsystems should print any warnings and errors using the error handler subsystem. If not provided an error handler subsystem, they should use the default one, which prints to stderr. An alternate error handler implementation could customize how errors are rendered, or it could collect the errors so that they could be inspected or printed a later point. + +If subsystem invocation determines that the app should be terminated, it should return an `ExitDescriptor`, otherwise `null`. This `ExitDescriptor` encapsulates an exit code and a description of the code’s meaning that is intended to be printed only when showing information about all available exit codes and their meanings. + +For example, the `HelpSubsystem`’s `ShowIfNeeded` invocation method checks whether the parseResult contains the help option, and if so, it prints help based on the grammar and annotations, and returns `ExitDescriptor.Success` to indicate that help was invoked and the program should exit. + +### Subsystem Pipeline + +Here is an example of a full result handling pipeline that uses all the subsystems: + +```csharp +// if there are any parsing errors, print them, and determines +// which error to use for the exit code and return it +if (errorHandler.TryPrintErrors(parseResult) is CliExit exit) { + // ExitDescriptor has implicit cast to exit code int + return error; +} + +// if result contains help option, show help and return success exit. +// may use values from the validation and default value +// subsystem to enrich output. +if (help.ShowIfNeeded(parseResult, validation, defaults, errorHandler) is CliExit exit) { + return exit; +} + +// if result contains completion directive, print completion +// and return success exit. may return an error exit if there is some +// internal error. +if (completion.Handle(parseResult, errorHandler) is CliExit exit) { + return exit; +} + +// validate all values in the parse result and print validation +// errors to the errorHandler. if any errors, return appropriate exit. +if (validation.Validate(parseResult, errorHandler) is CliExit exit) { + return exit +} +// create a collection that can return values for all options +// and arguments, even if they were not present in the ParseResult. +// if any default value delegate throws exceptions, +// print them using the errorHandler, and returns error exit. +if (CliValues.Create(parseResult, defaults, errorHandler, out CliValues values) is CliExit exit) { + return exit; +} + +// determine which handler delegate to use and invoke it. +// depending how the delegate was registered, may pass the values +// collection to the invocation delegate directly, or bind +// the delegate’s arguments to values from this collection. +// returns the exit descriptor returned from the invoked delegate, +// or null if it did not find a delegate to invoke. +if (invocation.Dispatch(parseResult, values, errorHandler) is CliExit exit) { + return exit; +} + +// creates a customized ExitDescriptor that also prints +// a short form of the help +CliExit noCommandExit = help.CreateNoCommandExit (); +errorHandler.WriteError(noCommandExit); +return noCommandExit; +``` + +There would be several extension methods that encapsulate the standard subsystem invocation pipeline shown above. The most important is `Invoke`: + +```csharp +ExitDescriptor Invoke( + this parseResult result, + InvocationSubsystem invocation, + HelpSubsystem? helpSubsystem = null, + DefaultValuesSubsystem? defaultValues = null, + ValidationSubsystem? validationSubsystem = null, + CompletionSubsystem? completion = null, + ErrorHandler? errorHandler = null +) +``` + +Note that the `InvocationSubsystem` subsystem cannot be null for the `Invoke` helper. Note also that the arguments are in the order of most to least likely to be provided , making it more likely arguments can be omitted without passing nulls or using named arguments. + +The `GetValues` variant of this helper omits the invocation subsystem, and returns the CliValues and the command, for users who want to perform invocation manually: + +```csharp +var (exitDescriptor, command, values) = parseResult.GetValues( + help, defaultValues, validation, completion, errorHandler +); +``` + +Note that any of the subsystems passed to these helpers may be null. If the error handle, help, completion, or default value subsystems are null, an instance of the default implementation will be used. The validation and invocation subsystem invocations will be skipped if they are not provided, as they do nothing without annotations so the default instance would be redundant. + +Although most users would use these helpers, some very advanced cases may wish to use any or all of the subsystems in a custom pipeline. The main value of this subsystem model is that apps can use alternate implementations for any or all of the subsystems, either written specifically for the app or obtained from NuGet. + +## End-to-End Example + +Here is an end-to-end example of an entire CLI application that initializes subsystems, constructs a simple command, attaches annotations, and runs the subsystem pipeline: + +```csharp +var rootCommand = new RootCommand(); + +var help = new HelpSubsystem(rootCommand); +var invocation = new InvocationSubsystem(rootCommand); +var defaults = new DefaultValuesSubsystem(rootCommand); + +rootCommand.Add ( + new Command (“greet”, + new Argument(“name”), + .With (help.Description, “The name of the person to greet”), + .With (defaults.Provider, () => Environment.UserName) + ) + .With(help.Description, “Greet the user”) + .With(invocation.Handler, + name => Console.WriteLine($“Hello {name}!”)) +); + +var parseResult = rootCommand.Parse(args); + +return parseResult.Invoke (invocation, help, defaults); +``` diff --git a/samples/DragonFruit/DragonFruit.csproj b/samples/DragonFruit/DragonFruit.csproj index 7c72c4da2b..92e9a9cd37 100644 --- a/samples/DragonFruit/DragonFruit.csproj +++ b/samples/DragonFruit/DragonFruit.csproj @@ -6,8 +6,4 @@ true - - - - diff --git a/samples/HostingPlayground/HostingPlayground.csproj b/samples/HostingPlayground/HostingPlayground.csproj index 54bc18bb67..b0060a43ee 100644 --- a/samples/HostingPlayground/HostingPlayground.csproj +++ b/samples/HostingPlayground/HostingPlayground.csproj @@ -8,8 +8,6 @@ - - diff --git a/samples/RenderingPlayground/RenderingPlayground.csproj b/samples/RenderingPlayground/RenderingPlayground.csproj index 109f8622e1..0fdc93b388 100644 --- a/samples/RenderingPlayground/RenderingPlayground.csproj +++ b/samples/RenderingPlayground/RenderingPlayground.csproj @@ -10,9 +10,4 @@ - - - - - diff --git a/sourcebuild.slnf b/sourcebuild.slnf index dc155a4d4e..22123b62b2 100644 --- a/sourcebuild.slnf +++ b/sourcebuild.slnf @@ -2,10 +2,7 @@ "solution": { "path": "System.CommandLine.sln", "projects": [ - "src\\System.CommandLine\\System.CommandLine.csproj", - "src\\System.CommandLine.DragonFruit\\System.CommandLine.DragonFruit.csproj", - "src\\System.CommandLine.NamingConventionBinder\\System.CommandLine.NamingConventionBinder.csproj", - "src\\System.CommandLine.Rendering\\System.CommandLine.Rendering.csproj" + "src\\System.CommandLine\\System.CommandLine.csproj" ] } } \ No newline at end of file diff --git a/src/System.CommandLine/System.Diagnostics.CodeAnalysis/DynamicallyAccessedMemberTypes.cs b/src/Common/Compat/CodeAnalysis/DynamicallyAccessedMemberTypes.cs similarity index 99% rename from src/System.CommandLine/System.Diagnostics.CodeAnalysis/DynamicallyAccessedMemberTypes.cs rename to src/Common/Compat/CodeAnalysis/DynamicallyAccessedMemberTypes.cs index a139bdc050..a077f34c51 100644 --- a/src/System.CommandLine/System.Diagnostics.CodeAnalysis/DynamicallyAccessedMemberTypes.cs +++ b/src/Common/Compat/CodeAnalysis/DynamicallyAccessedMemberTypes.cs @@ -1,6 +1,6 @@ // adapted from: https://github.com/dotnet/aspnetcore/blob/404d81767784552b0a148cb8c437332ebe726ae9/src/Shared/CodeAnalysis/DynamicallyAccessedMemberTypes.cs -#if !NET6_0_OR_GREATER +#if !NET5_0_OR_GREATER // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. diff --git a/src/System.CommandLine/System.Diagnostics.CodeAnalysis/DynamicallyAccessedMembersAttribute.cs b/src/Common/Compat/CodeAnalysis/DynamicallyAccessedMembersAttribute.cs similarity index 99% rename from src/System.CommandLine/System.Diagnostics.CodeAnalysis/DynamicallyAccessedMembersAttribute.cs rename to src/Common/Compat/CodeAnalysis/DynamicallyAccessedMembersAttribute.cs index a39283b0ab..8a15e0106f 100644 --- a/src/System.CommandLine/System.Diagnostics.CodeAnalysis/DynamicallyAccessedMembersAttribute.cs +++ b/src/Common/Compat/CodeAnalysis/DynamicallyAccessedMembersAttribute.cs @@ -1,6 +1,6 @@ // adapted from: https://github.com/dotnet/aspnetcore/blob/404d81767784552b0a148cb8c437332ebe726ae9/src/Shared/CodeAnalysis/DynamicallyAccessedMembersAttribute.cs#L29 -#if !NET6_0_OR_GREATER +#if !NET5_0_OR_GREATER // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. diff --git a/src/Common/Compat/CodeAnalysis/NullabilityAttributes.cs b/src/Common/Compat/CodeAnalysis/NullabilityAttributes.cs new file mode 100644 index 0000000000..eaa568afac --- /dev/null +++ b/src/Common/Compat/CodeAnalysis/NullabilityAttributes.cs @@ -0,0 +1,149 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Diagnostics.CodeAnalysis +{ +#if !NETCOREAPP3_0_OR_GREATER + /// Specifies that null is allowed as an input even if the corresponding type disallows it. + [AttributeUsage (AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)] + internal sealed class AllowNullAttribute : Attribute + { } + + /// Specifies that null is disallowed as an input even if the corresponding type allows it. + [AttributeUsage (AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)] + internal sealed class DisallowNullAttribute : Attribute + { } + + /// Specifies that an output may be null even if the corresponding type disallows it. + [AttributeUsage (AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)] + internal sealed class MaybeNullAttribute : Attribute + { } + + /// Specifies that an output will not be null even if the corresponding type allows it. Specifies that an input argument was not null when the call returns. + [AttributeUsage (AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)] + internal sealed class NotNullAttribute : Attribute + { } + + /// Specifies that when a method returns , the parameter may be null even if the corresponding type disallows it. + [AttributeUsage (AttributeTargets.Parameter, Inherited = false)] + internal sealed class MaybeNullWhenAttribute : Attribute + { + /// Initializes the attribute with the specified return value condition. + /// + /// The return value condition. If the method returns this value, the associated parameter may be null. + /// + public MaybeNullWhenAttribute (bool returnValue) => ReturnValue = returnValue; + + /// Gets the return value condition. + public bool ReturnValue { get; } + } + + /// Specifies that when a method returns , the parameter will not be null even if the corresponding type allows it. + [AttributeUsage (AttributeTargets.Parameter, Inherited = false)] + internal sealed class NotNullWhenAttribute : Attribute + { + /// Initializes the attribute with the specified return value condition. + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + public NotNullWhenAttribute (bool returnValue) => ReturnValue = returnValue; + + /// Gets the return value condition. + public bool ReturnValue { get; } + } + + /// Specifies that the output will be non-null if the named parameter is non-null. + [AttributeUsage (AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, AllowMultiple = true, Inherited = false)] + internal sealed class NotNullIfNotNullAttribute : Attribute + { + /// Initializes the attribute with the associated parameter name. + /// + /// The associated parameter name. The output will be non-null if the argument to the parameter specified is non-null. + /// + public NotNullIfNotNullAttribute (string parameterName) => ParameterName = parameterName; + + /// Gets the associated parameter name. + public string ParameterName { get; } + } + + /// Applied to a method that will never return under any circumstance. + [AttributeUsage (AttributeTargets.Method, Inherited = false)] + internal sealed class DoesNotReturnAttribute : Attribute + { } + + /// Specifies that the method will not return if the associated Boolean parameter is passed the specified value. + [AttributeUsage (AttributeTargets.Parameter, Inherited = false)] + internal sealed class DoesNotReturnIfAttribute : Attribute + { + /// Initializes the attribute with the specified parameter value. + /// + /// The condition parameter value. Code after the method will be considered unreachable by diagnostics if the argument to + /// the associated parameter matches this value. + /// + public DoesNotReturnIfAttribute (bool parameterValue) => ParameterValue = parameterValue; + + /// Gets the condition parameter value. + public bool ParameterValue { get; } + } + +#endif +#if !NET5_0_OR_GREATER + + /// Specifies that the method or property will ensure that the listed field and property members have not-null values. + [AttributeUsage (AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)] + internal sealed class MemberNotNullAttribute : Attribute + { + /// Initializes the attribute with a field or property member. + /// + /// The field or property member that is promised to be not-null. + /// + public MemberNotNullAttribute (string member) => Members = new[] { member }; + + /// Initializes the attribute with the list of field and property members. + /// + /// The list of field and property members that are promised to be not-null. + /// + public MemberNotNullAttribute (params string[] members) => Members = members; + + /// Gets field or property member names. + public string[] Members { get; } + } + + /// Specifies that the method or property will ensure that the listed field and property members have not-null values when returning with the specified return value condition. + [AttributeUsage (AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)] + internal sealed class MemberNotNullWhenAttribute : Attribute + { + /// Initializes the attribute with the specified return value condition and a field or property member. + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + /// + /// The field or property member that is promised to be not-null. + /// + public MemberNotNullWhenAttribute (bool returnValue, string member) + { + ReturnValue = returnValue; + Members = new[] { member }; + } + + /// Initializes the attribute with the specified return value condition and list of field and property members. + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + /// + /// The list of field and property members that are promised to be not-null. + /// + public MemberNotNullWhenAttribute (bool returnValue, params string[] members) + { + ReturnValue = returnValue; + Members = members; + } + + /// Gets the return value condition. + public bool ReturnValue { get; } + + /// Gets field or property member names. + public string[] Members { get; } + } +#endif +} \ No newline at end of file diff --git a/src/Common/Compat/CodeAnalysis/StringSyntaxAttribute.cs b/src/Common/Compat/CodeAnalysis/StringSyntaxAttribute.cs new file mode 100644 index 0000000000..ee4bfcf8c4 --- /dev/null +++ b/src/Common/Compat/CodeAnalysis/StringSyntaxAttribute.cs @@ -0,0 +1,71 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Diagnostics.CodeAnalysis +{ +#if !NET7_0_OR_GREATER + /// Specifies the syntax used in a string. + [AttributeUsage (AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] + sealed class StringSyntaxAttribute : Attribute + { + /// Initializes the with the identifier of the syntax used. + /// The syntax identifier. + public StringSyntaxAttribute(string syntax) + { + Syntax = syntax; + Arguments = Array.Empty(); + } + + /// Initializes the with the identifier of the syntax used. + /// The syntax identifier. + /// Optional arguments associated with the specific syntax employed. + public StringSyntaxAttribute(string syntax, params object?[] arguments) + { + Syntax = syntax; + Arguments = arguments; + } + + /// Gets the identifier of the syntax used. + public string Syntax { get; } + + /// Optional arguments associated with the specific syntax employed. + public object?[] Arguments { get; } + + /// The syntax identifier for strings containing composite formats for string formatting. + public const string CompositeFormat = nameof(CompositeFormat); + + /// The syntax identifier for strings containing date format specifiers. + public const string DateOnlyFormat = nameof(DateOnlyFormat); + + /// The syntax identifier for strings containing date and time format specifiers. + public const string DateTimeFormat = nameof(DateTimeFormat); + + /// The syntax identifier for strings containing format specifiers. + public const string EnumFormat = nameof(EnumFormat); + + /// The syntax identifier for strings containing format specifiers. + public const string GuidFormat = nameof(GuidFormat); + + /// The syntax identifier for strings containing JavaScript Object Notation (JSON). + public const string Json = nameof(Json); + + /// The syntax identifier for strings containing numeric format specifiers. + public const string NumericFormat = nameof(NumericFormat); + + /// The syntax identifier for strings containing regular expressions. + public const string Regex = nameof(Regex); + + /// The syntax identifier for strings containing time format specifiers. + public const string TimeOnlyFormat = nameof(TimeOnlyFormat); + + /// The syntax identifier for strings containing format specifiers. + public const string TimeSpanFormat = nameof(TimeSpanFormat); + + /// The syntax identifier for strings containing URIs. + public const string Uri = nameof(Uri); + + /// The syntax identifier for strings containing XML. + public const string Xml = nameof(Xml); + } +#endif +} \ No newline at end of file diff --git a/src/System.CommandLine/System.Diagnostics.CodeAnalysis/UnconditionalSuppressMessageAttribute.cs b/src/Common/Compat/CodeAnalysis/UnconditionalSuppressMessageAttribute.cs similarity index 99% rename from src/System.CommandLine/System.Diagnostics.CodeAnalysis/UnconditionalSuppressMessageAttribute.cs rename to src/Common/Compat/CodeAnalysis/UnconditionalSuppressMessageAttribute.cs index 8c54ed2c91..9654691e50 100644 --- a/src/System.CommandLine/System.Diagnostics.CodeAnalysis/UnconditionalSuppressMessageAttribute.cs +++ b/src/Common/Compat/CodeAnalysis/UnconditionalSuppressMessageAttribute.cs @@ -1,6 +1,6 @@ // adapted from: https://github.com/dotnet/runtime/blob/a5159b1a8840632ad34cf59c5aaf77040cb6ceda/src/libraries/System.Private.CoreLib/src/System/Diagnostics/CodeAnalysis/UnconditionalSuppressMessageAttribute.cs#L21 -#if !NET6_0_OR_GREATER +#if !NET5_0_OR_GREATER // Licensed to the .NET Foundation under one or more agreements. diff --git a/src/System.CommandLine/System.Runtime.CompilerServices/IsExternalInit.cs b/src/Common/Compat/CompilerServices/IsExternalInit.cs similarity index 88% rename from src/System.CommandLine/System.Runtime.CompilerServices/IsExternalInit.cs rename to src/Common/Compat/CompilerServices/IsExternalInit.cs index aee0d3ca7e..362fa00c5d 100644 --- a/src/System.CommandLine/System.Runtime.CompilerServices/IsExternalInit.cs +++ b/src/Common/Compat/CompilerServices/IsExternalInit.cs @@ -1,8 +1,12 @@ // 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. +#if !NET5_0_OR_GREATER + namespace System.Runtime.CompilerServices; internal static class IsExternalInit { -} \ No newline at end of file +} + +#endif \ No newline at end of file diff --git a/src/System.CommandLine.ApiCompatibility.Tests/ApiCompatibilityApprovalTests.cs b/src/System.CommandLine.ApiCompatibility.Tests/ApiCompatibilityApprovalTests.cs index b413bf2120..b6725a8b05 100644 --- a/src/System.CommandLine.ApiCompatibility.Tests/ApiCompatibilityApprovalTests.cs +++ b/src/System.CommandLine.ApiCompatibility.Tests/ApiCompatibilityApprovalTests.cs @@ -1,8 +1,6 @@ // 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.CommandLine.Hosting; -using System.CommandLine.NamingConventionBinder; using ApprovalTests; using ApprovalTests.Reporters; using Xunit; @@ -11,27 +9,11 @@ namespace System.CommandLine.ApiCompatibility.Tests; public class ApiCompatibilityApprovalTests { - [Fact] + [Fact(Skip = "This test to track API changes is turned off as we are aggressively changing the APO")] [UseReporter(typeof(DiffReporter))] public void System_CommandLine_api_is_not_changed() { var contract = ApiContract.GenerateContractForAssembly(typeof(ParseResult).Assembly); Approvals.Verify(contract); } - - [Fact] - [UseReporter(typeof(DiffReporter))] - public void System_CommandLine_Hosting_api_is_not_changed() - { - var contract = ApiContract.GenerateContractForAssembly(typeof(HostingExtensions).Assembly); - Approvals.Verify(contract); - } - - [Fact] - [UseReporter(typeof(DiffReporter))] - public void System_CommandLine_NamingConventionBinder_api_is_not_changed() - { - var contract = ApiContract.GenerateContractForAssembly(typeof(ModelBindingCommandHandler).Assembly); - Approvals.Verify(contract); - } } \ No newline at end of file diff --git a/src/System.CommandLine.ApiCompatibility.Tests/LocalizationTests.cs b/src/System.CommandLine.ApiCompatibility.Tests/LocalizationTests.cs index c861914005..0380955eb6 100644 --- a/src/System.CommandLine.ApiCompatibility.Tests/LocalizationTests.cs +++ b/src/System.CommandLine.ApiCompatibility.Tests/LocalizationTests.cs @@ -8,6 +8,7 @@ public class LocalizationTests { private const string CommandName = "the-command"; + /* [Theory] [InlineData("es", $"Falta el argumento requerido para el comando: '{CommandName}'.")] [InlineData("en-US", $"Required argument missing for command: '{CommandName}'.")] @@ -33,5 +34,6 @@ public void ErrorMessages_AreLocalized(string cultureName, string expectedMessag CultureInfo.CurrentUICulture = uiCultureBefore; } } + */ } } diff --git a/src/System.CommandLine.ApiCompatibility.Tests/System.CommandLine.ApiCompatibility.Tests.csproj b/src/System.CommandLine.ApiCompatibility.Tests/System.CommandLine.ApiCompatibility.Tests.csproj index 670b1d0361..b69badcf7c 100644 --- a/src/System.CommandLine.ApiCompatibility.Tests/System.CommandLine.ApiCompatibility.Tests.csproj +++ b/src/System.CommandLine.ApiCompatibility.Tests/System.CommandLine.ApiCompatibility.Tests.csproj @@ -10,8 +10,6 @@ - - diff --git a/src/System.CommandLine.Tests/CompilationTests.cs b/src/System.CommandLine.Extended.Tests/CompilationTests.cs similarity index 100% rename from src/System.CommandLine.Tests/CompilationTests.cs rename to src/System.CommandLine.Extended.Tests/CompilationTests.cs diff --git a/src/System.CommandLine.Tests/CompletionContextTests.cs b/src/System.CommandLine.Extended.Tests/CompletionContextTests.cs similarity index 100% rename from src/System.CommandLine.Tests/CompletionContextTests.cs rename to src/System.CommandLine.Extended.Tests/CompletionContextTests.cs diff --git a/src/System.CommandLine.Extended.Tests/Directory.Build.props b/src/System.CommandLine.Extended.Tests/Directory.Build.props new file mode 100644 index 0000000000..431713f682 --- /dev/null +++ b/src/System.CommandLine.Extended.Tests/Directory.Build.props @@ -0,0 +1,10 @@ + + + + true + IDE1006 + + + + + diff --git a/src/System.CommandLine.Tests/Invocation/CancelOnProcessTerminationTests.cs b/src/System.CommandLine.Extended.Tests/Invocation/CancelOnProcessTerminationTests.cs similarity index 100% rename from src/System.CommandLine.Tests/Invocation/CancelOnProcessTerminationTests.cs rename to src/System.CommandLine.Extended.Tests/Invocation/CancelOnProcessTerminationTests.cs diff --git a/src/System.CommandLine.Tests/Invocation/InvocationTests.cs b/src/System.CommandLine.Extended.Tests/Invocation/InvocationTests.cs similarity index 100% rename from src/System.CommandLine.Tests/Invocation/InvocationTests.cs rename to src/System.CommandLine.Extended.Tests/Invocation/InvocationTests.cs diff --git a/src/System.CommandLine.Tests/Invocation/TypoCorrectionTests.cs b/src/System.CommandLine.Extended.Tests/Invocation/TypoCorrectionTests.cs similarity index 100% rename from src/System.CommandLine.Tests/Invocation/TypoCorrectionTests.cs rename to src/System.CommandLine.Extended.Tests/Invocation/TypoCorrectionTests.cs diff --git a/src/System.CommandLine.Extended.Tests/Program.cs b/src/System.CommandLine.Extended.Tests/Program.cs new file mode 100644 index 0000000000..a5e09da17e --- /dev/null +++ b/src/System.CommandLine.Extended.Tests/Program.cs @@ -0,0 +1,10 @@ +// 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.Extended.Tests; + +public class Program +{ + public static void Main() + { } +} \ No newline at end of file diff --git a/src/System.CommandLine.Extended.Tests/System.CommandLine.Extended.Tests.csproj b/src/System.CommandLine.Extended.Tests/System.CommandLine.Extended.Tests.csproj new file mode 100644 index 0000000000..f194d83ce7 --- /dev/null +++ b/src/System.CommandLine.Extended.Tests/System.CommandLine.Extended.Tests.csproj @@ -0,0 +1,48 @@ + + + + $(TargetFrameworkForNETSDK);net462 + false + $(DefaultExcludesInProjectFolder);TestApps\** + Library + False + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/System.CommandLine/CompletionSourceExtensions.cs b/src/System.CommandLine.Extended/CompletionSourceExtensions.cs similarity index 100% rename from src/System.CommandLine/CompletionSourceExtensions.cs rename to src/System.CommandLine.Extended/CompletionSourceExtensions.cs diff --git a/src/System.CommandLine/Completions/CompletionAction.cs b/src/System.CommandLine.Extended/Completions/CompletionAction.cs similarity index 100% rename from src/System.CommandLine/Completions/CompletionAction.cs rename to src/System.CommandLine.Extended/Completions/CompletionAction.cs diff --git a/src/System.CommandLine/Completions/CompletionContext.cs b/src/System.CommandLine.Extended/Completions/CompletionContext.cs similarity index 100% rename from src/System.CommandLine/Completions/CompletionContext.cs rename to src/System.CommandLine.Extended/Completions/CompletionContext.cs diff --git a/src/System.CommandLine/Completions/CompletionItem.cs b/src/System.CommandLine.Extended/Completions/CompletionItem.cs similarity index 100% rename from src/System.CommandLine/Completions/CompletionItem.cs rename to src/System.CommandLine.Extended/Completions/CompletionItem.cs diff --git a/src/System.CommandLine/Completions/SuggestDirective.cs b/src/System.CommandLine.Extended/Completions/SuggestDirective.cs similarity index 100% rename from src/System.CommandLine/Completions/SuggestDirective.cs rename to src/System.CommandLine.Extended/Completions/SuggestDirective.cs diff --git a/src/System.CommandLine/Completions/TextCompletionContext.cs b/src/System.CommandLine.Extended/Completions/TextCompletionContext.cs similarity index 100% rename from src/System.CommandLine/Completions/TextCompletionContext.cs rename to src/System.CommandLine.Extended/Completions/TextCompletionContext.cs diff --git a/src/System.CommandLine/Help/HelpBuilder.Default.cs b/src/System.CommandLine.Extended/Help/HelpBuilder.Default.cs similarity index 100% rename from src/System.CommandLine/Help/HelpBuilder.Default.cs rename to src/System.CommandLine.Extended/Help/HelpBuilder.Default.cs diff --git a/src/System.CommandLine/Help/HelpBuilder.cs b/src/System.CommandLine.Extended/Help/HelpBuilder.cs similarity index 100% rename from src/System.CommandLine/Help/HelpBuilder.cs rename to src/System.CommandLine.Extended/Help/HelpBuilder.cs diff --git a/src/System.CommandLine/Help/HelpBuilderExtensions.cs b/src/System.CommandLine.Extended/Help/HelpBuilderExtensions.cs similarity index 100% rename from src/System.CommandLine/Help/HelpBuilderExtensions.cs rename to src/System.CommandLine.Extended/Help/HelpBuilderExtensions.cs diff --git a/src/System.CommandLine/Help/HelpContext.cs b/src/System.CommandLine.Extended/Help/HelpContext.cs similarity index 100% rename from src/System.CommandLine/Help/HelpContext.cs rename to src/System.CommandLine.Extended/Help/HelpContext.cs diff --git a/src/System.CommandLine/Help/HelpOption.cs b/src/System.CommandLine.Extended/Help/HelpOption.cs similarity index 94% rename from src/System.CommandLine/Help/HelpOption.cs rename to src/System.CommandLine.Extended/Help/HelpOption.cs index c4c53f194c..989fe9ea3f 100644 --- a/src/System.CommandLine/Help/HelpOption.cs +++ b/src/System.CommandLine.Extended/Help/HelpOption.cs @@ -30,8 +30,9 @@ public HelpOption() : this("--help", new[] { "-h", "/h", "-?", "/?" }) /// When added to a , it configures the application to show help when given name or one of the aliases are specified on the command line. /// public HelpOption(string name, params string[] aliases) - : base(name, aliases, new CliArgument(name) { Arity = ArgumentArity.Zero }) + : base(name, aliases) { + Arity = ArgumentArity.Zero; Recursive = true; Description = LocalizationResources.HelpOptionDescription(); } diff --git a/src/System.CommandLine/Help/HelpOptionAction.cs b/src/System.CommandLine.Extended/Help/HelpOptionAction.cs similarity index 97% rename from src/System.CommandLine/Help/HelpOptionAction.cs rename to src/System.CommandLine.Extended/Help/HelpOptionAction.cs index 9700643ae5..fe034647fd 100644 --- a/src/System.CommandLine/Help/HelpOptionAction.cs +++ b/src/System.CommandLine.Extended/Help/HelpOptionAction.cs @@ -24,7 +24,7 @@ public override int Invoke(ParseResult parseResult) var output = parseResult.Configuration.Output; var helpContext = new HelpContext(Builder, - parseResult.CommandResult.Command, + parseResult.CommandResultInternal.Command, output, parseResult); diff --git a/src/System.CommandLine/Help/TwoColumnHelpRow.cs b/src/System.CommandLine.Extended/Help/TwoColumnHelpRow.cs similarity index 100% rename from src/System.CommandLine/Help/TwoColumnHelpRow.cs rename to src/System.CommandLine.Extended/Help/TwoColumnHelpRow.cs diff --git a/src/System.CommandLine/Invocation/AnonymousAsynchronousCliAction.cs b/src/System.CommandLine.Extended/Invocation/AnonymousAsynchronousCliAction.cs similarity index 100% rename from src/System.CommandLine/Invocation/AnonymousAsynchronousCliAction.cs rename to src/System.CommandLine.Extended/Invocation/AnonymousAsynchronousCliAction.cs diff --git a/src/System.CommandLine/Invocation/AnonymousSynchronousCliAction.cs b/src/System.CommandLine.Extended/Invocation/AnonymousSynchronousCliAction.cs similarity index 100% rename from src/System.CommandLine/Invocation/AnonymousSynchronousCliAction.cs rename to src/System.CommandLine.Extended/Invocation/AnonymousSynchronousCliAction.cs diff --git a/src/System.CommandLine/Invocation/AsynchronousCliAction.cs b/src/System.CommandLine.Extended/Invocation/AsynchronousCliAction.cs similarity index 100% rename from src/System.CommandLine/Invocation/AsynchronousCliAction.cs rename to src/System.CommandLine.Extended/Invocation/AsynchronousCliAction.cs diff --git a/src/System.CommandLine/Invocation/CliAction.cs b/src/System.CommandLine.Extended/Invocation/CliAction.cs similarity index 100% rename from src/System.CommandLine/Invocation/CliAction.cs rename to src/System.CommandLine.Extended/Invocation/CliAction.cs diff --git a/src/System.CommandLine/Invocation/InvocationPipeline.cs b/src/System.CommandLine.Extended/Invocation/InvocationPipeline.cs similarity index 100% rename from src/System.CommandLine/Invocation/InvocationPipeline.cs rename to src/System.CommandLine.Extended/Invocation/InvocationPipeline.cs diff --git a/src/System.CommandLine/Invocation/ParseErrorAction.cs b/src/System.CommandLine.Extended/Invocation/ParseErrorAction.cs similarity index 97% rename from src/System.CommandLine/Invocation/ParseErrorAction.cs rename to src/System.CommandLine.Extended/Invocation/ParseErrorAction.cs index b1af669607..c85e432757 100644 --- a/src/System.CommandLine/Invocation/ParseErrorAction.cs +++ b/src/System.CommandLine.Extended/Invocation/ParseErrorAction.cs @@ -2,8 +2,6 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Collections.Generic; -using System.CommandLine.Help; -using System.CommandLine.Parsing; using System.Linq; using System.Threading; @@ -36,11 +34,13 @@ public override int Invoke(ParseResult parseResult) WriteErrorDetails(parseResult); + // TODO: move with invocation + /* if (ShowHelp) { WriteHelp(parseResult); } - + */ return 1; } @@ -59,30 +59,30 @@ private static void WriteErrorDetails(ParseResult parseResult) ConsoleHelpers.ResetTerminalForegroundColor(); } + // TODO: move with invocation + /* private static void WriteHelp(ParseResult parseResult) { // Find the most proximate help option (if any) and invoke its action. var availableHelpOptions = parseResult - .CommandResult - .RecurseWhileNotNull(r => r.Parent as CommandResult) - .Select(r => r.Command.Options.OfType().FirstOrDefault()); - + .CommandResultInternal + .RecurseWhileNotNull(r => r.Parent as CommandResultInternal) + .Select(r => r.Command.Options.OfType().FirstOrDefault()); if (availableHelpOptions.FirstOrDefault(o => o is not null) is { Action: not null } helpOption) { switch (helpOption.Action) { case SynchronousCliAction syncAction: syncAction.Invoke(parseResult); - break; - + break; case AsynchronousCliAction asyncAction: asyncAction.InvokeAsync(parseResult, CancellationToken.None).ConfigureAwait(false).GetAwaiter().GetResult(); break; } } } - +*/ private static void WriteTypoCorrectionSuggestions(ParseResult parseResult) { var unmatchedTokens = parseResult.UnmatchedTokens; @@ -92,7 +92,7 @@ private static void WriteTypoCorrectionSuggestions(ParseResult parseResult) var token = unmatchedTokens[i]; bool first = true; - foreach (string suggestion in GetPossibleTokens(parseResult.CommandResult.Command, token)) + foreach (string suggestion in GetPossibleTokens(parseResult.CommandResultInternal.Command, token)) { if (first) { diff --git a/src/System.CommandLine/Invocation/ProcessTerminationHandler.cs b/src/System.CommandLine.Extended/Invocation/ProcessTerminationHandler.cs similarity index 100% rename from src/System.CommandLine/Invocation/ProcessTerminationHandler.cs rename to src/System.CommandLine.Extended/Invocation/ProcessTerminationHandler.cs diff --git a/src/System.CommandLine/Invocation/SynchronousCliAction.cs b/src/System.CommandLine.Extended/Invocation/SynchronousCliAction.cs similarity index 100% rename from src/System.CommandLine/Invocation/SynchronousCliAction.cs rename to src/System.CommandLine.Extended/Invocation/SynchronousCliAction.cs diff --git a/src/System.CommandLine.Extended/System.CommandLine.Extended.csproj b/src/System.CommandLine.Extended/System.CommandLine.Extended.csproj new file mode 100644 index 0000000000..fc75683c8e --- /dev/null +++ b/src/System.CommandLine.Extended/System.CommandLine.Extended.csproj @@ -0,0 +1,57 @@ + + + + true + System.CommandLine.Extended + $(TargetFrameworkForNETSDK);netstandard2.0 + enable + true + latest + Support for parsing command lines, supporting both POSIX and Windows conventions and shell-agnostic command line completions. + true + False + + + + true + true + true + + + + portable + + + + + + + + + + + + + + True + True + Resources.resx + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + + + + + + + + diff --git a/src/System.CommandLine/VersionOption.cs b/src/System.CommandLine.Extended/VersionOption.cs similarity index 89% rename from src/System.CommandLine/VersionOption.cs rename to src/System.CommandLine.Extended/VersionOption.cs index 3186262469..53be33155d 100644 --- a/src/System.CommandLine/VersionOption.cs +++ b/src/System.CommandLine.Extended/VersionOption.cs @@ -1,7 +1,7 @@ // 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.CommandLine.Invocation; +//using System.CommandLine.Invocation; using System.CommandLine.Parsing; using System.Linq; @@ -12,8 +12,10 @@ namespace System.CommandLine /// public sealed class VersionOption : CliOption { +// TODO: invocation +/* private CliAction? _action; - +*/ /// /// When added to a , it enables the use of a --version option, which when specified in command line input will short circuit normal command handling and instead write out version information before exiting. /// @@ -25,8 +27,11 @@ public VersionOption() : this("--version", Array.Empty()) /// When added to a , it enables the use of a provided option name and aliases, which when specified in command line input will short circuit normal command handling and instead write out version information before exiting. /// public VersionOption(string name, params string[] aliases) - : base(name, aliases, new CliArgument("--version") { Arity = ArgumentArity.Zero }) + : base(name, aliases) { + Arity = ArgumentArity.Zero; +// TODO: help, validators, invocation, access to IsGreedy +/* Description = LocalizationResources.VersionOptionDescription(); AddValidators(); } @@ -42,7 +47,7 @@ private void AddValidators() { Validators.Add(static result => { - if (result.Parent is CommandResult parent && + if (result.Parent is CliCommandResultInternal parent && parent.Children.Any(r => r is not OptionResult { Option: VersionOption })) { result.AddError(LocalizationResources.VersionOptionCannotBeCombinedWithOtherArguments(result.IdentifierToken?.Value ?? result.Option.Name)); @@ -59,6 +64,7 @@ public override int Invoke(ParseResult parseResult) parseResult.Configuration.Output.WriteLine(CliRootCommand.ExecutableVersion); return 0; } +*/ } } } \ No newline at end of file diff --git a/src/System.CommandLine.Generator/System.CommandLine.Generator.csproj b/src/System.CommandLine.Generator/System.CommandLine.Generator.csproj index ba581bf1b5..9ba2dbb923 100644 --- a/src/System.CommandLine.Generator/System.CommandLine.Generator.csproj +++ b/src/System.CommandLine.Generator/System.CommandLine.Generator.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/System.CommandLine.NamingConventionBinder/BindingContextExtensions.cs b/src/System.CommandLine.NamingConventionBinder/BindingContextExtensions.cs index fe707c2347..d1e9ea54a4 100644 --- a/src/System.CommandLine.NamingConventionBinder/BindingContextExtensions.cs +++ b/src/System.CommandLine.NamingConventionBinder/BindingContextExtensions.cs @@ -20,12 +20,12 @@ private sealed class DummyStateHoldingHandler : BindingHandler public static BindingContext GetBindingContext(this ParseResult parseResult) { // parsing resulted with no handler or it was not created yet, we fake it to just store the BindingContext between the calls - if (parseResult.CommandResult.Command.Action is null) + if (parseResult.CommandResultInternal.Command.Action is null) { - parseResult.CommandResult.Command.Action = new DummyStateHoldingHandler(); + parseResult.CommandResultInternal.Command.Action = new DummyStateHoldingHandler(); } - return ((BindingHandler)parseResult.CommandResult.Command.Action).GetBindingContext(parseResult); + return ((BindingHandler)parseResult.CommandResultInternal.Command.Action).GetBindingContext(parseResult); } /// diff --git a/src/System.CommandLine.NamingConventionBinder/CommandResultExtensions.cs b/src/System.CommandLine.NamingConventionBinder/CommandResultExtensions.cs index f20269245e..223056ac29 100644 --- a/src/System.CommandLine.NamingConventionBinder/CommandResultExtensions.cs +++ b/src/System.CommandLine.NamingConventionBinder/CommandResultExtensions.cs @@ -8,7 +8,7 @@ namespace System.CommandLine.NamingConventionBinder; internal static class CommandResultExtensions { internal static bool TryGetValueForArgument( - this CommandResult commandResult, + this CliCommandResultInternal commandResult, IValueDescriptor valueDescriptor, out object? value) { @@ -38,7 +38,7 @@ internal static bool TryGetValueForArgument( } internal static bool TryGetValueForOption( - this CommandResult commandResult, + this CliCommandResultInternal commandResult, IValueDescriptor valueDescriptor, out object? value) { diff --git a/src/System.CommandLine.NamingConventionBinder/ParseResultMatchingValueSource.cs b/src/System.CommandLine.NamingConventionBinder/ParseResultMatchingValueSource.cs index 4168c99deb..190b85318b 100644 --- a/src/System.CommandLine.NamingConventionBinder/ParseResultMatchingValueSource.cs +++ b/src/System.CommandLine.NamingConventionBinder/ParseResultMatchingValueSource.cs @@ -15,7 +15,7 @@ public bool TryGetValue( { if (!string.IsNullOrEmpty(valueDescriptor.ValueName)) { - CommandResult? commandResult = bindingContext?.ParseResult.CommandResult; + CliCommandResultInternal? commandResult = bindingContext?.ParseResult.CommandResultInternal; while (commandResult is { }) { @@ -34,7 +34,7 @@ public bool TryGetValue( return true; } - commandResult = commandResult.Parent as CommandResult; + commandResult = commandResult.Parent as CliCommandResultInternal; } } diff --git a/src/System.CommandLine.Subsystems.Tests/AlternateSubsystems.cs b/src/System.CommandLine.Subsystems.Tests/AlternateSubsystems.cs new file mode 100644 index 0000000000..5677470b03 --- /dev/null +++ b/src/System.CommandLine.Subsystems.Tests/AlternateSubsystems.cs @@ -0,0 +1,75 @@ +// 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.CommandLine.Directives; +using System.CommandLine.Subsystems.Annotations; + +namespace System.CommandLine.Subsystems.Tests +{ + internal class AlternateSubsystems + { + internal class AlternateVersion : VersionSubsystem + { + public override void Execute(PipelineResult pipelineResult) + { + pipelineResult.ConsoleHack.WriteLine($"***{CliExecutable.ExecutableVersion}***"); + pipelineResult.SetSuccess(); + } + } + + internal class VersionThatUsesHelpData : VersionSubsystem + { + // for testing, this class accepts a symbol and accesses its description + + public VersionThatUsesHelpData(CliSymbol symbol) + { + Symbol = symbol; + } + + private CliSymbol Symbol { get; } + + public override void Execute(PipelineResult pipelineResult) + { + TryGetAnnotation(Symbol, HelpAnnotations.Description, out string? description); + pipelineResult.ConsoleHack.WriteLine(description); + pipelineResult.AlreadyHandled = true; + pipelineResult.SetSuccess(); + } + } + + internal class VersionWithInitializeAndTeardown : VersionSubsystem + { + internal bool InitializationWasRun; + internal bool ExecutionWasRun; + internal bool TeardownWasRun; + + protected internal override void Initialize(InitializationContext context) + { + base.Initialize(context); + // marker hack needed because ConsoleHack not available in initialization + InitializationWasRun = true; + } + + public override void Execute(PipelineResult pipelineResult) + { + ExecutionWasRun = true; + base.Execute(pipelineResult); + } + + protected internal override void TearDown(PipelineResult pipelineResult) + { + TeardownWasRun = true; + base.TearDown(pipelineResult); + } + } + + internal class StringDirectiveSubsystem(IAnnotationProvider? annotationProvider = null) + : DirectiveSubsystem("other", SubsystemKind.Diagram, annotationProvider) + { } + + internal class BooleanDirectiveSubsystem(IAnnotationProvider? annotationProvider = null) + : DirectiveSubsystem("diagram", SubsystemKind.Diagram, annotationProvider) + { } + + } +} diff --git a/src/System.CommandLine.Tests/CompletionTests.cs b/src/System.CommandLine.Subsystems.Tests/CompletionTests.cs similarity index 100% rename from src/System.CommandLine.Tests/CompletionTests.cs rename to src/System.CommandLine.Subsystems.Tests/CompletionTests.cs diff --git a/src/System.CommandLine.Subsystems.Tests/Constants.cs b/src/System.CommandLine.Subsystems.Tests/Constants.cs new file mode 100644 index 0000000000..3ad05c1614 --- /dev/null +++ b/src/System.CommandLine.Subsystems.Tests/Constants.cs @@ -0,0 +1,19 @@ +// 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.Reflection; + +namespace System.CommandLine.Subsystems.Tests +{ + internal class Constants + { + + internal static readonly string? version = (Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly()) + ?.GetCustomAttribute() + ?.InformationalVersion; + + internal static readonly string newLine = Environment.NewLine; + + + } +} diff --git a/src/System.CommandLine.Subsystems.Tests/DiagramSubsystemTests.cs b/src/System.CommandLine.Subsystems.Tests/DiagramSubsystemTests.cs new file mode 100644 index 0000000000..254526dab0 --- /dev/null +++ b/src/System.CommandLine.Subsystems.Tests/DiagramSubsystemTests.cs @@ -0,0 +1,45 @@ +// 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 FluentAssertions; +using System.CommandLine.Directives; +using System.CommandLine.Parsing; +using Xunit; + +namespace System.CommandLine.Subsystems.Tests; + +public class DiagramSubsystemTests +{ + + [Theory] + [ClassData(typeof(TestData.Diagram))] + public void Diagram_is_activated_only_when_requested(string input, bool expectedIsActive) + { + CliRootCommand rootCommand = [new CliCommand("x")]; + var configuration = new CliConfiguration(rootCommand); + var subsystem = new DiagramSubsystem(); + var args = CliParser.SplitCommandLine(input).ToList().AsReadOnly(); + + Subsystem.Initialize(subsystem, configuration, args); + var parseResult = CliParser.Parse(rootCommand, input, configuration); + var isActive = Subsystem.GetIsActivated(subsystem, parseResult); + + isActive.Should().Be(expectedIsActive); + } + + [Theory] + [ClassData(typeof(TestData.Diagram))] + public void String_directive_supplies_string_or_default_and_is_activated_only_when_requested(string input, bool expectedIsActive) + { + CliRootCommand rootCommand = [new CliCommand("x")]; + var configuration = new CliConfiguration(rootCommand); + var subsystem = new DiagramSubsystem(); + var args = CliParser.SplitCommandLine(input).ToList().AsReadOnly(); + + Subsystem.Initialize(subsystem, configuration, args); + var parseResult = CliParser.Parse(rootCommand, input, configuration); + var isActive = Subsystem.GetIsActivated(subsystem, parseResult); + + isActive.Should().Be(expectedIsActive); + } +} diff --git a/src/System.CommandLine.Subsystems.Tests/DirectiveSubsystemTests.cs b/src/System.CommandLine.Subsystems.Tests/DirectiveSubsystemTests.cs new file mode 100644 index 0000000000..2dbdebb506 --- /dev/null +++ b/src/System.CommandLine.Subsystems.Tests/DirectiveSubsystemTests.cs @@ -0,0 +1,41 @@ +// 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 FluentAssertions; +using System.CommandLine.Directives; +using System.CommandLine.Parsing; +using Xunit; + +namespace System.CommandLine.Subsystems.Tests; + +public class DirectiveSubsystemTests +{ + + // For Boolean tests see DiagramSubsystemTests + + [Theory] + [ClassData(typeof(TestData.Directive))] + // TODO: Not sure why these tests are passing + public void String_directive_supplies_string_or_default_and_is_activated_only_when_requested( + string input, bool expectedBoolIsActive, bool expectedStringIsActive, string? expectedValue) + { + CliRootCommand rootCommand = [new CliCommand("x")]; + var configuration = new CliConfiguration(rootCommand); + var stringSubsystem = new AlternateSubsystems.StringDirectiveSubsystem(); + var boolSubsystem = new AlternateSubsystems.BooleanDirectiveSubsystem(); + var args = CliParser.SplitCommandLine(input).ToList().AsReadOnly(); + + Subsystem.Initialize(stringSubsystem, configuration, args); + Subsystem.Initialize(boolSubsystem, configuration, args); + + var parseResult = CliParser.Parse(rootCommand, input, configuration); + var stringIsActive = Subsystem.GetIsActivated(stringSubsystem, parseResult); + var boolIsActive = Subsystem.GetIsActivated(boolSubsystem, parseResult); + var actualValue = stringSubsystem.Value; + + boolIsActive.Should().Be(expectedBoolIsActive); + stringIsActive.Should().Be(expectedStringIsActive); + actualValue.Should().Be(expectedValue); + + } +} diff --git a/src/System.CommandLine.Tests/ParseErrorReportingTests.cs b/src/System.CommandLine.Subsystems.Tests/ErrorReportingFunctionalTests.cs similarity index 96% rename from src/System.CommandLine.Tests/ParseErrorReportingTests.cs rename to src/System.CommandLine.Subsystems.Tests/ErrorReportingFunctionalTests.cs index e6e4b21137..47e4808c18 100644 --- a/src/System.CommandLine.Tests/ParseErrorReportingTests.cs +++ b/src/System.CommandLine.Subsystems.Tests/ErrorReportingFunctionalTests.cs @@ -1,8 +1,10 @@ // 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.CommandLine.Help; using System.CommandLine.Invocation; +*/ using System.IO; using FluentAssertions; using Xunit; @@ -12,8 +14,10 @@ namespace System.CommandLine.Tests; -public class ParseErrorReportingTests +public class ErrorReportingFunctionalTests { + // TODO: these tests depend on help output + /* [Fact] // https://github.com/dotnet/command-line-api/issues/817 public void Parse_error_reporting_reports_error_when_help_is_used_and_required_subcommand_is_missing() { @@ -126,4 +130,5 @@ public void When_no_help_option_is_present_then_help_is_not_shown_for_parse_erro output.ToString().Should().NotShowHelp(); } -} \ No newline at end of file + */ +} diff --git a/src/System.CommandLine.Subsystems.Tests/ErrorReportingSubsystemTests.cs b/src/System.CommandLine.Subsystems.Tests/ErrorReportingSubsystemTests.cs new file mode 100644 index 0000000000..1ce30a9422 --- /dev/null +++ b/src/System.CommandLine.Subsystems.Tests/ErrorReportingSubsystemTests.cs @@ -0,0 +1,84 @@ +// 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 FluentAssertions; +using Xunit; +using System.CommandLine.Parsing; + +namespace System.CommandLine.Subsystems.Tests; + +public class ErrorReportingSubsystemTests +{ + [Fact] + public void Report_when_single_error_writes_to_console_hack() + { + var error = new ParseError("a sweet error message"); + var errors = new List { error }; + var errorSubsystem = new ErrorReportingSubsystem(); + var consoleHack = new ConsoleHack().RedirectToBuffer(true); + + errorSubsystem.Report(consoleHack, errors); + + consoleHack.GetBuffer().Trim().Should().Be(error.Message); + } + + [Fact] + public void Report_when_multiple_error_writes_to_console_hack() + { + var error = new ParseError("a sweet error message"); + var anotherError = new ParseError("another sweet error message"); + var errors = new List { error, anotherError }; + var errorSubsystem = new ErrorReportingSubsystem(); + var consoleHack = new ConsoleHack().RedirectToBuffer(true); + + errorSubsystem.Report(consoleHack, errors); + + consoleHack.GetBuffer().Trim().Should().Be($"{error.Message}{Environment.NewLine}{anotherError.Message}"); + } + + [Fact] + public void Report_when_no_errors_writes_nothing_to_console_hack() + { + var errors = new List { }; + var errorSubsystem = new ErrorReportingSubsystem(); + var consoleHack = new ConsoleHack().RedirectToBuffer(true); + + errorSubsystem.Report(consoleHack, errors); + + consoleHack.GetBuffer().Trim().Should().Be(""); + } + + [Theory] + [InlineData("-x")] + [InlineData("-non_existant_option")] + public void GetIsActivated_GivenInvalidInput_SubsystemIsActive(string input) + { + var rootCommand = new CliRootCommand {new CliOption("-v")}; + var configuration = new CliConfiguration(rootCommand); + var errorSubsystem = new ErrorReportingSubsystem(); + IReadOnlyList args = [""]; + Subsystem.Initialize(errorSubsystem, configuration, args); + + var parseResult = CliParser.Parse(rootCommand, input, configuration); + var isActive = Subsystem.GetIsActivated(errorSubsystem, parseResult); + + isActive.Should().BeTrue(); + } + + [Theory] + [InlineData("-v")] + [InlineData("")] + public void GetIsActivated_GivenValidInput_SubsystemShouldNotBeActive(string input) + { + var rootCommand = new CliRootCommand { new CliOption("-v") }; + var configuration = new CliConfiguration(rootCommand); + var errorSubsystem = new ErrorReportingSubsystem(); + IReadOnlyList args = [""]; + Subsystem.Initialize(errorSubsystem, configuration, args); + + var parseResult = CliParser.Parse(rootCommand, input, configuration); + var isActive = Subsystem.GetIsActivated(errorSubsystem, parseResult); + + isActive.Should().BeFalse(); + } +} diff --git a/src/System.CommandLine.Tests/Help/ApprovalTests.Config.cs b/src/System.CommandLine.Subsystems.Tests/Help/ApprovalTests.Config.cs similarity index 100% rename from src/System.CommandLine.Tests/Help/ApprovalTests.Config.cs rename to src/System.CommandLine.Subsystems.Tests/Help/ApprovalTests.Config.cs diff --git a/src/System.CommandLine.Tests/Help/Approvals/HelpBuilderTests.Help_layout_has_not_changed.approved.txt b/src/System.CommandLine.Subsystems.Tests/Help/Approvals/HelpBuilderTests.Help_layout_has_not_changed.approved.txt similarity index 100% rename from src/System.CommandLine.Tests/Help/Approvals/HelpBuilderTests.Help_layout_has_not_changed.approved.txt rename to src/System.CommandLine.Subsystems.Tests/Help/Approvals/HelpBuilderTests.Help_layout_has_not_changed.approved.txt diff --git a/src/System.CommandLine.Tests/Help/HelpBuilderExtensions.cs b/src/System.CommandLine.Subsystems.Tests/Help/HelpBuilderExtensions.cs similarity index 100% rename from src/System.CommandLine.Tests/Help/HelpBuilderExtensions.cs rename to src/System.CommandLine.Subsystems.Tests/Help/HelpBuilderExtensions.cs diff --git a/src/System.CommandLine.Tests/Help/HelpBuilderTests.Approval.cs b/src/System.CommandLine.Subsystems.Tests/Help/HelpBuilderTests.Approval.cs similarity index 100% rename from src/System.CommandLine.Tests/Help/HelpBuilderTests.Approval.cs rename to src/System.CommandLine.Subsystems.Tests/Help/HelpBuilderTests.Approval.cs diff --git a/src/System.CommandLine.Tests/Help/HelpBuilderTests.Customization.cs b/src/System.CommandLine.Subsystems.Tests/Help/HelpBuilderTests.Customization.cs similarity index 100% rename from src/System.CommandLine.Tests/Help/HelpBuilderTests.Customization.cs rename to src/System.CommandLine.Subsystems.Tests/Help/HelpBuilderTests.Customization.cs diff --git a/src/System.CommandLine.Tests/Help/HelpBuilderTests.cs b/src/System.CommandLine.Subsystems.Tests/Help/HelpBuilderTests.cs similarity index 100% rename from src/System.CommandLine.Tests/Help/HelpBuilderTests.cs rename to src/System.CommandLine.Subsystems.Tests/Help/HelpBuilderTests.cs diff --git a/src/System.CommandLine.Tests/HelpOptionTests.cs b/src/System.CommandLine.Subsystems.Tests/HelpOptionTests.cs similarity index 100% rename from src/System.CommandLine.Tests/HelpOptionTests.cs rename to src/System.CommandLine.Subsystems.Tests/HelpOptionTests.cs diff --git a/src/System.CommandLine.Subsystems.Tests/PipelineTests.cs b/src/System.CommandLine.Subsystems.Tests/PipelineTests.cs new file mode 100644 index 0000000000..62f1ac8348 --- /dev/null +++ b/src/System.CommandLine.Subsystems.Tests/PipelineTests.cs @@ -0,0 +1,197 @@ +// 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 FluentAssertions; +using System.CommandLine.Parsing; +using Xunit; + +namespace System.CommandLine.Subsystems.Tests +{ + public class PipelineTests + { + private static Pipeline GetTestPipeline(VersionSubsystem versionSubsystem) + { + var pipeline = Pipeline.CreateEmpty(); + pipeline.Version = versionSubsystem; + return pipeline; + } + + private static CliConfiguration GetNewTestConfiguration() + => new(new CliRootCommand { new CliOption("-x") }); // Add option expected by test data + + private static ConsoleHack GetNewTestConsole() + => new ConsoleHack().RedirectToBuffer(true); + + //private static (Pipeline pipeline, CliConfiguration configuration, ConsoleHack consoleHack) StandardObjects(VersionSubsystem versionSubsystem) + //{ + // var configuration = new CliConfiguration(new CliRootCommand { new CliOption("-x") }); + // var pipeline = new Pipeline + // { + // Version = versionSubsystem + // }; + // var consoleHack = new ConsoleHack().RedirectToBuffer(true); + // return (pipeline, configuration, consoleHack); + //} + + [Theory] + [ClassData(typeof(TestData.Version))] + public void Subsystem_runs_in_pipeline_only_when_requested(string input, bool shouldRun) + { + var pipeline = GetTestPipeline(new VersionSubsystem()); + var console = GetNewTestConsole(); + + var exit = pipeline.Execute(GetNewTestConfiguration(), input, console); + + exit.ExitCode.Should().Be(0); + exit.AlreadyHandled.Should().Be(shouldRun); + if (shouldRun) + { + console.GetBuffer().Trim().Should().Be(TestData.AssemblyVersionString); + } + } + + [Theory] + [ClassData(typeof(TestData.Version))] + public void Subsystem_runs_with_explicit_parse_only_when_requested(string input, bool shouldRun) + { + var pipeline = GetTestPipeline(new VersionSubsystem()); + var console = GetNewTestConsole(); + + var result = pipeline.Parse(GetNewTestConfiguration(), input); + var exit = pipeline.Execute(result, input, console); + + exit.ExitCode.Should().Be(0); + exit.AlreadyHandled.Should().Be(shouldRun); + if (shouldRun) + { + console.GetBuffer().Trim().Should().Be(TestData.AssemblyVersionString); + } + } + + [Theory] + [ClassData(typeof(TestData.Version))] + public void Subsystem_runs_initialize_and_teardown_when_requested(string input, bool shouldRun) + { + var versionSubsystem = new AlternateSubsystems.VersionWithInitializeAndTeardown(); + var pipeline = GetTestPipeline(versionSubsystem); + var console = GetNewTestConsole(); + + var exit = pipeline.Execute(GetNewTestConfiguration(), input, console); + + exit.ExitCode.Should().Be(0); + exit.AlreadyHandled.Should().Be(shouldRun); + versionSubsystem.InitializationWasRun.Should().BeTrue(); + versionSubsystem.ExecutionWasRun.Should().Be(shouldRun); + versionSubsystem.TeardownWasRun.Should().BeTrue(); + } + + + [Theory] + [ClassData(typeof(TestData.Version))] + public void Subsystem_works_without_pipeline(string input, bool shouldRun) + { + var versionSubsystem = new VersionSubsystem(); + // TODO: Ensure an efficient conversion as people may copy this code + var args = CliParser.SplitCommandLine(input).ToList().AsReadOnly(); + var console = GetNewTestConsole(); + var configuration = GetNewTestConfiguration(); + + Subsystem.Initialize(versionSubsystem, configuration, args); + // This approach might be taken if someone is using a subsystem just for initialization + var parseResult = CliParser.Parse(configuration.RootCommand, args, configuration); + bool value = parseResult.GetValue("--version"); + + parseResult.Errors.Should().BeEmpty(); + value.Should().Be(shouldRun); + if (shouldRun) + { + // TODO: Add an execute overload to avoid checking activated twice + var exit = Subsystem.Execute(versionSubsystem, parseResult, input, console); + exit.Should().NotBeNull(); + exit.ExitCode.Should().Be(0); + exit.AlreadyHandled.Should().BeTrue(); + console.GetBuffer().Trim().Should().Be(TestData.AssemblyVersionString); + } + } + + [Theory] + [ClassData(typeof(TestData.Version))] + public void Subsystem_works_without_pipeline_style2(string input, bool shouldRun) + { + var versionSubsystem = new VersionSubsystem(); + var args = CliParser.SplitCommandLine(input).ToList().AsReadOnly(); + var console = GetNewTestConsole(); + var configuration = GetNewTestConfiguration(); + var expectedVersion = shouldRun + ? TestData.AssemblyVersionString + : ""; + + // Someone might use this approach if they wanted to do something with the ParseResult + Subsystem.Initialize(versionSubsystem, configuration, args); + var parseResult = CliParser.Parse(configuration.RootCommand, args, configuration); + var exit = Subsystem.ExecuteIfNeeded(versionSubsystem, parseResult, input, console); + + exit.ExitCode.Should().Be(0); + exit.AlreadyHandled.Should().Be(shouldRun); + console.GetBuffer().Trim().Should().Be(expectedVersion); + } + + + [Theory] + [InlineData("-xy", false)] + [InlineData("--versionx", false)] + public void Subsystem_runs_when_requested_even_when_there_are_errors(string input, bool shouldRun) + { + var versionSubsystem = new VersionSubsystem(); + var args = CliParser.SplitCommandLine(input).ToList().AsReadOnly(); + var configuration = GetNewTestConfiguration(); + + Subsystem.Initialize(versionSubsystem, configuration, args); + // This approach might be taken if someone is using a subsystem just for initialization + var parseResult = CliParser.Parse(configuration.RootCommand, args, configuration); + bool value = parseResult.GetValue("--version"); + + parseResult.Errors.Should().NotBeEmpty(); + value.Should().Be(shouldRun); + } + + [Fact] + public void Standard_pipeline_contains_expected_subsystems() + { + var pipeline = Pipeline.Create(); + pipeline.Version.Should().BeOfType(); + pipeline.Help.Should().BeOfType(); + pipeline.ErrorReporting.Should().BeOfType(); + pipeline.Completion.Should().BeOfType(); + } + + [Fact] + public void Normal_pipeline_contains_no_subsystems() + { + var pipeline = Pipeline.CreateEmpty();; + pipeline.Version.Should().BeNull(); + pipeline.Help.Should().BeNull(); + pipeline.ErrorReporting.Should().BeNull(); + pipeline.Completion.Should().BeNull(); + } + + [Fact] + public void Subsystems_can_access_each_others_data() + { + // TODO: Explore a mechanism that doesn't require the reference to retrieve data, this shows that it is awkward + var symbol = new CliOption("-x"); + var console = GetNewTestConsole(); + var pipeline = Pipeline.Create(version : new AlternateSubsystems.VersionThatUsesHelpData(symbol)); + + if (pipeline.Help is null) throw new InvalidOperationException(); + var rootCommand = new CliRootCommand + { + symbol.WithDescription("Testing") + }; + + pipeline.Execute(new CliConfiguration(rootCommand), "-v", console); + + console.GetBuffer().Trim().Should().Be($"Testing"); + } + } +} diff --git a/src/System.CommandLine.Subsystems.Tests/ResponseSubsystemTests.cs b/src/System.CommandLine.Subsystems.Tests/ResponseSubsystemTests.cs new file mode 100644 index 0000000000..2e7bb8af1d --- /dev/null +++ b/src/System.CommandLine.Subsystems.Tests/ResponseSubsystemTests.cs @@ -0,0 +1,32 @@ +// 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 FluentAssertions; +using System.CommandLine.Directives; +using System.CommandLine.Parsing; +using Xunit; + +namespace System.CommandLine.Subsystems.Tests; + +public class ResponseSubsystemTests +{ + + [Fact] + // TODO: Not sure why these tests are passing + public void Simple_response_file_contributes_to_parsing() + { + var option = new CliOption("--hello"); + var rootCommand = new CliRootCommand { option }; + var configuration = new CliConfiguration(rootCommand); + var subsystem = new ResponseSubsystem(); + subsystem.Enabled = true; + string[] args = ["@Response_1.rsp"]; + + Subsystem.Initialize(subsystem, configuration, args); + + var parseResult = CliParser.Parse(rootCommand, args, configuration); + var value = parseResult.GetValue(option); + + value.Should().Be("world"); + } +} diff --git a/src/System.CommandLine.Subsystems.Tests/Response_1.rsp b/src/System.CommandLine.Subsystems.Tests/Response_1.rsp new file mode 100644 index 0000000000..93f169504f --- /dev/null +++ b/src/System.CommandLine.Subsystems.Tests/Response_1.rsp @@ -0,0 +1 @@ +--hello world diff --git a/src/System.CommandLine.Subsystems.Tests/System.CommandLine.Subsystems.Tests.csproj b/src/System.CommandLine.Subsystems.Tests/System.CommandLine.Subsystems.Tests.csproj new file mode 100644 index 0000000000..ba321497f8 --- /dev/null +++ b/src/System.CommandLine.Subsystems.Tests/System.CommandLine.Subsystems.Tests.csproj @@ -0,0 +1,70 @@ + + + + $(TargetFrameworkForNETSDK) + false + $(DefaultExcludesInProjectFolder);TestApps\** + Library + enable + annotations + + False + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/System.CommandLine.Subsystems.Tests/TestData.cs b/src/System.CommandLine.Subsystems.Tests/TestData.cs new file mode 100644 index 0000000000..817d362d22 --- /dev/null +++ b/src/System.CommandLine.Subsystems.Tests/TestData.cs @@ -0,0 +1,96 @@ +// 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; +using System.Reflection; + +namespace System.CommandLine.Subsystems.Tests; + +internal class TestData +{ + internal static readonly string? AssemblyVersionString = (Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly()) + ?.GetCustomAttribute() + ?.InformationalVersion; + + internal class Version : IEnumerable + { + // This data only works if the CLI has a --version with a -v alias and also has a -x option + private readonly List _data = + [ + ["--version", true], + ["-v", true], + ["-vx", true], + ["-xv", true], + ["-x", false], + [null, false], + ["", false], + ]; + + public IEnumerator GetEnumerator() => _data.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + internal class Diagram : IEnumerable + { + // The tests define an x command, but -o and -v are just random values + private readonly List _data = + [ + ["[diagram]", true], + ["[diagram] x", true], + ["[diagram] -o", true], + ["[diagram] -v", true], + ["[diagram] x -v", true], + ["[diagramX]", false], + ["[diagram] [other]", true], + ["x", false], + ["-o", false], + ["x -x", false], + [null, false], + ["", false] + ]; + + public IEnumerator GetEnumerator() => _data.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + internal class Directive : IEnumerable + { + private readonly List _data = + [ + ["[diagram]", true, false, null], + ["[other:Hello]", false, true, "Hello"], + ["[diagram] x", true, false, null], + ["[diagram] -o", true, false, null], + ["[diagram] -v", true, false, null], + ["[diagram] x -v", true, false, null], + ["[diagramX]", false, false, null], + ["[diagram] [other:Hello]", true, true, "Hello"], + ["x", false, false, null], + ["-o", false, false, null], + ["x -x", false, false, null], + [null, false, false, null], + ["", false, false, null], + //["[diagram] [other Goodbye]", true, true, "Goodbye"],This is a new test that demos new feature, but is also broken + ]; + + public IEnumerator GetEnumerator() => _data.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + internal class Value : IEnumerable + { + private readonly List _data = + [ + ["--intValue", 42], + ["--stringValue", "43"], + ["--boolValue", true] + ]; + + public IEnumerator GetEnumerator() => _data.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/src/System.CommandLine.Subsystems.Tests/ValidationSubsystemTests.cs b/src/System.CommandLine.Subsystems.Tests/ValidationSubsystemTests.cs new file mode 100644 index 0000000000..3ca4e362c4 --- /dev/null +++ b/src/System.CommandLine.Subsystems.Tests/ValidationSubsystemTests.cs @@ -0,0 +1,187 @@ +// 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 FluentAssertions; +using System.CommandLine.Parsing; +using System.CommandLine.ValueSources; +using Xunit; + +namespace System.CommandLine.Subsystems.Tests; + +public class ValidationSubsystemTests +{ + // Running exactly the same code is important here because missing a step will result in a false positive. Ask me how I know + private CliOption GetOptionWithSimpleRange(T lowerBound, T upperBound) + where T : IComparable + { + var option = new CliOption("--intOpt"); + option.SetRange(lowerBound, upperBound); + return option; + } + + private CliOption GetOptionWithRangeBounds(ValueSource lowerBound, ValueSource upperBound) + where T : IComparable + { + var option = new CliOption("--intOpt"); + option.SetRange(lowerBound, upperBound); + return option; + } + + private PipelineResult ExecutedPipelineResultForRangeOption(CliOption option, string input) + { + var command = new CliRootCommand { option }; + return ExecutedPipelineResultForCommand(command, input); + } + + private PipelineResult ExecutedPipelineResultForCommand(CliCommand command, string input) + { + var validationSubsystem = ValidationSubsystem.Create(); + var parseResult = CliParser.Parse(command, input, new CliConfiguration(command)); + var pipelineResult = new PipelineResult(parseResult, input, Pipeline.CreateEmpty()); + validationSubsystem.Execute(pipelineResult); + return pipelineResult; + } + + [Fact] + public void Int_values_in_specified_range_do_not_have_errors() + { + var option = GetOptionWithSimpleRange(0, 50); + + var pipelineResult = ExecutedPipelineResultForRangeOption(option, "--intOpt 42"); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().BeEmpty(); + } + + [Fact] + public void Int_values_above_upper_bound_report_error() + { + var option = GetOptionWithSimpleRange(0, 5); + + var pipelineResult = ExecutedPipelineResultForRangeOption(option, "--intOpt 42"); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().HaveCount(1); + var error = pipelineResult.GetErrors().First(); + // TODO: Create test mechanism for CliDiagnostics + } + + [Fact] + public void Int_below_lower_bound_report_error() + { + var option = GetOptionWithSimpleRange(0, 5); + + var pipelineResult = ExecutedPipelineResultForRangeOption(option, "--intOpt -42"); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().HaveCount(1); + var error = pipelineResult.GetErrors().First(); + // TODO: Create test mechanism for CliDiagnostics + } + + [Fact] + public void Int_values_on_lower_range_bound_do_not_report_error() + { + var option = GetOptionWithSimpleRange(42, 50); + + var pipelineResult = ExecutedPipelineResultForRangeOption(option, "--intOpt 42"); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().BeEmpty(); + } + + [Fact] + public void Int_values_on_upper_range_bound_do_not_report_error() + { + var option = GetOptionWithSimpleRange(0, 42); + + var pipelineResult = ExecutedPipelineResultForRangeOption(option, "--intOpt 42"); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().BeEmpty(); + } + + [Fact] + public void Values_below_calculated_lower_bound_report_error() + { + var option = GetOptionWithRangeBounds(ValueSource.Create(() => (true, 1)), 50); + + var pipelineResult = ExecutedPipelineResultForRangeOption(option, "--intOpt 0"); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().HaveCount(1); + var error = pipelineResult.GetErrors().First(); + // TODO: Create test mechanism for CliDiagnostics + } + + + [Fact] + public void Values_within_calculated_range_do_not_report_error() + { + var option = GetOptionWithRangeBounds(ValueSource.Create(() => (true, 1)), ValueSource.Create(() => (true, 50))); + + var pipelineResult = ExecutedPipelineResultForRangeOption(option, "--intOpt 42"); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().BeEmpty(); + } + + [Fact] + public void Values_above_calculated_upper_bound_report_error() + { + var option = GetOptionWithRangeBounds(0, ValueSource.Create(() => (true, 40))); + + var pipelineResult = ExecutedPipelineResultForRangeOption(option, "--intOpt 42"); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().HaveCount(1); + var error = pipelineResult.GetErrors().First(); + // TODO: Create test mechanism for CliDiagnostics + } + + [Fact] + public void Values_below_relative_lower_bound_report_error() + { + var otherOption = new CliOption("-a"); + var option = GetOptionWithRangeBounds(ValueSource.Create(otherOption, o => (true, (int)o + 1)), 50); + var command = new CliCommand("cmd") { option, otherOption }; + + var pipelineResult = ExecutedPipelineResultForCommand(command, "--intOpt 0 -a 0"); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().HaveCount(1); + var error = pipelineResult.GetErrors().First(); + // TODO: Create test mechanism for CliDiagnostics + } + + + [Fact] + public void Values_within_relative_range_do_not_report_error() + { + var otherOption = new CliOption("-a"); + var option = GetOptionWithRangeBounds(ValueSource.Create(otherOption, o => (true, (int)o + 1)), ValueSource.Create(otherOption, o => (true, (int)o + 10))); + var command = new CliCommand("cmd") { option, otherOption }; + + var pipelineResult = ExecutedPipelineResultForCommand(command, "--intOpt 11 -a 3"); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().BeEmpty(); + } + + [Fact] + public void Values_above_relative_upper_bound_report_error() + { + var otherOption = new CliOption("-a"); + var option = GetOptionWithRangeBounds(0, ValueSource.Create(otherOption, o => (true, (int)o + 10))); + var command = new CliCommand("cmd") { option, otherOption }; + + var pipelineResult = ExecutedPipelineResultForCommand(command, "--intOpt 9 -a -2"); + + pipelineResult.Should().NotBeNull(); + pipelineResult.GetErrors().Should().HaveCount(1); + var error = pipelineResult.GetErrors().First(); + // TODO: Create test mechanism for CliDiagnostics + } + + +} diff --git a/src/System.CommandLine.Subsystems.Tests/ValueSourceTests.cs b/src/System.CommandLine.Subsystems.Tests/ValueSourceTests.cs new file mode 100644 index 0000000000..68a482b6b7 --- /dev/null +++ b/src/System.CommandLine.Subsystems.Tests/ValueSourceTests.cs @@ -0,0 +1,190 @@ +// 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 FluentAssertions; +using System.CommandLine.Parsing; +using System.CommandLine.ValueSources; +using Xunit; + +namespace System.CommandLine.Subsystems.Tests; + +public class ValueSourceTests +{ + private PipelineResult EmptyPipelineResult(string input = "", params CliValueSymbol[] valueSymbols) + { + var rootCommand = new CliRootCommand(); + foreach (var symbol in valueSymbols) + { + rootCommand.Add(symbol); + } + var parseResult = CliParser.Parse(rootCommand, input); + return new PipelineResult(parseResult, "", Pipeline.CreateEmpty()); + } + + [Fact] + public void SimpleValueSource_with_set_value_retrieved() + { + var valueSource = new SimpleValueSource(42); + + if (valueSource.TryGetTypedValue(EmptyPipelineResult(), out var value)) + { + value.Should() + .Be(42); + return; + } + Assert.Fail("Typed value not retrieved"); + } + + [Fact] + public void SimpleValueSource_with_converted_value_retrieved() + { + ValueSource valueSource = 42; + + if (valueSource.TryGetTypedValue(EmptyPipelineResult(), out var value)) + { + value.Should() + .Be(42); + return; + } + Assert.Fail("Typed value not retrieved"); + } + + [Fact] + public void SimpleValueSource_created_via_extension_value_retrieved() + { + var valueSource = ValueSource.Create(42); + + if (valueSource.TryGetTypedValue(EmptyPipelineResult(), out var value)) + { + value.Should() + .Be(42); + return; + } + Assert.Fail("Typed value not retrieved"); + } + + [Fact] + public void CalculatedValueSource_produces_value() + { + var valueSource = new CalculatedValueSource(() => (true, 42)); + + if (valueSource.TryGetTypedValue(EmptyPipelineResult(), out var value)) + { + value.Should() + .Be(42); + return; + } + Assert.Fail("Typed value not retrieved"); + } + + [Fact] + public void CalculatedValueSource_implicitly_converted_produces_value() + { + // TODO: Figure out why this doesn't work, and remove implicit operator if it does not work + // ValueSource valueSource2 = (() => 42); + ValueSource valueSource = (ValueSource)(() => (true, 42)); ; + + if (valueSource.TryGetTypedValue(EmptyPipelineResult(), out var value)) + { + value.Should() + .Be(42); + return; + } + Assert.Fail("Typed value not retrieved"); + } + + [Fact] + public void CalculatedValueSource_from_extension_produces_value() + { + var valueSource = ValueSource.Create(() => (true, 42)); + + if (valueSource.TryGetTypedValue(EmptyPipelineResult(), out var value)) + { + value.Should() + .Be(42); + return; + } + Assert.Fail("Typed value not retrieved"); + } + + [Fact] + public void RelativeToSymbolValueSource_produces_value_that_was_set() + { + var option = new CliOption("-a"); + var valueSource = new RelativeToSymbolValueSource(option); + + if (valueSource.TryGetTypedValue(EmptyPipelineResult("-a 42", option), out var value)) + { + value.Should() + .Be(42); + return; + } + Assert.Fail("Typed value not retrieved"); + + } + + [Fact] + public void RelativeToSymbolValueSource_implicitly_converted_produces_value_that_was_set() + { + var option = new CliOption("-a"); + ValueSource valueSource = option; + + if (valueSource.TryGetTypedValue(EmptyPipelineResult("-a 42", option), out var value)) + { + value.Should() + .Be(42); + return; + } + Assert.Fail("Typed value not retrieved"); + } + + [Fact] + public void RelativeToSymbolValueSource_from_extension_produces_value_that_was_set() + { + var option = new CliOption("-a"); + var valueSource = new RelativeToSymbolValueSource(option); + + if (valueSource.TryGetTypedValue(EmptyPipelineResult("-a 42", option), out var value)) + { + value.Should() + .Be(42); + return; + } + Assert.Fail("Typed value not retrieved"); + } + + [Fact] + public void RelativeToEnvironmentVariableValueSource_produces_value_that_was_set() + { + var envName = "SYSTEM_COMMANDLINE_TESTING"; + var valueSource = new RelativeToEnvironmentVariableValueSource(envName); + + Environment.SetEnvironmentVariable(envName, "42"); + if (valueSource.TryGetTypedValue(EmptyPipelineResult(), out var value)) + { + value.Should() + .Be(42); + return; + } + Environment.SetEnvironmentVariable(envName, null); + Assert.Fail("Typed value not retrieved"); + } + + + [Fact] + public void RelativeToEnvironmentVariableValueSource_from_extension_produces_value_that_was_set() + { + var envName = "SYSTEM_COMMANDLINE_TESTING"; + var valueSource = ValueSource.CreateFromEnvironmentVariable(envName); + + Environment.SetEnvironmentVariable(envName, "42"); + if (valueSource.TryGetTypedValue(EmptyPipelineResult(), out var value)) + { + value.Should() + .Be(42); + return; + } + Environment.SetEnvironmentVariable(envName, null); + Assert.Fail("Typed value not retrieved"); + } +} diff --git a/src/System.CommandLine.Subsystems.Tests/ValueSubsystemTests.cs b/src/System.CommandLine.Subsystems.Tests/ValueSubsystemTests.cs new file mode 100644 index 0000000000..9fb7573a9d --- /dev/null +++ b/src/System.CommandLine.Subsystems.Tests/ValueSubsystemTests.cs @@ -0,0 +1,126 @@ +// 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 FluentAssertions; +using System.CommandLine.Directives; +using System.CommandLine.Parsing; +using Xunit; +using static System.CommandLine.Subsystems.Tests.TestData; + +namespace System.CommandLine.Subsystems.Tests; + +public class ValueSubsystemTests +{ + [Fact] + public void Values_that_are_entered_are_retrieved() + { + var option = new CliOption("--intOpt"); + var rootCommand = new CliRootCommand { option }; + var configuration = new CliConfiguration(rootCommand); + var pipeline = Pipeline.Create(); + var input = "--intOpt 42"; + + var parseResult = CliParser.Parse(rootCommand, input, configuration); + var pipelineResult = new PipelineResult(parseResult, input, pipeline); + + pipelineResult.Should().NotBeNull(); + var optionValueResult = pipelineResult.GetValueResult(option); + var optionValue = pipelineResult.GetValue(option); + optionValueResult.Should().NotBeNull(); + optionValue.Should().Be(42); + } + + [Fact] + public void Values_that_are_not_entered_are_type_default_with_no_default_values() + { + var stringOption = new CliOption("--stringOption"); + var intOption = new CliOption("--intOption"); + var dateOption = new CliOption("--dateOption"); + var nullableIntOption = new CliOption("--nullableIntOption"); + var guidOption = new CliOption("--guidOption"); + var rootCommand = new CliRootCommand { stringOption, intOption, dateOption, nullableIntOption, guidOption }; + var configuration = new CliConfiguration(rootCommand); + var pipeline = Pipeline.Create(); + var input = ""; + + var parseResult = CliParser.Parse(rootCommand, input, configuration); + var pipelineResult = new PipelineResult(parseResult, input, pipeline); + + pipelineResult.Should().NotBeNull(); + var stringOptionValue = pipelineResult.GetValue(stringOption); + var intOptionValue = pipelineResult.GetValue(intOption); + var dateOptionValue = pipelineResult.GetValue(dateOption); + var nullableIntOptionValue = pipelineResult.GetValue(nullableIntOption); + var guidOptionValue = pipelineResult.GetValue(guidOption); + stringOptionValue.Should().BeNull(); + intOptionValue.Should().Be(0); + dateOptionValue.Should().Be(DateTime.MinValue); + nullableIntOptionValue.Should().BeNull(); + guidOptionValue.Should().Be(Guid.Empty); + } + + // TODO: Add various default value tests + + /* Hold these tests until we determine if ValueSubsystem is replaceable + [Fact] + public void ValueSubsystem_returns_values_that_are_entered() + { + var consoleHack = new ConsoleHack().RedirectToBuffer(true); + CliOption option = new CliOption("--intValue"); + CliRootCommand rootCommand = [ + new CliCommand("x") + { + option + }]; + var configuration = new CliConfiguration(rootCommand); + var pipeline = Pipeline.CreateEmpty(); + pipeline.Value = new ValueSubsystem(); + const int expected = 42; + var input = $"x --intValue {expected}"; + + var parseResult = pipeline.Parse(configuration, input); // assigned for debugging + pipeline.Execute(configuration, input, consoleHack); + + pipeline.Value.GetValue(option).Should().Be(expected); + } + + [Fact] + public void ValueSubsystem_returns_default_value_when_no_value_is_entered() + { + var consoleHack = new ConsoleHack().RedirectToBuffer(true); + CliOption option = new CliOption("--intValue"); + CliRootCommand rootCommand = [option]; + var configuration = new CliConfiguration(rootCommand); + var pipeline = Pipeline.CreateEmpty(); + pipeline.Value = new ValueSubsystem(); + option.SetDefaultValue(43); + const int expected = 43; + var input = $""; + + pipeline.Execute(configuration, input, consoleHack); + + pipeline.Value.GetValue(option).Should().Be(expected); + } + + + [Fact] + public void ValueSubsystem_returns_calculated_default_value_when_no_value_is_entered() + { + var consoleHack = new ConsoleHack().RedirectToBuffer(true); + CliOption option = new CliOption("--intValue"); + CliRootCommand rootCommand = [option]; + var configuration = new CliConfiguration(rootCommand); + var pipeline = Pipeline.CreateEmpty(); + pipeline.Value = new ValueSubsystem(); + var x = 42; + option.SetDefaultValueCalculation(() => x + 2); + const int expected = 44; + var input = ""; + + var parseResult = pipeline.Parse(configuration, input); // assigned for debugging + pipeline.Execute(configuration, input, consoleHack); + + pipeline.Value.GetValue(option).Should().Be(expected); + } + */ +} diff --git a/src/System.CommandLine.Subsystems.Tests/VersionFunctionalTests.cs b/src/System.CommandLine.Subsystems.Tests/VersionFunctionalTests.cs new file mode 100644 index 0000000000..8804cca62e --- /dev/null +++ b/src/System.CommandLine.Subsystems.Tests/VersionFunctionalTests.cs @@ -0,0 +1,205 @@ +// 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.Reflection; +using FluentAssertions; +using Xunit; +using System.CommandLine.Parsing; + +namespace System.CommandLine.Subsystems.Tests +{ + public class VersionFunctionalTests + { + private static readonly string? version = (Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly()) + ?.GetCustomAttribute() + ?.InformationalVersion; + private readonly string newLine = Environment.NewLine; + + [Fact] + public void When_the_version_option_is_specified_then_the_version_is_written_to_standard_out() + { + var configuration = new CliConfiguration(new CliRootCommand()); + var pipeline = Pipeline.CreateEmpty(); + var consoleHack = new ConsoleHack().RedirectToBuffer(true); + pipeline.Version = new VersionSubsystem(); + + var exit = pipeline.Execute(configuration, "-v", consoleHack); + + exit.ExitCode.Should().Be(0); + exit.AlreadyHandled.Should().BeTrue(); + consoleHack.GetBuffer().Should().Be($"{version}{newLine}"); + } + + // TODO: invocation/output + /* + [Fact] + public async Task When_the_version_option_is_specified_then_invocation_is_short_circuited() + { + var wasCalled = false; + var rootCommand = new CliRootCommand(); + rootCommand.SetAction((_) => wasCalled = true); + + CliConfiguration configuration = new(rootCommand) + { + Output = new StringWriter() + }; + + await configuration.InvokeAsync("--version"); + + wasCalled.Should().BeFalse(); + } + */ + + /* Consider removing this test as it appears to test that the version option is added by default + [Fact] + public void When_the_version_option_is_specified_then_the_version_is_parsed() + { + ParseResult parseResult = CliParser.Parse ( + new CliRootCommand(), + [ "--version"]); + + parseResult.Errors.Should().BeEmpty(); + parseResult.GetValue(configuration.RootCommand.Options.OfType().Single()).Should().BeTrue(); + } + */ + + // TODO: Help + /* + [Fact] + public async Task Version_option_appears_in_help() + { + CliConfiguration configuration = new(new CliRootCommand()) + { + Output = new StringWriter() + }; + + await configuration.InvokeAsync("--help"); + + configuration.Output + .ToString() + .Should() + .Match("*Options:*--version*Show version information*"); + } + + // TODO: Defaults. These two tests appear to test whether the presence of a default factory on a different option breaks version + /* + [Fact] + public void When_the_version_option_is_specified_and_there_are_default_options_then_the_version_is_written_to_standard_out() + { + var rootCommand = new CliRootCommand + { + new CliOption("-x") + { + DefaultValueFactory = (_) => true + }, + }; + rootCommand.SetAction((_) => { }); + + CliConfiguration configuration = new(rootCommand) + { + Output = new StringWriter() + }; + + await configuration.InvokeAsync("--version"); + + configuration.Output.ToString().Should().Be($"{version}{NewLine}"); + } + + [Fact] + public async Task When_the_version_option_is_specified_and_there_are_default_arguments_then_the_version_is_written_to_standard_out() + { + CliRootCommand rootCommand = new() + { + new CliArgument("x") { DefaultValueFactory =(_) => true }, + }; + rootCommand.SetAction((_) => { }); + + CliConfiguration configuration = new(rootCommand) + { + Output = new StringWriter() + }; + + await configuration.InvokeAsync("--version"); + + configuration.Output.ToString().Should().Be($"{version}{NewLine}"); + } + */ + + const string SkipValidationTests = "VersionOption does not yet do validation"; + + [Theory] + [InlineData("--version", "-x", Skip = SkipValidationTests)] + [InlineData("--version", "subcommand", Skip = SkipValidationTests)] + // TODO: This test will fail because it expects version to always be added + public void Version_is_not_valid_with_other_tokens(params string[] commandLine) + { + var subcommand = new CliCommand("subcommand"); + var rootCommand = new CliRootCommand + { + subcommand, + new CliOption("-x") + }; + + var result = CliParser.Parse(rootCommand, commandLine); + + result.Errors.Should().Contain(e => e.Message == "--version option cannot be combined with other arguments."); + } + + [Fact] + // TODO: This test will fail because it expects version to always be added + public void Version_option_is_not_added_to_subcommands() + { + var childCommand = new CliCommand("subcommand"); + + var rootCommand = new CliRootCommand + { + childCommand + }; + + rootCommand + .Subcommands + .Single(c => c.Name == "subcommand") + .Options + .Should() + .BeEmpty(); + } + + // TODO: Determine Ux for adding more aliases. There is no easy access point for the user to access the option, and not much reason to. Consider requiring override or possibly extra property. + /* + [Fact] + public void Version_can_specify_additional_alias() + { + var versionOption = new VersionOption("-version", "-v"); + CliRootCommand rootCommand = [versionOption]; + + var parseResult = CliParser.Parse(rootCommand, ["-version"]); + var versionSpecified = parseResult.GetValue(versionOption); + versionSpecified.Should().BeTrue(); + + parseResult = CliParser.Parse(rootCommand, ["-v"]); + versionSpecified = parseResult.GetValue(versionOption); + versionSpecified.Should().BeTrue(); + } + */ + + // TODO: Determine if the limitation to root is desirable + /* + [Fact(Skip = SkipValidationTests)] + // TODO: This test will fail because it expects version to always be added + public void Version_is_not_valid_with_other_tokens_uses_custom_alias() + { + var childCommand = new CliCommand("subcommand"); + var rootCommand = new CliRootCommand + { + childCommand + }; + + rootCommand.Options[0] = new VersionOption("-v"); + + var result = CliParser.Parse(rootCommand, ["-v", "subcommand"]); + + result.Errors.Should().ContainSingle(e => e.Message == "-v option cannot be combined with other arguments."); + } + */ + } +} \ No newline at end of file diff --git a/src/System.CommandLine.Subsystems.Tests/VersionSubsystemTests.cs b/src/System.CommandLine.Subsystems.Tests/VersionSubsystemTests.cs new file mode 100644 index 0000000000..bdef1b2daf --- /dev/null +++ b/src/System.CommandLine.Subsystems.Tests/VersionSubsystemTests.cs @@ -0,0 +1,117 @@ +// 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 FluentAssertions; +using Xunit; +using System.CommandLine.Parsing; + +namespace System.CommandLine.Subsystems.Tests +{ + public class VersionSubsystemTests + { + [Fact] + public void When_version_subsystem_is_used_the_version_option_is_added_to_the_root() + { + var rootCommand = new CliRootCommand + { + new CliOption("-x") // add option that is expected for the test data used here + }; + var configuration = new CliConfiguration(rootCommand); + var pipeline = Pipeline.CreateEmpty(); + pipeline.Version = new VersionSubsystem(); + + // Parse is used because directly calling Initialize would be unusual + var result = pipeline.Parse(configuration, ""); + + rootCommand.Options.Should().NotBeNull(); + rootCommand.Options + .Count(x => x.Name == "--version") + .Should() + .Be(1); + } + + [Theory] + [ClassData(typeof(TestData.Version))] + public void Version_is_activated_only_when_requested(string input, bool result) + { + CliRootCommand rootCommand = [new CliOption("-x")]; // add random option as empty CLIs are rare + var configuration = new CliConfiguration(rootCommand); + var versionSubsystem = new VersionSubsystem(); + var args = CliParser.SplitCommandLine(input).ToList().AsReadOnly(); + + Subsystem.Initialize(versionSubsystem, configuration, args); + + var parseResult = CliParser.Parse(rootCommand, input, configuration); + var isActive = Subsystem.GetIsActivated(versionSubsystem, parseResult); + + isActive.Should().Be(result); + } + + [Fact] + public void Outputs_assembly_version() + { + var consoleHack = new ConsoleHack().RedirectToBuffer(true); + var versionSubsystem = new VersionSubsystem(); + Subsystem.Execute(versionSubsystem, new PipelineResult(null, "", null, consoleHack)); + consoleHack.GetBuffer().Trim().Should().Be(Constants.version); + } + + [Fact] + public void Outputs_specified_version() + { + var consoleHack = new ConsoleHack().RedirectToBuffer(true); + var versionSubsystem = new VersionSubsystem + { + SpecificVersion = "42" + }; + Subsystem.Execute(versionSubsystem, new PipelineResult(null, "", null, consoleHack)); + consoleHack.GetBuffer().Trim().Should().Be("42"); + } + + [Fact] + public void Outputs_assembly_version_when_specified_version_set_to_null() + { + var consoleHack = new ConsoleHack().RedirectToBuffer(true); + var versionSubsystem = new VersionSubsystem + { + SpecificVersion = null + }; + Subsystem.Execute(versionSubsystem, new PipelineResult(null, "", null, consoleHack)); + consoleHack.GetBuffer().Trim().Should().Be(Constants.version); + } + + [Fact] + public void Console_output_can_be_tested() + { + CliConfiguration configuration = new(new CliRootCommand()) + { }; + + var consoleHack = new ConsoleHack().RedirectToBuffer(true); + var versionSubsystem = new VersionSubsystem(); + Subsystem.Execute(versionSubsystem, new PipelineResult(null, "", null, consoleHack)); + consoleHack.GetBuffer().Trim().Should().Be(Constants.version); + } + + [Fact] + public void Custom_version_subsystem_can_be_used() + { + var consoleHack = new ConsoleHack().RedirectToBuffer(true); + var pipeline = Pipeline.CreateEmpty(); + pipeline.Version = new AlternateSubsystems.AlternateVersion(); + + pipeline.Execute(new CliConfiguration(new CliRootCommand()), "-v", consoleHack); + consoleHack.GetBuffer().Trim().Should().Be($"***{Constants.version}***"); + } + + [Fact] + public void Custom_version_subsystem_can_replace_standard() + { + var consoleHack = new ConsoleHack().RedirectToBuffer(true); + var pipeline = Pipeline.CreateEmpty(); + pipeline.Version = new AlternateSubsystems.AlternateVersion(); + + pipeline.Execute(new CliConfiguration(new CliRootCommand()), "-v", consoleHack); + consoleHack.GetBuffer().Trim().Should().Be($"***{Constants.version}***"); + } + } +} diff --git a/src/System.CommandLine.Subsystems/CommandCondition.cs b/src/System.CommandLine.Subsystems/CommandCondition.cs new file mode 100644 index 0000000000..6c6cf72009 --- /dev/null +++ b/src/System.CommandLine.Subsystems/CommandCondition.cs @@ -0,0 +1,10 @@ +// 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; + +public abstract class CommandCondition(string name) +{ + public virtual bool MustHaveValidator { get; } = true; + public string Name { get; } = name; +} diff --git a/src/System.CommandLine.Subsystems/CompletionSubsystem.cs b/src/System.CommandLine.Subsystems/CompletionSubsystem.cs new file mode 100644 index 0000000000..8fd0156ba0 --- /dev/null +++ b/src/System.CommandLine.Subsystems/CompletionSubsystem.cs @@ -0,0 +1,35 @@ +// 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.CommandLine.Subsystems; +using System.CommandLine.Subsystems.Annotations; + +namespace System.CommandLine; + +// Notes from Chet's work on static shells and further thoughts +// - Chet has work he can later upstream for completion script creation - static when it can be +// - Completions need to know - what it is, whether it is static or dynamic, and how many items would be in the list. +// - Not sure whether these need to be in the Completer or the trait +// - Validation can have many validators per type. Completions may need to have a single one. +// - Probably two steps - determining the available values and matching the current word +// - The code in CompletionContext of main/Extended to get current word requires tokens and is pretty gnarly +// - File and directory are special as they can get handed off to shell ro the work + +public class CompletionSubsystem : CliSubsystem +{ + public CompletionSubsystem(IAnnotationProvider? annotationProvider = null) + : base(CompletionAnnotations.Prefix, SubsystemKind.Completion, annotationProvider) + { } + + // TODO: Figure out trigger for completions + protected internal override bool GetIsActivated(ParseResult? parseResult) + => parseResult is null + ? false + : false; + + public override void Execute(PipelineResult pipelineResult) + { + pipelineResult.ConsoleHack.WriteLine("Not yet implemented"); + pipelineResult.SetSuccess(); + } +} diff --git a/src/System.CommandLine.Subsystems/ConsoleHack.cs b/src/System.CommandLine.Subsystems/ConsoleHack.cs new file mode 100644 index 0000000000..02f8a6bb2b --- /dev/null +++ b/src/System.CommandLine.Subsystems/ConsoleHack.cs @@ -0,0 +1,34 @@ +// 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.Text; + +namespace System.CommandLine; + +public class ConsoleHack +{ + private readonly StringBuilder buffer = new(); + private bool redirecting = false; + + public void WriteLine(string text = "") + { + if (redirecting) + { + buffer.AppendLine(text); + } + else + { + Console.WriteLine(text); + } + } + + public string GetBuffer() => buffer.ToString(); + + public void ClearBuffer() => buffer.Clear(); + + public ConsoleHack RedirectToBuffer(bool shouldRedirect) + { + redirecting = shouldRedirect; + return this; + } +} diff --git a/src/System.CommandLine/ConsoleHelpers.cs b/src/System.CommandLine.Subsystems/ConsoleHelpers.cs similarity index 97% rename from src/System.CommandLine/ConsoleHelpers.cs rename to src/System.CommandLine.Subsystems/ConsoleHelpers.cs index 2684413c89..8470bb5f0f 100644 --- a/src/System.CommandLine/ConsoleHelpers.cs +++ b/src/System.CommandLine.Subsystems/ConsoleHelpers.cs @@ -1,8 +1,6 @@ // 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.Runtime.InteropServices; - namespace System.CommandLine { internal static class ConsoleHelpers diff --git a/src/System.CommandLine.Subsystems/Directives/DiagramSubsystem.cs b/src/System.CommandLine.Subsystems/Directives/DiagramSubsystem.cs new file mode 100644 index 0000000000..791290a097 --- /dev/null +++ b/src/System.CommandLine.Subsystems/Directives/DiagramSubsystem.cs @@ -0,0 +1,180 @@ +// 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.CommandLine.Subsystems; +using System.Text; + +namespace System.CommandLine.Directives; + +public class DiagramSubsystem(IAnnotationProvider? annotationProvider = null) + : DirectiveSubsystem("diagram", SubsystemKind.Diagram, annotationProvider) +{ + //protected internal override bool GetIsActivated(ParseResult? parseResult) + // => parseResult is not null && option is not null && parseResult.GetValue(option); + + public override void Execute(PipelineResult pipelineResult) + { + // Gather locations + //var locations = pipelineResult.ParseResult.LocationMap + // .Concat(Map(pipelineResult.ParseResult.Configuration.PreProcessedLocations)); + + pipelineResult.ConsoleHack.WriteLine("Output diagram"); + pipelineResult.SetSuccess(); + } + + + // TODO: Capture logic in previous diagramming, shown below + /// + /// Formats a string explaining a parse result. + /// + /// The parse result to be diagrammed. + /// A string containing a diagram of the parse result. + internal static StringBuilder Diagram(ParseResult parseResult) + { + var builder = new StringBuilder(100); + + + // TODO: Reinstate this when ready to implement + //Diagram(builder, parseResult.RootCommandResult, parseResult); + + // TODO: Unmatched tokens + /* + var unmatchedTokens = parseResult.UnmatchedTokens; + if (unmatchedTokens.Count > 0) + { + builder.Append(" ???-->"); + + for (var i = 0; i < unmatchedTokens.Count; i++) + { + var error = unmatchedTokens[i]; + builder.Append(' '); + builder.Append(error); + } + } + */ + + return builder; + } + + /* + private static void Diagram( + StringBuilder builder, + CliSymbolResultInternal symbolResult, + ParseResult parseResult) + { + if (parseResult.Errors.Any(e => e.SymbolResultInternal == symbolResult)) + { + builder.Append('!'); + } + */ + + /* Directives + switch (symbolResult) + { + case DirectiveResult { Directive: not DiagramDirective }: + break; + */ + + /* TODO: This logic is deeply tied to internal types/properties. These aren't things we probably want to expose like SymbolNode. See #2349 for alternatives + case ArgumentResult argumentResult: + { + var includeArgumentName = + argumentResult.Argument.FirstParent!.Symbol is CliCommand { HasArguments: true, Arguments.Count: > 1 }; + + if (includeArgumentName) + { + builder.Append("[ "); + builder.Append(argumentResult.Argument.Name); + builder.Append(' '); + } + + if (argumentResult.Argument.Arity.MaximumNumberOfValues > 0) + { + ArgumentConversionResult conversionResult = argumentResult.GetArgumentConversionResult(); + switch (conversionResult.Result) + { + case ArgumentConversionResultType.NoArgument: + break; + case ArgumentConversionResultType.Successful: + switch (conversionResult.Value) + { + case string s: + builder.Append($"<{s}>"); + break; + + case IEnumerable items: + builder.Append('<'); + builder.Append( + string.Join("> <", + items.Cast().ToArray())); + builder.Append('>'); + break; + + default: + builder.Append('<'); + builder.Append(conversionResult.Value); + builder.Append('>'); + break; + } + + break; + + default: // failures + builder.Append('<'); + builder.Append(string.Join("> <", symbolResult.Tokens.Select(t => t.Value))); + builder.Append('>'); + + break; + } + } + + if (includeArgumentName) + { + builder.Append(" ]"); + } + + break; + } + + default: + { + OptionResult? optionResult = symbolResult as OptionResult; + + if (optionResult is { Implicit: true }) + { + builder.Append('*'); + } + + builder.Append("[ "); + + if (optionResult is not null) + { + builder.Append(optionResult.IdentifierToken?.Value ?? optionResult.Option.Name); + } + else + { + builder.Append(((CliCommandResultInternal)symbolResult).IdentifierToken.Value); + } + + foreach (CliSymbolResultInternal child in symbolResult.SymbolResultTree.GetChildren(symbolResult)) + { + if (child is ArgumentResult arg && + (arg.Argument.ValueType == typeof(bool) || + arg.Argument.Arity.MaximumNumberOfValues == 0)) + { + continue; + } + + builder.Append(' '); + + Diagram(builder, child, parseResult); + } + + builder.Append(" ]"); + break; + } + } + } + } +*/ +} diff --git a/src/System.CommandLine.Subsystems/Directives/DirectiveSubsystem.cs b/src/System.CommandLine.Subsystems/Directives/DirectiveSubsystem.cs new file mode 100644 index 0000000000..efb51bceee --- /dev/null +++ b/src/System.CommandLine.Subsystems/Directives/DirectiveSubsystem.cs @@ -0,0 +1,58 @@ +// 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.CommandLine.Parsing; +using System.CommandLine.Subsystems; + +namespace System.CommandLine.Directives; + +public abstract class DirectiveSubsystem : CliSubsystem +{ + public string? Value { get; private set; } + public bool Found { get; private set; } + public string Id { get; } + public Location? Location { get; private set; } + + public DirectiveSubsystem(string name, SubsystemKind kind, IAnnotationProvider? annotationProvider = null, string? id = null) + : base(name, kind, annotationProvider: annotationProvider) + { + Id = id ?? name; + } + + protected internal override void Initialize(InitializationContext context) + { + for (int i = 0; i < context.Args.Count; i++) + { + var arg = context.Args[i]; + if (arg[0] == '[') // It looks like a directive, see if it is the one we want + { + var start = arg.IndexOf($"[{Id}"); + // Protect against matching substrings, such as "diagramX" matching "diagram" - but longer string may be valid for a different directive and we may still find the one we want + if (start >= 0) + { + var end = arg.IndexOf("]", start) + 1; + var nextChar = arg[start + Id.Length + 1]; + if (nextChar is ']' or ':') + { + Found = true; + if (nextChar == ':') + { + Value = arg[(start + Id.Length + 2)..(end - 1)]; + } + Location = new Location(arg.Substring(start, end - start), Location.User, i, null, start); + context.Configuration.AddPreprocessedLocation(Location); + break; + } + } + } + else if (i > 0) // First position might be ExeName, but directives are not legal after other tokens appear + { + break; + } + } + } + + protected internal override bool GetIsActivated(ParseResult? parseResult) + => Found; + +} diff --git a/src/System.CommandLine.Subsystems/Directives/ResponseSubsystem.cs b/src/System.CommandLine.Subsystems/Directives/ResponseSubsystem.cs new file mode 100644 index 0000000000..c626348e28 --- /dev/null +++ b/src/System.CommandLine.Subsystems/Directives/ResponseSubsystem.cs @@ -0,0 +1,106 @@ +// 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.CommandLine.Parsing; +using System.CommandLine.Subsystems; + +namespace System.CommandLine.Directives; + +public class ResponseSubsystem() + : CliSubsystem("Response", SubsystemKind.Response, null) +{ + public bool Enabled { get; set; } + + protected internal override void Initialize(InitializationContext context) + => context.Configuration.ResponseFileTokenReplacer = Replacer; + + public (List? tokens, List? errors) Replacer(string responseSourceName) + { + if (!Enabled) + { + return ([responseSourceName], null); + } + try + { + // TODO: Include checks from previous system. + var contents = File.ReadAllText(responseSourceName); + return (CliParser.SplitCommandLine(contents).ToList(), null); + } + catch + { + // TODO: Switch to proper errors + return (null, + errors: + [ + $"Failed to open response file {responseSourceName}" + ]); + } + } + + // TODO: File handling from previous system - ensure these checks are done (note: no tests caught these oversights + /* internal static bool TryReadResponseFile( + string filePath, + out IReadOnlyList? newTokens, + out string? error) + { + try + { + newTokens = ExpandResponseFile(filePath).ToArray(); + error = null; + return true; + } + catch (FileNotFoundException) + { + error = LocalizationResources.ResponseFileNotFound(filePath); + } + catch (IOException e) + { + error = LocalizationResources.ErrorReadingResponseFile(filePath, e); + } + + newTokens = null; + return false; + + static IEnumerable ExpandResponseFile(string filePath) + { + var lines = File.ReadAllLines(filePath); + + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i]; + + foreach (var p in SplitLine(line)) + { + if (GetReplaceableTokenValue(p) is { } path) + { + foreach (var q in ExpandResponseFile(path)) + { + yield return q; + } + } + else + { + yield return p; + } + } + } + } + + static IEnumerable SplitLine(string line) + { + var arg = line.Trim(); + + if (arg.Length == 0 || arg[0] == '#') + { + yield break; + } + + foreach (var word in CliParser.SplitCommandLine(arg)) + { + yield return word; + } + } + } + */ + +} \ No newline at end of file diff --git a/src/System.CommandLine.Subsystems/ErrorReportingSubsystem.cs b/src/System.CommandLine.Subsystems/ErrorReportingSubsystem.cs new file mode 100644 index 0000000000..7c7432616b --- /dev/null +++ b/src/System.CommandLine.Subsystems/ErrorReportingSubsystem.cs @@ -0,0 +1,49 @@ +// 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.CommandLine.Parsing; +using System.CommandLine.Subsystems; +using System.CommandLine.Subsystems.Annotations; + +namespace System.CommandLine; + +/// +/// Subsystem for reporting errors +/// +/// +/// This class, including interface, is likey to change as powderhouse continues +/// +public class ErrorReportingSubsystem : CliSubsystem +{ + public ErrorReportingSubsystem(IAnnotationProvider? annotationProvider = null) + : base(ErrorReportingAnnotations.Prefix, SubsystemKind.ErrorReporting, annotationProvider) + { } + + protected internal override bool GetIsActivated(ParseResult? parseResult) + => parseResult is not null && parseResult.Errors.Any(); + + // TODO: properly test execute directly when parse result is usable in tests + public override void Execute(PipelineResult pipelineResult) + { + var _ = pipelineResult.ParseResult + ?? throw new ArgumentException("The parse result has not been set", nameof(pipelineResult)); + + Report(pipelineResult.ConsoleHack, pipelineResult.ParseResult.Errors); + + pipelineResult.SetSuccess(); + } + + public void Report(ConsoleHack consoleHack, IReadOnlyList errors) + { + ConsoleHelpers.ResetTerminalForegroundColor(); + ConsoleHelpers.SetTerminalForegroundRed(); + + foreach (var error in errors) + { + consoleHack.WriteLine(error.Message); + } + consoleHack.WriteLine(); + + ConsoleHelpers.ResetTerminalForegroundColor(); + } +} diff --git a/src/System.CommandLine.Subsystems/HelpAnnotationExtensions.cs b/src/System.CommandLine.Subsystems/HelpAnnotationExtensions.cs new file mode 100644 index 0000000000..8cb06c594d --- /dev/null +++ b/src/System.CommandLine.Subsystems/HelpAnnotationExtensions.cs @@ -0,0 +1,50 @@ +// 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.CommandLine.Subsystems; +using System.CommandLine.Subsystems.Annotations; + +namespace System.CommandLine; + +public static class HelpAnnotationExtensions +{ + /// + /// Sets the help description on the + /// + /// The type of the symbol + /// The symbol + /// The help description for the symbol + /// The , to enable fluent construction of symbols with annotations. + public static TSymbol WithDescription (this TSymbol symbol, string description) where TSymbol : CliSymbol + { + symbol.SetDescription(description); + return symbol; + } + + + /// + /// Sets the help description on the + /// + /// The type of the symbol + /// The symbol + /// The help description for the symbol + public static void SetDescription(this TSymbol symbol, string description) where TSymbol : CliSymbol + { + symbol.SetAnnotation(HelpAnnotations.Description, description); + } + + /// + /// Get the help description on the + /// + /// The type of the symbol + /// The symbol + /// The symbol description if any, otherwise + /// + /// This is intended to be called by CLI authors. Subsystems should instead call , + /// values from the subsystem's . + /// + public static string? GetDescription(this TSymbol symbol) where TSymbol : CliSymbol + { + return symbol.GetAnnotationOrDefault(HelpAnnotations.Description); + } +} diff --git a/src/System.CommandLine.Subsystems/HelpSubsystem.cs b/src/System.CommandLine.Subsystems/HelpSubsystem.cs new file mode 100644 index 0000000000..ca4d99c2ae --- /dev/null +++ b/src/System.CommandLine.Subsystems/HelpSubsystem.cs @@ -0,0 +1,50 @@ +// 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.CommandLine.Subsystems.Annotations; +using System.CommandLine.Subsystems; + +namespace System.CommandLine; + +// stub Help subsystem demonstrating annotation model. +// +// usage: +// +// +// var help = new HelpSubsystem(); +// var command = new CliCommand("greet") +// .With(help.Description, "Greet the user"); +// +public class HelpSubsystem(IAnnotationProvider? annotationProvider = null) + : CliSubsystem(HelpAnnotations.Prefix, SubsystemKind.Help, annotationProvider) +{ + /// + /// Gets the help option, which allows the user to customize + /// + /// + /// By design, the user can modify the help option but not replace it. This allows us to + /// do the fastest possible lookup of whether help was called, and we aren't clear why + /// the option would need to be replaced + /// + public CliOption HelpOption { get; } = new CliOption("--help", ["-h"]) + { + // TODO: Why don't we accept bool like any other bool option? + Arity = ArgumentArity.Zero + }; + + protected internal override void Initialize(InitializationContext context) + => context.Configuration.RootCommand.Add(HelpOption); + + protected internal override bool GetIsActivated(ParseResult? parseResult) + => parseResult is not null && parseResult.GetValue(HelpOption); + + public override void Execute(PipelineResult pipelineResult) + { + // TODO: Match testable output pattern + pipelineResult.ConsoleHack.WriteLine("Help me!"); + pipelineResult.SetSuccess(); + } + + public bool TryGetDescription(CliSymbol symbol, out string? description) + => TryGetAnnotation(symbol, HelpAnnotations.Description, out description); +} diff --git a/src/System.CommandLine.Subsystems/InvocationSubsystem.cs b/src/System.CommandLine.Subsystems/InvocationSubsystem.cs new file mode 100644 index 0000000000..a451065831 --- /dev/null +++ b/src/System.CommandLine.Subsystems/InvocationSubsystem.cs @@ -0,0 +1,11 @@ +// 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.CommandLine.Subsystems; +using System.CommandLine.Subsystems.Annotations; + +namespace System.CommandLine; + +public class InvocationSubsystem(IAnnotationProvider? annotationProvider = null) + : CliSubsystem(InvocationAnnotations.Prefix, SubsystemKind.Invocation, annotationProvider) +{} diff --git a/src/System.CommandLine.Subsystems/Pipeline.cs b/src/System.CommandLine.Subsystems/Pipeline.cs new file mode 100644 index 0000000000..837fdea398 --- /dev/null +++ b/src/System.CommandLine.Subsystems/Pipeline.cs @@ -0,0 +1,265 @@ +// 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.CommandLine.Directives; +using System.CommandLine.Parsing; +using System.CommandLine.Subsystems; + +namespace System.CommandLine; + +public partial class Pipeline +{ + private readonly PipelinePhase diagramPhase = new(SubsystemKind.Diagram); + private readonly PipelinePhase completionPhase = new(SubsystemKind.Completion); + private readonly PipelinePhase helpPhase = new(SubsystemKind.Help); + private readonly PipelinePhase versionPhase = new(SubsystemKind.Version); + private readonly PipelinePhase validationPhase = new(SubsystemKind.Validation); + private readonly PipelinePhase invocationPhase = new(SubsystemKind.Invocation); + private readonly PipelinePhase errorReportingPhase = new(SubsystemKind.ErrorReporting); + private readonly IEnumerable phases; + + /// + /// Creates an instance of the pipeline using standard features. + /// + /// A help subsystem to replace the standard one. To add a subsystem, use + /// A help subsystem to replace the standard one. To add a subsystem, use + /// A help subsystem to replace the standard one. To add a subsystem, use + /// A help subsystem to replace the standard one. To add a subsystem, use + /// A help subsystem to replace the standard one. To add a subsystem, use + /// A new pipeline. + /// + /// The , , and cannot be replaced. + /// + public static Pipeline Create(HelpSubsystem? help = null, + VersionSubsystem? version = null, + CompletionSubsystem? completion = null, + DiagramSubsystem? diagram = null, + ErrorReportingSubsystem? errorReporting = null) + => new() + { + Help = help ?? new HelpSubsystem(), + Version = version ?? new VersionSubsystem(), + Completion = completion ?? new CompletionSubsystem(), + Diagram = diagram ?? new DiagramSubsystem(), + ErrorReporting = errorReporting ?? new ErrorReportingSubsystem(), + }; + + /// + /// Creates an instance of the pipeline with no features. Use this if you want to explicitly add features. + /// + /// A new pipeline. + /// + /// The ValueSubsystem and is always added and cannot be changed. + /// + public static Pipeline CreateEmpty() + => new(); + + private Pipeline() + { + Response = new ResponseSubsystem(); + Invocation = new InvocationSubsystem(); + Validation = ValidationSubsystem.Create(); + + // This order is based on: if the user entered both, which should they get? + // * It is reasonable to diagram help and completion. More reasonable than getting help on Diagram or Completion + // * A future version of Help and Version may take arguments/options. In that case, help on version is reasonable. + phases = + [ + diagramPhase, completionPhase, helpPhase, versionPhase, + validationPhase, invocationPhase, errorReportingPhase + ]; + } + + /// + /// Enables response files. They are disabled by default. + /// + public bool ResponseFilesEnabled + { + get => Response.Enabled; + set => Response.Enabled = value; + } + + /// + /// Adds a subsystem. + /// + /// The subsystem to add. + /// indicates that the subsystem should run before all other subsystems in the phase, and indicates it should run after other subsystems. The default is . + /// + /// + /// The phase in which the subsystem runs is determined by the subsystem's 'Kind' property. + ///
+ /// To replace one of the standard subsystems, use the `Pipeline.(subsystem)` property, such as `myPipeline.Help = new OtherHelp();` + ///
+ public void AddSubsystem(CliSubsystem subsystem, AddToPhaseBehavior timing = AddToPhaseBehavior.SubsystemRecommendation) + { + switch (subsystem.Kind) + { + case SubsystemKind.Other: + case SubsystemKind.Response: + case SubsystemKind.Value: + throw new InvalidOperationException($"You cannot add subsystems to {subsystem.Kind}"); + case SubsystemKind.Diagram: + diagramPhase.AddSubsystem(subsystem, timing); + break; + case SubsystemKind.Completion: + completionPhase.AddSubsystem(subsystem, timing); + break; + case SubsystemKind.Help: + helpPhase.AddSubsystem(subsystem, timing); + break; + case SubsystemKind.Version: + versionPhase.AddSubsystem(subsystem, timing); + break; + // You can add Validation and Invocation subsystems, but you can't remove the core. + // Other things may need to be run in the phase. + case SubsystemKind.Validation: + validationPhase.AddSubsystem(subsystem, timing); + break; + case SubsystemKind.Invocation: + invocationPhase.AddSubsystem(subsystem, timing); + break; + case SubsystemKind.ErrorReporting: + errorReportingPhase.AddSubsystem(subsystem, timing); + break; + } + } + + /// + /// Sets or gets the diagramming subsystem. + /// + public DiagramSubsystem? Diagram + { + get => diagramPhase.Subsystem; + set => diagramPhase.Subsystem = value; + } + + /// + /// Sets or gets the completion subsystem. + /// + public CompletionSubsystem? Completion + { + get => completionPhase.Subsystem; + set => completionPhase.Subsystem = value; + } + + /// + /// Sets or gets the help subsystem. + /// + public HelpSubsystem? Help + { + get => helpPhase.Subsystem; + set => helpPhase.Subsystem = value; + } + + /// + /// Sets or gets the version subsystem. + /// + public VersionSubsystem? Version + { + get => versionPhase.Subsystem; + set => versionPhase.Subsystem = value; + } + + /// + /// Sets or gets the error reporting subsystem. + /// + public ErrorReportingSubsystem? ErrorReporting + { + get => errorReportingPhase.Subsystem; + set => errorReportingPhase.Subsystem = value; + } + + // TODO: Consider whether replacing the validation subsystem is valuable + /// + /// Gets the validation subsystem + /// + public ValidationSubsystem? Validation { get; } + + // TODO: Consider whether replacing the invocation subsystem is valuable + /// + /// Gets the invocation subsystem + /// + public InvocationSubsystem? Invocation { get; } + + /// + /// Gets the response file subsystem + /// + public ResponseSubsystem Response { get; } + + public ParseResult Parse(CliConfiguration configuration, string rawInput) + => Parse(configuration, CliParser.SplitCommandLine(rawInput).ToArray()); + + public ParseResult Parse(CliConfiguration configuration, IReadOnlyList args) + { + InitializeSubsystems(new InitializationContext(configuration, args)); + var parseResult = CliParser.Parse(configuration.RootCommand, args, configuration); + return parseResult; + } + + public PipelineResult Execute(CliConfiguration configuration, string rawInput, ConsoleHack? consoleHack = null) + => Execute(configuration, CliParser.SplitCommandLine(rawInput).ToArray(), rawInput, consoleHack); + + public PipelineResult Execute(CliConfiguration configuration, string[] args, string rawInput, ConsoleHack? consoleHack = null) + => Execute(Parse(configuration, args), rawInput, consoleHack); + + public PipelineResult Execute(ParseResult parseResult, string rawInput, ConsoleHack? consoleHack = null) + { + var pipelineResult = new PipelineResult(parseResult, rawInput, this, consoleHack ?? new ConsoleHack()); + foreach (var phase in phases) + { + // TODO: Allow subsystems to control short-circuiting + foreach (var subsystem in phase.GetSubsystems()) + { + // TODO: RunEvenIfAlreadyHandled needs more thought and laying out the scenarios + if (subsystem is not null && (!pipelineResult.AlreadyHandled || subsystem.RunsEvenIfAlreadyHandled)) + { + subsystem.ExecuteIfNeeded(pipelineResult); + } + } + } + TearDownSubsystems(pipelineResult); + return pipelineResult; + } + + // TODO: Consider whether this should be public. It would simplify testing, but would it do anything else + // TODO: Confirm that it is OK for ConsoleHack to be unavailable in Initialize + /// + /// Perform any setup for the subsystem. This may include adding to the CLI definition, + /// such as adding a help option. It is important that work only needed when the subsystem + /// + /// + /// + /// + /// Note to inheritors: The ordering of initializing should normally be in the reverse order than tear down + /// + protected virtual void InitializeSubsystems(InitializationContext context) + { + foreach (var phase in phases) + { + // TODO: Allow subsystems to control short-circuiting? Not sure we need that for initialization + foreach (var subsystem in phase.GetSubsystems()) + { + subsystem.Initialize(context); + } + } + } + + // TODO: Consider whether this should be public + // TODO: Would Dispose be a better alternative? This may be non-dispose like things, such as removing options? + /// + /// Perform any cleanup operations + /// + /// The context of the current execution + protected virtual void TearDownSubsystems(PipelineResult pipelineResult) + { + // TODO: Work on this design as the last pipelineResult wins and they may not all be well behaved + foreach (var phase in phases) + { + // TODO: Allow subsystems to control short-circuiting? Not sure we need that for teardown + foreach (var subsystem in phase.GetSubsystems()) + { + subsystem.TearDown(pipelineResult); + } + } + } +} diff --git a/src/System.CommandLine.Subsystems/PipelineResult.cs b/src/System.CommandLine.Subsystems/PipelineResult.cs new file mode 100644 index 0000000000..55b5f4963e --- /dev/null +++ b/src/System.CommandLine.Subsystems/PipelineResult.cs @@ -0,0 +1,69 @@ +// 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.CommandLine.Parsing; + +namespace System.CommandLine; + +public class PipelineResult +{ + // TODO: Try to build workflow so it is illegal to create this without a ParseResult + private readonly List errors = []; + private ValueProvider valueProvider { get; } + + public PipelineResult(ParseResult parseResult, string rawInput, Pipeline? pipeline, ConsoleHack? consoleHack = null) + { + ParseResult = parseResult; + RawInput = rawInput; + Pipeline = pipeline ?? Pipeline.CreateEmpty(); + ConsoleHack = consoleHack ?? new ConsoleHack(); + valueProvider = new ValueProvider(this); + } + + public ParseResult ParseResult { get; } + public string RawInput { get; } + + // TODO: Consider behavior when pipeline is null - this is probably a core user accessing some subsystems + public Pipeline Pipeline { get; } + public ConsoleHack ConsoleHack { get; } + + public bool AlreadyHandled { get; set; } + public int ExitCode { get; set; } + + public T? GetValue(CliValueSymbol valueSymbol) + => valueProvider.GetValue(valueSymbol); + + public object? GetValue(CliValueSymbol option) + => valueProvider.GetValue(option); + + public CliValueResult? GetValueResult(CliValueSymbol valueSymbol) + => ParseResult.GetValueResult(valueSymbol); + + + public void AddErrors(IEnumerable errors) + { + if (errors is not null) + { + this.errors.AddRange(errors); + } + } + + public void AddError(ParseError error) + => errors.Add(error); + + public IEnumerable GetErrors(bool excludeParseErrors = false) + => excludeParseErrors || ParseResult is null + ? errors + : ParseResult.Errors.Concat(errors); + + public void NotRun(ParseResult? parseResult) + { + // no op because defaults are false and 0 + } + + public void SetSuccess() + { + AlreadyHandled = true; + ExitCode = 0; + } +} diff --git a/src/System.CommandLine.Subsystems/Subsystems/AddToPhaseBehavior.cs b/src/System.CommandLine.Subsystems/Subsystems/AddToPhaseBehavior.cs new file mode 100644 index 0000000000..45d8866fed --- /dev/null +++ b/src/System.CommandLine.Subsystems/Subsystems/AddToPhaseBehavior.cs @@ -0,0 +1,11 @@ +// 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.Subsystems; + +public enum AddToPhaseBehavior +{ + SubsystemRecommendation = 0, + Prepend, + Append +} diff --git a/src/System.CommandLine.Subsystems/Subsystems/Annotations/AnnotationId.cs b/src/System.CommandLine.Subsystems/Subsystems/Annotations/AnnotationId.cs new file mode 100644 index 0000000000..bda76cf6cb --- /dev/null +++ b/src/System.CommandLine.Subsystems/Subsystems/Annotations/AnnotationId.cs @@ -0,0 +1,14 @@ +// 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.Subsystems.Annotations; + +/// +/// Describes the ID and type of an annotation. +/// +public record struct AnnotationId(string Prefix, string Id) +{ + public override readonly string ToString() => $"{Prefix}.{Id}"; +} + + diff --git a/src/System.CommandLine.Subsystems/Subsystems/Annotations/AnnotationStorageExtensions.AnnotationStorage.cs b/src/System.CommandLine.Subsystems/Subsystems/Annotations/AnnotationStorageExtensions.AnnotationStorage.cs new file mode 100644 index 0000000000..58341d3bf1 --- /dev/null +++ b/src/System.CommandLine.Subsystems/Subsystems/Annotations/AnnotationStorageExtensions.AnnotationStorage.cs @@ -0,0 +1,36 @@ +// 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.Diagnostics.CodeAnalysis; + +namespace System.CommandLine.Subsystems.Annotations; + +partial class AnnotationStorageExtensions +{ + class AnnotationStorage : IAnnotationProvider + { + record struct AnnotationKey(CliSymbol symbol, string prefix, string id) + { + public static AnnotationKey Create (CliSymbol symbol, AnnotationId annotationId) + => new (symbol, annotationId.Prefix, annotationId.Id); + } + + readonly Dictionary annotations = []; + + public bool TryGet(CliSymbol symbol, AnnotationId annotationId, [NotNullWhen(true)] out object? value) + => annotations.TryGetValue(AnnotationKey.Create(symbol, annotationId), out value); + + public void Set(CliSymbol symbol, AnnotationId annotationId, object? value) + { + var key = AnnotationKey.Create(symbol, annotationId); + if (value is not null) + { + annotations[key] = value; + } + else + { + annotations.Remove(key); + } + } + } +} diff --git a/src/System.CommandLine.Subsystems/Subsystems/Annotations/AnnotationStorageExtensions.cs b/src/System.CommandLine.Subsystems/Subsystems/Annotations/AnnotationStorageExtensions.cs new file mode 100644 index 0000000000..5a8a4be569 --- /dev/null +++ b/src/System.CommandLine.Subsystems/Subsystems/Annotations/AnnotationStorageExtensions.cs @@ -0,0 +1,188 @@ +// 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.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace System.CommandLine.Subsystems.Annotations; + +/// +/// Handles storage of annotations associated with instances. +/// +public static partial class AnnotationStorageExtensions +{ + // CliSymbol does not offer any PropertyBag-like storage of arbitrary annotations, so the only way to allow setting + // subsystem-specific annotations on CliSymbol instances (such as help description, default value, etc) via simple + // extension methods is to use a static field with a dictionary that associates annotations with CliSymbol instances. + // + // Using ConditionalWeakTable for this dictionary ensures that the symbols and annotations can be collected when the + // symbols are no longer reachable. Although this is unlikely to happen in a CLI app, it is important not to create + // unexpected, unfixable, unbounded memory leaks in apps that construct multiple grammars/pipelines. + // + // The main use case for System.CommandLine is for a CLI app to construct a single annotated grammar in its entry point, + // construct a pipeline using that grammar, and use the pipeline/grammar only once to parse its arguments. However, it + // is important to have well defined and reasonable threading behavior so that System.CommandLine does not behave in + // surprising ways when used in more advanced cases: + // + // * There may be multiple threads constructing and using completely independent grammars/pipelines. This happens in + // our own unit tests, but might happen e.g. in a multithreaded data processing app or web service that uses + // System.CommandLine to process inputs. + // + // * The grammar/pipeline are reentrant; they do not store they do not store internal state, and may be used to parse + // input multiple times. As this is the case, it is reasonable to expect a grammar/pipeline instance to be + // constructed in one thread then used in multiple threads. This might be done by the aforementioned web service or + // data processing app. + // + // The thread-safe behavior of ConditionalWeakTable ensures this works as expected without us having to worry about + // taking locks directly, even though the instance is on a static field and shared between all threads. Note that + // thread local storage is not useful for this, as that would create unexpected behaviors where a grammar constructed + // in one thread would be missing its annotations when used in another thread. + // + // However, while getting values from ConditionalWeakTable is lock free, setting values internally uses an expensive + // lock, so it is not ideal to store all individual annotations directly in the ConditionalWeakTable. This is especially + // true as we do not want the common case of the CLI app entrypoint to have its performance impacted by multithreading + // support more than absolutely necessary. + // + // Instead, we have a single static ConditionalWeakTable that maps each CliSymbol to an AnnotationStorage dictionary, + // which is lazily created and added to the ConditionalWeakTable a single time for each CliSymbol. The individual + // annotations are stored in the AnnotationStorage dictionary, which uses no locks, so is fast, but is not safe to be + // modified from multiple threads. + // + // This is fine, as we will have the following well-defined threading behavior: an annotated grammar and pipeline may + // only be constructed/modified from a single thread. Once the grammar/pipeline instance is fully constructed, it may + // be safely used from multiple threads, but is not safe to further modify once in use. + + static readonly ConditionalWeakTable symbolToAnnotationStorage = new(); + + /// + /// Sets the value for the annotation associated with the in the internal annotation storage. + /// + /// The symbol that is annotated + /// + /// The identifier for the annotation. For example, the annotation identifier for the help description is . + /// + /// The annotation value + public static void SetAnnotation(this CliSymbol symbol, AnnotationId annotationId, object? value) + { + var storage = symbolToAnnotationStorage.GetValue(symbol, static (CliSymbol _) => new AnnotationStorage()); + storage.Set(symbol, annotationId, value); + } + + /// + /// Sets the value for the annotation associated with the in the internal annotation storage, + /// and returns the to enable fluent construction of symbols with annotations. + /// + /// The symbol that is annotated + /// + /// The identifier for the annotation. For example, the annotation identifier for the help description is . + /// + /// The annotation value + /// + /// The , to enable fluent construction of symbols with annotations. + /// + public static TSymbol WithAnnotation(this TSymbol symbol, AnnotationId annotationId, object? value) where TSymbol : CliSymbol + { + symbol.SetAnnotation(annotationId, value); + return symbol; + } + + /// + /// Attempts to get the value for the annotation associated with the in the internal annotation + /// storage used to store values set via . + /// + /// + /// The expected type of the annotation value. If the type does not match, a will be thrown. + /// If the annotation allows multiple types for its values, and a type parameter cannot be determined statically, + /// use to access the annotation value without checking its type. + /// + /// The symbol that is annotated + /// + /// The identifier for the annotation. For example, the annotation identifier for the help description is . + /// + /// The annotation value, if successful, otherwise default + /// True if successful + /// + /// If the annotation value does not have a single expected type for this symbol, use the overload instead. + /// + /// This is intended to be called by the implementation of specialized ID-specific accessors for CLI authors such as . + /// + /// + /// Subsystems should not call it directly, as it does not account for values from the subsystem's . They should instead call + /// or an ID-specific accessor on the subsystem such + /// . + /// + /// + public static bool TryGetAnnotation(this CliSymbol symbol, AnnotationId annotationId, [NotNullWhen(true)] out TValue? value) + { + if (TryGetAnnotation(symbol, annotationId, out object? rawValue)) + { + if (rawValue is TValue expectedTypeValue) + { + value = expectedTypeValue; + return true; + } + throw new AnnotationTypeException(annotationId, typeof(TValue), rawValue?.GetType()); + } + + value = default; + return false; + } + + /// + /// Attempts to get the value for the annotation associated with the in the internal annotation + /// storage used to store values set via . + /// + /// The symbol that is annotated + /// + /// The identifier for the annotation. For example, the annotation identifier for the help description is . + /// + /// The annotation value, if successful, otherwise default + /// True if successful + /// + /// If the expected type of the annotation value is known, use the overload instead. + /// + /// This is intended to be called by the implementation of specialized ID-specific accessors for CLI authors such as . + /// + /// + /// Subsystems should not call it directly, as it does not account for values from the subsystem's . They should instead call + /// or an ID-specific accessor on the subsystem such + /// . + /// + /// + public static bool TryGetAnnotation(this CliSymbol symbol, AnnotationId annotationId, [NotNullWhen(true)] out object? value) + { + if (symbolToAnnotationStorage.TryGetValue(symbol, out var storage) && storage.TryGet(symbol, annotationId, out value)) + { + return true; + } + + value = default; + return false; + } + + /// + /// Attempts to get the value for the annotation associated with the in the internal annotation + /// storage used to store values set via . + /// + /// The type of the annotation value + /// The symbol that is annotated + /// + /// The identifier for the annotation. For example, the annotation identifier for the help description is . + /// + /// The annotation value, if successful, otherwise default + /// + /// This is intended to be called by specialized ID-specific accessors for CLI authors such as . + /// Subsystems should not call it, as it does not account for values from the subsystem's . They should instead call + /// or an ID-specific accessor on the subsystem such + /// . + /// + public static TValue? GetAnnotationOrDefault(this CliSymbol symbol, AnnotationId annotationId) + { + if (symbol.TryGetAnnotation(annotationId, out TValue? value)) + { + return value; + } + + return default; + } +} diff --git a/src/System.CommandLine.Subsystems/Subsystems/Annotations/AnnotationTypeException.cs b/src/System.CommandLine.Subsystems/Subsystems/Annotations/AnnotationTypeException.cs new file mode 100644 index 0000000000..4d1a19aded --- /dev/null +++ b/src/System.CommandLine.Subsystems/Subsystems/Annotations/AnnotationTypeException.cs @@ -0,0 +1,33 @@ +// 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.Subsystems.Annotations; + +/// +/// Thrown when an annotation value does not match the expected type for that . +/// +public class AnnotationTypeException(AnnotationId annotationId, Type expectedType, Type? actualType, IAnnotationProvider? provider = null) : Exception +{ + public AnnotationId AnnotationId { get; } = annotationId; + public Type ExpectedType { get; } = expectedType; + public Type? ActualType { get; } = actualType; + public IAnnotationProvider? Provider = provider; + + public override string Message + { + get + { + if (Provider is not null) + { + return + $"Typed accessor for annotation '${AnnotationId}' expected type '{ExpectedType}' but the annotation provider returned an annotation of type '{ActualType?.ToString() ?? "[null]"}'. " + + $"This may be an authoring error in in the annotation provider '{Provider.GetType()}' or in a typed annotation accessor."; + + } + + return + $"Typed accessor for annotation '${AnnotationId}' expected type '{ExpectedType}' but the stored annotation is of type '{ActualType?.ToString() ?? "[null]"}'. " + + $"This may be an authoring error in a typed annotation accessor, or the annotation may have be stored directly with the incorrect type, bypassing the typed accessors."; + } + } +} \ No newline at end of file diff --git a/src/System.CommandLine.Subsystems/Subsystems/Annotations/CompletionAnnotations.cs b/src/System.CommandLine.Subsystems/Subsystems/Annotations/CompletionAnnotations.cs new file mode 100644 index 0000000000..7619d6687f --- /dev/null +++ b/src/System.CommandLine.Subsystems/Subsystems/Annotations/CompletionAnnotations.cs @@ -0,0 +1,12 @@ +// 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.Subsystems.Annotations; + +/// +/// IDs for well-known Completion annotations. +/// +public static class CompletionAnnotations +{ + public static string Prefix { get; } = nameof(SubsystemKind.Completion); +} diff --git a/src/System.CommandLine.Subsystems/Subsystems/Annotations/DiagramAnnotations.cs b/src/System.CommandLine.Subsystems/Subsystems/Annotations/DiagramAnnotations.cs new file mode 100644 index 0000000000..dc4ea181fc --- /dev/null +++ b/src/System.CommandLine.Subsystems/Subsystems/Annotations/DiagramAnnotations.cs @@ -0,0 +1,13 @@ +// 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.Subsystems.Annotations; + +/// +/// IDs for well-known diagram annotations. +/// +public static class DiagramAnnotations +{ + public static string Prefix { get; } = nameof(SubsystemKind.Diagram); + +} diff --git a/src/System.CommandLine.Subsystems/Subsystems/Annotations/ErrorReportingAnnotations.cs b/src/System.CommandLine.Subsystems/Subsystems/Annotations/ErrorReportingAnnotations.cs new file mode 100644 index 0000000000..bb5dd594b2 --- /dev/null +++ b/src/System.CommandLine.Subsystems/Subsystems/Annotations/ErrorReportingAnnotations.cs @@ -0,0 +1,12 @@ +// 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.Subsystems.Annotations; + +/// +/// IDs for well-known ErrorReporting annotations. +/// +public static class ErrorReportingAnnotations +{ + public static string Prefix { get; } = nameof(SubsystemKind.ErrorReporting); +} diff --git a/src/System.CommandLine.Subsystems/Subsystems/Annotations/HelpAnnotations.cs b/src/System.CommandLine.Subsystems/Subsystems/Annotations/HelpAnnotations.cs new file mode 100644 index 0000000000..fb0ced77a7 --- /dev/null +++ b/src/System.CommandLine.Subsystems/Subsystems/Annotations/HelpAnnotations.cs @@ -0,0 +1,17 @@ +// 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.Subsystems.Annotations; + +/// +/// IDs for well-known help annotations. +/// +public static class HelpAnnotations +{ + public static string Prefix { get; } = nameof(SubsystemKind.Help); + + /// + /// The description of the symbol, as a plain text . + /// + public static AnnotationId Description { get; } = new(Prefix, nameof(Description)); +} diff --git a/src/System.CommandLine.Subsystems/Subsystems/Annotations/InvocationAnnotations.cs b/src/System.CommandLine.Subsystems/Subsystems/Annotations/InvocationAnnotations.cs new file mode 100644 index 0000000000..76756c3a46 --- /dev/null +++ b/src/System.CommandLine.Subsystems/Subsystems/Annotations/InvocationAnnotations.cs @@ -0,0 +1,13 @@ +// 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.Subsystems.Annotations; + +/// +/// IDs for well-known Version annotations. +/// +public static class InvocationAnnotations +{ + internal static string Prefix { get; } = nameof(SubsystemKind.Invocation); + +} diff --git a/src/System.CommandLine.Subsystems/Subsystems/Annotations/ValueAnnotations.cs b/src/System.CommandLine.Subsystems/Subsystems/Annotations/ValueAnnotations.cs new file mode 100644 index 0000000000..7695c363e0 --- /dev/null +++ b/src/System.CommandLine.Subsystems/Subsystems/Annotations/ValueAnnotations.cs @@ -0,0 +1,39 @@ +// 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.Subsystems.Annotations; + +/// +/// IDs for well-known Default Value annotations. +/// +public static class ValueAnnotations +{ + internal static string Prefix { get; } = nameof(SubsystemKind.Value); + + /// + /// Default value source, which may be an aggregate source, for an option or argument + /// + public static AnnotationId DefaultValueSource { get; } = new(Prefix, nameof(DefaultValueSource)); + + /// + /// Default default value for an option or argument + /// + /// + /// Should be the same type as the type parameter of + /// the or . + /// + public static AnnotationId DefaultValue { get; } = new(Prefix, nameof(DefaultValue)); + + + /// + /// Default default value calculation for an option or argument + /// + /// + /// Please use the extension methods and do not call this directly. + /// + /// Should use a with the same type parameter as + /// the or . + /// + /// + public static AnnotationId DefaultValueCalculation { get; } = new(Prefix, nameof(DefaultValueCalculation)); +} diff --git a/src/System.CommandLine.Subsystems/Subsystems/Annotations/ValueConditionAnnotations.cs b/src/System.CommandLine.Subsystems/Subsystems/Annotations/ValueConditionAnnotations.cs new file mode 100644 index 0000000000..574c111d2a --- /dev/null +++ b/src/System.CommandLine.Subsystems/Subsystems/Annotations/ValueConditionAnnotations.cs @@ -0,0 +1,17 @@ +// 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.Subsystems.Annotations; + +/// +/// IDs for well-known Value Condition annotations. +/// +public static class ValueConditionAnnotations +{ + internal static string Prefix { get; } = "General"; + + /// + /// Value conditions for a symbol + /// + public static AnnotationId ValueConditions { get; } = new(Prefix, nameof(ValueConditions)); +} diff --git a/src/System.CommandLine.Subsystems/Subsystems/Annotations/VersionAnnotations.cs b/src/System.CommandLine.Subsystems/Subsystems/Annotations/VersionAnnotations.cs new file mode 100644 index 0000000000..3672894c24 --- /dev/null +++ b/src/System.CommandLine.Subsystems/Subsystems/Annotations/VersionAnnotations.cs @@ -0,0 +1,12 @@ +// 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.Subsystems.Annotations; + +/// +/// IDs for well-known Version annotations. +/// +public static class VersionAnnotations +{ + public static string Prefix { get; } = nameof(SubsystemKind.Version); +} diff --git a/src/System.CommandLine.Subsystems/Subsystems/CliSubsystem.cs b/src/System.CommandLine.Subsystems/Subsystems/CliSubsystem.cs new file mode 100644 index 0000000000..49e6f98af7 --- /dev/null +++ b/src/System.CommandLine.Subsystems/Subsystems/CliSubsystem.cs @@ -0,0 +1,159 @@ +// 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.CommandLine.Subsystems.Annotations; +using System.Diagnostics.CodeAnalysis; + +namespace System.CommandLine.Subsystems; + +/// +/// Base class for CLI subsystems. Implements storage of annotations. +/// +/// +public abstract class CliSubsystem +{ + protected CliSubsystem(string name, SubsystemKind subsystemKind, IAnnotationProvider? annotationProvider) + { + Name = name; + _annotationProvider = annotationProvider; + Kind = subsystemKind; + } + + /// + /// The name of the subsystem. + /// + public string Name { get; } + + /// + /// Defines the kind of subsystem, such as help or version + /// + public SubsystemKind Kind { get; } + public AddToPhaseBehavior RecommendedAddToPhaseBehavior { get; } + + private readonly IAnnotationProvider? _annotationProvider; + + /// + /// Attempt to retrieve the 's value for the annotation . This will check the + /// annotation provider that was passed to the subsystem constructor, and the internal annotation storage. + /// + /// + /// The expected type of the annotation value. If the type does not match, a will be thrown. + /// If the annotation allows multiple types for its values, and a type parameter cannot be determined statically, + /// use to access the annotation value without checking its type. + /// + /// The symbol the value is attached to + /// + /// The identifier for the annotation value to be retrieved. + /// For example, the annotation identifier for the help description is . + /// + /// An out parameter to contain the result + /// True if successful + /// + /// If the annotation value does not have a single expected type for this symbol, use the overload instead. + /// + /// Subsystem authors must use this to access annotation values, as it respects the subsystem's if it has one. + /// This value is protected because it is intended for use only by subsystem authors. It calls + /// + /// + protected internal bool TryGetAnnotation(CliSymbol symbol, AnnotationId annotationId, [NotNullWhen(true)] out TValue? value) + { + if (_annotationProvider is not null && _annotationProvider.TryGet(symbol, annotationId, out object? rawValue)) + { + if (rawValue is TValue expectedTypeValue) + { + value = expectedTypeValue; + return true; + } + throw new AnnotationTypeException(annotationId, typeof(TValue), rawValue?.GetType(), _annotationProvider); + } + + return symbol.TryGetAnnotation(annotationId, out value); + } + + /// + /// Attempt to retrieve the 's value for the annotation . This will check the + /// annotation provider that was passed to the subsystem constructor, and the internal annotation storage. + /// + /// The symbol the value is attached to + /// + /// The identifier for the annotation value to be retrieved. + /// For example, the annotation identifier for the help description is . + /// + /// An out parameter to contain the result + /// True if successful + /// + /// If the expected type of the annotation value is known, use the overload instead. + /// + /// Subsystem authors must use this to access annotation values, as it respects the subsystem's if it has one. + /// This value is protected because it is intended for use only by subsystem authors. It calls + /// + /// + protected internal bool TryGetAnnotation(CliSymbol symbol, AnnotationId annotationId, [NotNullWhen(true)] out object? value) + { + if (_annotationProvider is not null && _annotationProvider.TryGet(symbol, annotationId, out value)) + { + return true; + } + + return symbol.TryGetAnnotation(annotationId, out value); + } + + /// + /// The subystem executes, even if another subsystem has handled the operation. This is expected to be used in things like error reporting. + /// + protected internal virtual bool RunsEvenIfAlreadyHandled { get; protected set; } + + /// + /// Executes the behavior of the subsystem. For example, help would write information to the console. + /// + /// The context contains data like the ParseResult, and allows setting of values like whether execution was handled and the CLI should terminate + /// A PipelineResult object with information such as whether the CLI should terminate + // These methods are public to support use of subsystems without the pipeline + public virtual void Execute(PipelineResult pipelineResult) + => pipelineResult.NotRun(pipelineResult.ParseResult); + + public PipelineResult ExecuteIfNeeded(PipelineResult pipelineResult) + => ExecuteIfNeeded(pipelineResult.ParseResult, pipelineResult); + + internal PipelineResult ExecuteIfNeeded(ParseResult? parseResult, PipelineResult pipelineResult) + { + if (GetIsActivated(parseResult)) + { + Execute(pipelineResult); + } + return pipelineResult; + } + + + /// + /// Indicates to invocation patterns that the extension should be run. + /// + /// + /// This may be explicitly set, such as a directive like Diagram, or it may explore the result + /// + /// The parse result. + /// + protected internal virtual bool GetIsActivated(ParseResult? parseResult) => false; + + /// + /// Runs before parsing to prepare the parser. Since it always runs, slow code that is only needed when the extension + /// runs as part of invocation should be delayed to BeforeRun(). Default behavior is to do nothing. + /// + /// + /// Use cases: + /// * Add to the CLI, such as adding version option + /// * Early setup of extension internal data, such as reading a file that contains defaults + /// * Licensing if early exit is needed + /// + /// The CLI configuration, which contains the RootCommand for customization + /// True if parsing should continue // there might be a better design that supports a message + // TODO: Because of this and similar usage, consider combining CLI declaration and config. ArgParse calls this the parser, which I like + // TODO: Why does Intitialize return a configuration? + protected internal virtual void Initialize(InitializationContext context) + { } + + // TODO: Determine if this is needed. + protected internal virtual void TearDown(PipelineResult pipelineResult) + { } + +} diff --git a/src/System.CommandLine.Subsystems/Subsystems/IAnnotationProvider.cs b/src/System.CommandLine.Subsystems/Subsystems/IAnnotationProvider.cs new file mode 100644 index 0000000000..dd4d9e4fd5 --- /dev/null +++ b/src/System.CommandLine.Subsystems/Subsystems/IAnnotationProvider.cs @@ -0,0 +1,15 @@ +// 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.CommandLine.Subsystems.Annotations; +using System.Diagnostics.CodeAnalysis; + +namespace System.CommandLine.Subsystems; + +/// +/// Alternative storage of annotations, enabling lazy loading and dynamic annotations. +/// +public interface IAnnotationProvider +{ + bool TryGet(CliSymbol symbol, AnnotationId id, [NotNullWhen(true)] out object? value); +} diff --git a/src/System.CommandLine.Subsystems/Subsystems/InitializationContext.cs b/src/System.CommandLine.Subsystems/Subsystems/InitializationContext.cs new file mode 100644 index 0000000000..0a8f1ed80e --- /dev/null +++ b/src/System.CommandLine.Subsystems/Subsystems/InitializationContext.cs @@ -0,0 +1,10 @@ +// 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.Subsystems; + +public class InitializationContext(CliConfiguration configuration, IReadOnlyList args) +{ + public CliConfiguration Configuration { get; } = configuration; + public IReadOnlyList Args { get; } = args; +} diff --git a/src/System.CommandLine.Subsystems/Subsystems/PipelinePhase.cs b/src/System.CommandLine.Subsystems/Subsystems/PipelinePhase.cs new file mode 100644 index 0000000000..023b7f779a --- /dev/null +++ b/src/System.CommandLine.Subsystems/Subsystems/PipelinePhase.cs @@ -0,0 +1,82 @@ +// 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.Subsystems; + +internal class PipelinePhase(SubsystemKind kind) +{ + private List? before = null; + private List? after = null; + + public readonly SubsystemKind Kind = kind; + protected CliSubsystem? CliSubsystem { get; set; } + + /// + /// Add a subsystem to the phase + /// + /// The subsystem to add + /// Whether it should run before or after the key subsystem + /// + /// Adding a subsystem that is not of the normal phase type is expected and OK. + /// + internal void AddSubsystem(CliSubsystem subsystem, AddToPhaseBehavior timing) + { + timing = timing == AddToPhaseBehavior.SubsystemRecommendation ? subsystem.RecommendedAddToPhaseBehavior : timing; + List? addToList = timing == AddToPhaseBehavior.Prepend + ? CreateBeforeIfNeeded() + : CreateAfterIfNeeded(); + + addToList.Add(subsystem); + } + + private List CreateBeforeIfNeeded() + { + before ??= []; + return before; + } + + private List CreateAfterIfNeeded() + { + after ??= []; + return after; + } + + public IEnumerable GetSubsystems() + => [ + .. (before is null ? [] : before), + .. (CliSubsystem is null ? new List { } : [CliSubsystem]), + .. (after is null ? [] : after) + ]; +} + +/// +/// This manages one phase. The most common case is that it is empty, and the most complicated +/// case of several items before, and several items after will be quite rare. +/// +/// +/// +/// The most common case is that it is empty, and the most complicated +/// case of several items before, and several items after will be quite rare.
+///
+/// +/// In the current design, this needs to be a reference type so values are synced. +/// +///
+internal class PipelinePhase : PipelinePhase + where TSubsystem : CliSubsystem +{ + private TSubsystem? subsystem; + + public PipelinePhase(SubsystemKind kind) : base(kind) + { } + + internal TSubsystem? Subsystem + { + get => subsystem; + set + { + CliSubsystem = value; + subsystem = value; + } + } +} diff --git a/src/System.CommandLine.Subsystems/Subsystems/Subsystem.cs b/src/System.CommandLine.Subsystems/Subsystems/Subsystem.cs new file mode 100644 index 0000000000..e55d28a7d7 --- /dev/null +++ b/src/System.CommandLine.Subsystems/Subsystems/Subsystem.cs @@ -0,0 +1,36 @@ +// 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.Subsystems; + +public class Subsystem +{ + public static void Initialize(CliSubsystem subsystem, CliConfiguration configuration, IReadOnlyList args) + => subsystem.Initialize(new InitializationContext(configuration, args)); + + public static void Execute(CliSubsystem subsystem, PipelineResult pipelineResult) + => subsystem.Execute(pipelineResult); + + public static bool GetIsActivated(CliSubsystem subsystem, ParseResult parseResult) + => subsystem.GetIsActivated(parseResult); + + public static PipelineResult ExecuteIfNeeded(CliSubsystem subsystem, ParseResult parseResult, string rawInput, ConsoleHack? consoleHack = null) + => subsystem.ExecuteIfNeeded(new PipelineResult(parseResult, rawInput, null, consoleHack)); + + public static PipelineResult Execute(CliSubsystem subsystem, ParseResult parseResult, string rawInput, ConsoleHack? consoleHack = null) + { + var pipelineResult = new PipelineResult(parseResult, rawInput,null, consoleHack); + subsystem.Execute(pipelineResult); + return pipelineResult; + } + + internal static PipelineResult ExecuteIfNeeded(CliSubsystem subsystem, ParseResult parseResult, string rawInput, ConsoleHack? consoleHack, PipelineResult? pipelineResult = null) + { + pipelineResult ??= new PipelineResult(parseResult, rawInput, null, consoleHack); + subsystem.ExecuteIfNeeded(pipelineResult ); + return pipelineResult; + } + + internal static void ExecuteIfNeeded(CliSubsystem subsystem, PipelineResult pipelineResult) + => subsystem.ExecuteIfNeeded(pipelineResult); +} diff --git a/src/System.CommandLine.Subsystems/Subsystems/SubsystemKind.cs b/src/System.CommandLine.Subsystems/Subsystems/SubsystemKind.cs new file mode 100644 index 0000000000..f565372de0 --- /dev/null +++ b/src/System.CommandLine.Subsystems/Subsystems/SubsystemKind.cs @@ -0,0 +1,19 @@ +// 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.Subsystems; + +public enum SubsystemKind +{ + Other = 0, + Diagram, + Completion, + Help, + Version, + Validation, + Invocation, + ErrorReporting, + Value, + Response, +} + diff --git a/src/System.CommandLine.Subsystems/SymbolAnnotationExtensions.cs b/src/System.CommandLine.Subsystems/SymbolAnnotationExtensions.cs new file mode 100644 index 0000000000..87a19c9da0 --- /dev/null +++ b/src/System.CommandLine.Subsystems/SymbolAnnotationExtensions.cs @@ -0,0 +1,41 @@ +// 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; + +/// +/// Extensions for that allow fluent construction of and fluent addition of annotations to objects. +/// +public static class SymbolAnnotationExtensions +{ + public static TCommand With(this TCommand command, CliCommand subcommand) + where TCommand : CliCommand + { + command.Add(subcommand); + return command; + } + + public static TCommand With(this TCommand command, CliOption option) + where TCommand : CliCommand + { + command.Add(option); + return command; + } + + public static TCommand With(this TCommand command, CliArgument argument) + where TCommand : CliCommand + { + command.Add(argument); + return command; + } + + public static TCommand With(this TCommand command, params CliSymbol[] symbols) + where TCommand : CliCommand + { + foreach (var symbol in symbols) + { + command.Add(symbol); + } + return command; + } +} diff --git a/src/System.CommandLine.Subsystems/System.CommandLine.Subsystems.csproj b/src/System.CommandLine.Subsystems/System.CommandLine.Subsystems.csproj new file mode 100644 index 0000000000..2f5b0e4d91 --- /dev/null +++ b/src/System.CommandLine.Subsystems/System.CommandLine.Subsystems.csproj @@ -0,0 +1,24 @@ + + + + $(TargetFrameworkForNETSDK) + enable + enable + System.CommandLine + true + + + + + + + + + + + + + + + + diff --git a/src/System.CommandLine.Subsystems/Validation/CommandValidator.cs b/src/System.CommandLine.Subsystems/Validation/CommandValidator.cs new file mode 100644 index 0000000000..df58f9e655 --- /dev/null +++ b/src/System.CommandLine.Subsystems/Validation/CommandValidator.cs @@ -0,0 +1,24 @@ +// 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.CommandLine.Parsing; + +namespace System.CommandLine.Validation; + +/// +/// Base class for validators that affect the entire command +/// +public abstract class CommandValidator : Validator +{ + protected CommandValidator(string name, Type valueConditionType, params Type[] moreValueConditionTypes) + : base(name, valueConditionType, moreValueConditionTypes) + { } + + /// + /// Validation method specific to command results. + /// + /// The + /// The + /// The + public abstract void Validate(CliCommandResult commandResult, CommandCondition commandCondition, ValidationContext validationContext); +} diff --git a/src/System.CommandLine.Subsystems/Validation/ICommandValidator.cs b/src/System.CommandLine.Subsystems/Validation/ICommandValidator.cs new file mode 100644 index 0000000000..ff109370be --- /dev/null +++ b/src/System.CommandLine.Subsystems/Validation/ICommandValidator.cs @@ -0,0 +1,22 @@ +// 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.CommandLine.Parsing; + +namespace System.CommandLine.Validation; + +/// +/// Interface that allows non-Validator derived methods to perform validation. Specifically, this supports +/// instances that can validate. +/// +public interface ICommandValidator +{ + /// + /// Validation method specific to command results + /// + /// The + /// The + /// The + void Validate(CliCommandResult commandResult, CommandCondition commandCondition, ValidationContext validationContext); +} + diff --git a/src/System.CommandLine.Subsystems/Validation/IValueValidator.cs b/src/System.CommandLine.Subsystems/Validation/IValueValidator.cs new file mode 100644 index 0000000000..bca36f53c3 --- /dev/null +++ b/src/System.CommandLine.Subsystems/Validation/IValueValidator.cs @@ -0,0 +1,25 @@ +// 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.CommandLine.Parsing; + +namespace System.CommandLine.Validation; + +/// +/// Interface that allows non-Validator derived methods to perform validation. Specifically, this supports +/// instances that can validate. +/// +public interface IValueValidator +{ + // Note: We pass both valueSymbol and valueResult, because we may validate symbols where valueResult is null. + /// + /// Validation method specific to value results. + /// + /// The value to validate. + /// The of the value to validate. + /// The of the value to validate. + /// The + /// The + void Validate(object? value, CliValueSymbol valueSymbol, + CliValueResult? valueResult, ValueCondition valueCondition, ValidationContext validationContext); +} diff --git a/src/System.CommandLine.Subsystems/Validation/InclusiveGroupValidator.cs b/src/System.CommandLine.Subsystems/Validation/InclusiveGroupValidator.cs new file mode 100644 index 0000000000..6c8a1c28e5 --- /dev/null +++ b/src/System.CommandLine.Subsystems/Validation/InclusiveGroupValidator.cs @@ -0,0 +1,52 @@ +// 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.CommandLine.Parsing; +using System.CommandLine.ValueConditions; + +namespace System.CommandLine.Validation; + +/// +/// Validator that requires that if one member of the group is present, they are all present. +/// +public class InclusiveGroupValidator : CommandValidator +{ + public InclusiveGroupValidator() : base(nameof(InclusiveGroup), typeof(InclusiveGroup)) + { } + + /// + public override void Validate(CliCommandResult commandResult, + CommandCondition valueCondition, ValidationContext validationContext) + { + var commandSymbol = commandResult.Command; + // TODO: Write the SymbolsInUse method. I think this should allow for default values, so it requires some thought. Hopefully ValueResult already returns only those vaues that the user entered. + var symbolsInUse = commandResult.ValueResults.Select(x => x.ValueSymbol); // commandResult.SymbolsInUse(); + var inclusiveGroup = GetTypedValueConditionOrThrow(valueCondition); + var groupMembers = inclusiveGroup.Members; + var groupInUse = groupMembers + .Any(x => symbolsInUse.Contains(x)); + if (!groupInUse) + { + return; + } + // TODO: Lazily create the missing member list + // TODO: See if there is a LINQ set method for "all not in the other list" + var missingMembers = new List(); + foreach (var member in groupMembers) + { + if (!symbolsInUse.Contains(member)) + { + missingMembers.Add(member); + } + } + if (missingMembers is not null && missingMembers.Any()) + { + // TODO: Rework to allow localization + var pluralToBe = "are"; + var singularToBe = "is"; + validationContext.AddError(new ParseError( $"The members {string.Join(", ", groupMembers.Select(m => m.Name))} " + + $"must all be used if one is used. {string.Join(", ", missingMembers.Select(m => m.Name))} " + + $"{(missingMembers.Skip(1).Any() ? pluralToBe : singularToBe)} missing.")); + } + } +} diff --git a/src/System.CommandLine.Subsystems/Validation/RangeValidator.cs b/src/System.CommandLine.Subsystems/Validation/RangeValidator.cs new file mode 100644 index 0000000000..cfab72a183 --- /dev/null +++ b/src/System.CommandLine.Subsystems/Validation/RangeValidator.cs @@ -0,0 +1,32 @@ +// 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.CommandLine.Parsing; + +namespace System.CommandLine.Validation; + +/// +/// Validates that a value is within the specified bounds. +/// +public class RangeValidator : ValueValidator, IValueValidator +{ + public RangeValidator() : base(nameof(ValueConditions.Range), typeof(ValueConditions.Range)) + { } + + /// + public override void Validate(object? value, CliValueSymbol valueSymbol, + CliValueResult? valueResult, ValueCondition valueCondition, ValidationContext validationContext) + { + if (valueCondition is IValueValidator valueValidator) + { + valueValidator.Validate(value, valueSymbol, valueResult, valueCondition, validationContext); + return; + } + if (valueCondition.MustHaveValidator) + { + validationContext.AddError(new ParseError($"Range validator missing for {valueSymbol.Name}")); + } + } + + +} diff --git a/src/System.CommandLine.Subsystems/Validation/ValidationContext.cs b/src/System.CommandLine.Subsystems/Validation/ValidationContext.cs new file mode 100644 index 0000000000..3f22a12280 --- /dev/null +++ b/src/System.CommandLine.Subsystems/Validation/ValidationContext.cs @@ -0,0 +1,57 @@ +// 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.CommandLine.Parsing; +using System.CommandLine.ValueSources; +using static System.Runtime.InteropServices.JavaScript.JSType; + +namespace System.CommandLine.Validation; + +/// +/// Provides the context for IValidator implementations +/// +public class ValidationContext +{ + private PipelineResult pipelineResult { get; } + + internal ValidationContext(PipelineResult pipelineResult, ValidationSubsystem validationSubsystem) + { + this.pipelineResult = pipelineResult; + ValidationSubsystem = validationSubsystem; + } + + /// + /// Adds an error to the PipelineContext. + /// + /// The to add + public void AddError(ParseError error) + => pipelineResult.AddError(error); + + /// + /// Gets the value for an option or argument. + /// + /// The symbol to get the value for. + /// + public object? GetValue(CliValueSymbol valueSymbol) + => pipelineResult.GetValue(valueSymbol); + + /// + /// Gets the for the option or argument, if the user entered a value. + /// + /// The symbol to get the ValueResult for. + /// The ValueResult for the option or argument, or null if the user did not enter a value. + public CliValueResult? GetValueResult(CliValueSymbol valueSymbol) + => pipelineResult.GetValueResult(valueSymbol); + + /// + /// Tries to get the value for a and returns it a an `out` parameter. + /// + /// The type of the value to retrieve + /// The to query for its result. + /// An output parameter that contains the value, if it is found. + /// True if the succeeded, otherwise false. + public bool TryGetTypedValue(ValueSource valueSource, out T? value) + => valueSource.TryGetTypedValue(pipelineResult, out value); + + internal ValidationSubsystem ValidationSubsystem { get; } +} diff --git a/src/System.CommandLine.Subsystems/Validation/Validator.cs b/src/System.CommandLine.Subsystems/Validation/Validator.cs new file mode 100644 index 0000000000..e33f732c11 --- /dev/null +++ b/src/System.CommandLine.Subsystems/Validation/Validator.cs @@ -0,0 +1,57 @@ +using System.CommandLine.Parsing; +// 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.Validation; + +// TODO: This may be removed if we settle on ValueCondition validation only. +/// +/// Base class for CommandValidator and ValueValidator. +/// +// TODO: Discuss visibility and custom validators +public abstract class Validator +{ + internal Validator(string name, Type valueConditionType, params Type[] moreValueConditionTypes) + { + Name = name; + ValueConditionTypes = [valueConditionType, .. moreValueConditionTypes]; + } + + public string Name { get; } + + public Type[] ValueConditionTypes { get; } + + /// + /// Adds a validation CliDiagnostic that will alter be added to the PipelineResult. Not yet implemented to support that + /// + /// + /// + /// + /// + /// + /// This method needs to be evolved as we replace ParseError with CliError + /// + protected static List AddValidationError(ref List? parseErrors, string message, IEnumerable errorValues) + { + // TODO: Review the handling of errors. They are currently transient and returned by the Validate method, and to avoid allocating in the case of no errors (the common case) this method is used. This adds complexity to creating a new validator. + parseErrors ??= new List(); + parseErrors.Add(new ParseError(message)); + return parseErrors; + } + + // These methods provide consistent messages + protected TCommandCondition GetTypedValueConditionOrThrow(CommandCondition commandCondition) + where TCommandCondition : CommandCondition + => commandCondition is TCommandCondition typedValueCondition + ? typedValueCondition + : throw new ArgumentException($"{Name} validation failed to validator"); + + // These methods provide consistent messages + protected TDataValueCondition GetTypedValueConditionOrThrow(ValueCondition valueCondition) + where TDataValueCondition : ValueCondition + => valueCondition is TDataValueCondition typedValueCondition + ? typedValueCondition + : throw new ArgumentException($"{Name} validation failed to find bounds"); + + +} diff --git a/src/System.CommandLine.Subsystems/Validation/ValueValidator.cs b/src/System.CommandLine.Subsystems/Validation/ValueValidator.cs new file mode 100644 index 0000000000..39685a015c --- /dev/null +++ b/src/System.CommandLine.Subsystems/Validation/ValueValidator.cs @@ -0,0 +1,33 @@ +// 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.CommandLine.Parsing; + +namespace System.CommandLine.Validation; + +/// +/// Base class for validators that affect a single symbol. +/// +public abstract class ValueValidator : Validator +{ + protected ValueValidator(string name, Type valueConditionType, params Type[] moreValueConditionTypes) + : base(name, valueConditionType, moreValueConditionTypes) + { } + + protected TValue GetValueAsTypeOrThrow(object? value) + => value is TValue typedValue + ? typedValue + : throw new InvalidOperationException($"{Name} validation does not apply to this type"); + + /// + /// Validation method specific to a single symbols value.results + /// + /// The value to validate. + /// The option or argument being validated. + /// The + /// The + /// The + public abstract void Validate(object? value, CliValueSymbol valueSymbol, + CliValueResult? valueResult, ValueCondition valueCondition, ValidationContext validationContext); +} + diff --git a/src/System.CommandLine.Subsystems/ValidationSubsystem.cs b/src/System.CommandLine.Subsystems/ValidationSubsystem.cs new file mode 100644 index 0000000000..e3dd610b38 --- /dev/null +++ b/src/System.CommandLine.Subsystems/ValidationSubsystem.cs @@ -0,0 +1,175 @@ +// 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.CommandLine.Parsing; +using System.CommandLine.Subsystems; +using System.CommandLine.Validation; + +namespace System.CommandLine; + +// TODO: Add support for terminating validator. This is needed at least for required because it would be annoying to get an error that you forgot to enter something, and also all the validation errors for the default value Probably other uses, so generalize to termintating. +public sealed class ValidationSubsystem : CliSubsystem +{ + // The type here is the ValueCondition type + private Dictionary valueValidators = []; + private Dictionary commandValidators = []; + + private ValidationSubsystem(IAnnotationProvider? annotationProvider = null) + : base("", SubsystemKind.Validation, annotationProvider) + { } + + public static ValidationSubsystem Create() + { + var newValidationSubsystem = new ValidationSubsystem(); + newValidationSubsystem.AddValidator(new RangeValidator()); + newValidationSubsystem.AddValidator(new InclusiveGroupValidator()); + return newValidationSubsystem; + } + + public static ValidationSubsystem CreateEmpty() + => new ValidationSubsystem(); + + public void AddValidator(ValueValidator validator) + { + foreach (var type in validator.ValueConditionTypes) + { + valueValidators[type] = validator; + } + } + + public void AddValidator(CommandValidator validator) + { + foreach (var type in validator.ValueConditionTypes) + { + commandValidators[type] = validator; + } + } + + protected internal override bool GetIsActivated(ParseResult? parseResult) => true; + + public override void Execute(PipelineResult pipelineResult) + { + if (pipelineResult.ParseResult is null) + { + return; + } + var validationContext = new ValidationContext(pipelineResult, this); + var commandResults = CommandAndAncestors(pipelineResult.ParseResult.CommandResult); + var valueSymbols = GetValueSymbols(commandResults); + foreach (var symbol in valueSymbols) + { + ValidateValue(symbol, validationContext); + } + foreach (var commandResult in commandResults) + { + ValidateCommand(commandResult, validationContext); + } + } + + private void ValidateValue(CliValueSymbol valueSymbol, ValidationContext validationContext) + { + var valueConditions = valueSymbol.GetValueConditions(); + if (valueConditions is null) + { + return; // nothing to do + } + + var value = validationContext.GetValue(valueSymbol); + var valueResult = validationContext.GetValueResult(valueSymbol); + foreach (var condition in valueConditions) + { + ValidateValueCondition(value, valueSymbol, valueResult, condition, validationContext); + } + } + + private void ValidateCommand(CliCommandResult commandResult, ValidationContext validationContext) + { + var valueConditions = commandResult.Command.GetCommandConditions(); + if (valueConditions is null) + { + return; // nothing to do + } + + foreach (var condition in valueConditions) + { + ValidateCommandCondition(commandResult, condition, validationContext); + } + } + + private static List GetValueSymbols(IEnumerable commandResults) + => commandResults + .SelectMany(commandResult => commandResult.ValueResults.Select(valueResult => valueResult.ValueSymbol)) + .Distinct() + .ToList(); + + // Consider moving to CliCommandResult + private static IEnumerable CommandAndAncestors(CliCommandResult commandResult) + => commandResult.Parent is not null + ? [commandResult, .. CommandAndAncestors(commandResult.Parent)] + : [commandResult]; + + private void ValidateValueCondition(object? value, CliValueSymbol valueSymbol, CliValueResult? valueResult, ValueCondition condition, ValidationContext validationContext) + { + if (condition is IValueValidator conditionValidator) + { + conditionValidator.Validate(value, valueSymbol, valueResult, condition, validationContext); + return; + } + ValueValidator? validator = GetValidator(condition); + if (validator == null) + { + if (condition.MustHaveValidator) + { + validationContext.AddError(new ParseError($"{valueSymbol.Name} must have {condition.Name} validator.")); + } + return; + } + validator.Validate(value, valueSymbol, valueResult, condition, validationContext); + + } + + private ValueValidator? GetValidator(ValueCondition condition) + { + if (!valueValidators.TryGetValue(condition.GetType(), out var validator) || validator is null) + { + if (condition.MustHaveValidator) + { + // Output missing validator error + } + } + + return validator; + } + + private CommandValidator? GetValidator(CommandCondition condition) + { + if (!commandValidators.TryGetValue(condition.GetType(), out var validator) || validator is null) + { + if (condition.MustHaveValidator) + { + // Output missing validator error + } + } + + return validator; + } + + private void ValidateCommandCondition(CliCommandResult commandResult, CommandCondition condition, ValidationContext validationContext) + { + if (condition is ICommandValidator conditionValidator) + { + conditionValidator.Validate(commandResult, condition, validationContext); + return; + } + CommandValidator? validator = GetValidator(condition); + if (validator == null) + { + if (condition.MustHaveValidator) + { + validationContext.AddError(new ParseError($"{commandResult.Command.Name} must have {condition.Name} validator.")); + } + return; + } + validator.Validate(commandResult, condition, validationContext); + } +} diff --git a/src/System.CommandLine.Subsystems/ValueAnnotationExtensions.cs b/src/System.CommandLine.Subsystems/ValueAnnotationExtensions.cs new file mode 100644 index 0000000000..676bcc9b7d --- /dev/null +++ b/src/System.CommandLine.Subsystems/ValueAnnotationExtensions.cs @@ -0,0 +1,214 @@ +// 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.CommandLine.Subsystems; +using System.CommandLine.Subsystems.Annotations; +using System.CommandLine.ValueSources; +using System.Diagnostics.CodeAnalysis; + +namespace System.CommandLine; + +public static class ValueAnnotationExtensions +{ + /// + /// Get the default value annotation for the + /// + /// The type of the option value + /// The option + /// The option's default value annotation if any, otherwise + /// + /// This is intended to be called by CLI authors. Subsystems should instead call , + /// which calculates the actual default value, based on the default value annotation and default value calculation, + /// whether directly stored on the symbol or from the subsystem's . + /// + public static bool TryGetDefaultValueSource(this CliValueSymbol valueSymbol, [NotNullWhen(true)] out ValueSource? defaultValueSource) + => valueSymbol.TryGetAnnotation(ValueAnnotations.DefaultValueSource, out defaultValueSource); + + /// + /// Sets the default value annotation on the + /// + /// The type of the option value + /// The option + /// The default value for the option + public static void SetDefaultValueSource(this CliValueSymbol valueSymbol, ValueSource defaultValue) + => valueSymbol.SetAnnotation(ValueAnnotations.DefaultValue, defaultValue); + + + /// + /// Sets the default value annotation on the + /// + /// The type of the option value + /// The option + /// The default value for the option + /// The , to enable fluent construction of symbols with annotations. + public static CliOption WithDefaultValue(this CliOption option, TValue defaultValue) + { + option.SetDefaultValue(defaultValue); + return option; + } + + /// + /// Sets the default value annotation on the + /// + /// The type of the option value + /// The option + /// The default value for the option + public static void SetDefaultValue(this CliOption option, TValue defaultValue) + { + option.SetAnnotation(ValueAnnotations.DefaultValue, defaultValue); + } + + /// + /// Get the default value annotation for the + /// + /// The type of the option value + /// The option + /// The option's default value annotation if any, otherwise + /// + /// This is intended to be called by CLI authors. Subsystems should instead call , + /// which calculates the actual default value, based on the default value annotation and default value calculation, + /// whether directly stored on the symbol or from the subsystem's . + /// + public static TValue? GetDefaultValueAnnotation(this CliOption option) + { + if (option.TryGetAnnotation(ValueAnnotations.DefaultValue, out TValue? defaultValue)) + { + return defaultValue; + } + return default; + } + + /// + /// Sets the default value annotation on the + /// + /// The type of the argument value + /// The argument + /// The default value for the argument + /// The , to enable fluent construction of symbols with annotations. + public static CliArgument WithDefaultValue(this CliArgument argument, TValue defaultValue) + { + argument.SetDefaultValue(defaultValue); + return argument; + } + + /// + /// Sets the default value annotation on the + /// + /// The type of the argument value + /// The argument + /// The default value for the argument + /// The , to enable fluent construction of symbols with annotations. + public static void SetDefaultValue(this CliArgument argument, TValue defaultValue) + { + argument.SetAnnotation(ValueAnnotations.DefaultValue, defaultValue); + } + + /// + /// Get the default value annotation for the + /// + /// The type of the argument value + /// The argument + /// The argument's default value annotation if any, otherwise + /// + /// This is intended to be called by CLI authors. Subsystems should instead call , + /// which calculates the actual default value, based on the default value annotation and default value calculation, + /// whether directly stored on the symbol or from the subsystem's . + /// + public static TValue? GetDefaultValueAnnotation(this CliArgument argument) + { + if (argument.TryGetAnnotation(ValueAnnotations.DefaultValue, out TValue? defaultValue)) + { + return (TValue?)defaultValue; + } + return default; + } + + /// + /// Sets the default value calculation for the + /// + /// The type of the option value + /// The option + /// The default value calculation for the option + /// The , to enable fluent construction of symbols with annotations. + public static CliOption WithDefaultValueCalculation(this CliOption option, Func defaultValueCalculation) + { + option.SetDefaultValueCalculation(defaultValueCalculation); + return option; + } + + /// + /// Sets the default value calculation for the + /// + /// The type of the option value + /// The option + /// The default value calculation for the option + public static void SetDefaultValueCalculation(this CliOption option, Func defaultValueCalculation) + { + option.SetAnnotation(ValueAnnotations.DefaultValueCalculation, defaultValueCalculation); + } + + /// + /// Get the default value calculation for the + /// + /// The type of the option value + /// The option + /// The option's default value calculation if any, otherwise + /// + /// This is intended to be called by CLI authors. Subsystems should instead call , + /// which calculates the actual default value, based on the default value annotation and default value calculation, + /// whether directly stored on the symbol or from the subsystem's . + /// + public static Func? GetDefaultValueCalculation(this CliOption option) + { + if (option.TryGetAnnotation(ValueAnnotations.DefaultValueCalculation, out Func? defaultValueCalculation)) + { + return defaultValueCalculation; + } + return default; + } + + /// + /// Sets the default value calculation for the + /// + /// The type of the argument value + /// The argument + /// The default value calculation for the argument + /// The , to enable fluent construction of symbols with annotations. + public static CliArgument WithDefaultValueCalculation(this CliArgument argument, Func defaultValueCalculation) + { + argument.SetDefaultValueCalculation(defaultValueCalculation); + return argument; + } + + /// + /// Sets the default value calculation for the + /// + /// The type of the argument value + /// The argument + /// The default value calculation for the argument + /// The , to enable fluent construction of symbols with annotations. + public static void SetDefaultValueCalculation(this CliArgument argument, Func defaultValueCalculation) + { + argument.SetAnnotation(ValueAnnotations.DefaultValueCalculation, defaultValueCalculation); + } + + /// + /// Get the default value calculation for the + /// + /// The type of the argument value + /// The argument + /// The argument's default value calculation if any, otherwise + /// + /// This is intended to be called by CLI authors. Subsystems should instead call , + /// which calculates the actual default value, based on the default value annotation and default value calculation, + /// whether directly stored on the symbol or from the subsystem's . + /// + public static Func? GetDefaultValueCalculation(this CliArgument argument) + { + if (argument.TryGetAnnotation(ValueAnnotations.DefaultValueCalculation, out Func? defaultValueCalculation)) + { + return defaultValueCalculation; + } + return default; + } +} diff --git a/src/System.CommandLine.Subsystems/ValueCondition.cs b/src/System.CommandLine.Subsystems/ValueCondition.cs new file mode 100644 index 0000000000..e7f3d8bf77 --- /dev/null +++ b/src/System.CommandLine.Subsystems/ValueCondition.cs @@ -0,0 +1,24 @@ +// 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; + +/// +/// The base class for all conditions. Conditions describe aspects of +/// symbol results, including restrictions used for validation. +/// +/// +public abstract class ValueCondition(string name) +{ + /// + /// Whether a diagnostic should be reported if there is no validator. + /// Conditions may be used for other purposes, such as completions and + /// not require validation. + /// + public virtual bool MustHaveValidator { get; } = true; + + /// + /// The name of the ValueCondition. + /// + public string Name { get; } = name; +} diff --git a/src/System.CommandLine.Subsystems/ValueConditionAnnotationExtensions.cs b/src/System.CommandLine.Subsystems/ValueConditionAnnotationExtensions.cs new file mode 100644 index 0000000000..1ca563b7e4 --- /dev/null +++ b/src/System.CommandLine.Subsystems/ValueConditionAnnotationExtensions.cs @@ -0,0 +1,134 @@ +using System.CommandLine.Subsystems.Annotations; +using System.CommandLine.ValueConditions; +using System.CommandLine.ValueSources; + +namespace System.CommandLine; + +/// +/// Contains the extension methods that are used to create value conditions +/// +public static class ValueConditionAnnotationExtensions +{ + /// + /// Set the upper and/or lower bound values of the range. + /// + /// The type of the bounds. + /// The option or argument the range applies to. + /// The lower bound of the range. + /// The upper bound of the range. + // TODO: Add RangeBounds + // TODO: You should not have to set both...why not nullable? + public static void SetRange(this CliValueSymbol symbol, T lowerBound, T upperBound) + where T : IComparable + { + var range = new Range(lowerBound, upperBound); + + symbol.SetValueCondition(range); + } + + /// + /// Set the upper and/or lower bound via ValueSource. Implicit conversions means this + /// generally just works with any . + /// + /// The type of the bounds. + /// The option or argument the range applies to. + /// The that is the lower bound of the range. + /// The that is the upper bound of the range. + // TODO: Add RangeBounds + // TODO: You should not have to set both...why not nullable? + public static void SetRange(this CliValueSymbol symbol, ValueSource lowerBound, ValueSource upperBound) + where T : IComparable + // TODO: You should not have to set both...why not nullable? + { + var range = new Range(lowerBound, upperBound); + + symbol.SetValueCondition(range); + } + + /// + /// Indicates that there is an inclusive group of options and arguments for the command. All + /// members of an inclusive must be present, or none can be present. + /// + /// The command the inclusive group applies to. + /// The group of options and arguments that must all be present, or none can be present. + public static void SetInclusiveGroup(this CliCommand command, IEnumerable group) + => command.SetValueCondition(new InclusiveGroup(group)); + + // TODO: This should not be public if ValueConditions are not public + public static void SetValueCondition(this TValueSymbol symbol, TValueCondition valueCondition) + where TValueSymbol : CliValueSymbol + where TValueCondition : ValueCondition + { + if (!symbol.TryGetAnnotation>(ValueConditionAnnotations.ValueConditions, out var valueConditions)) + { + valueConditions = []; + symbol.SetAnnotation(ValueConditionAnnotations.ValueConditions, valueConditions); + } + valueConditions.Add(valueCondition); + } + + // TODO: This should not be public if ValueConditions are not public + public static void SetValueCondition(this CliCommand symbol, TCommandCondition commandCondition) + where TCommandCondition : CommandCondition + { + if (!symbol.TryGetAnnotation>(ValueConditionAnnotations.ValueConditions, out var valueConditions)) + { + valueConditions = []; + symbol.SetAnnotation(ValueConditionAnnotations.ValueConditions, valueConditions); + } + valueConditions.Add(commandCondition); + } + + /// + /// Gets a list of conditions on an option or argument. + /// + /// The option or argument to get the conditions for. + /// The conditions that have been applied to the option or argument. + /// + // TODO: This is public because it will be used by other subsystems we might not own. It could be an extension method the subsystem namespace + public static List? GetValueConditions(this CliValueSymbol symbol) + => symbol.TryGetAnnotation>(ValueConditionAnnotations.ValueConditions, out var valueConditions) + ? valueConditions + : null; + + /// + /// Gets a list of conditions on a command. + /// + /// The command to get the conditions for. + /// The conditions that have been applied to the command. + /// + // TODO: This is public because it will be used by other subsystems we might not own. It could be an extension method the subsystem namespace + public static List? GetCommandConditions(this CliCommand command) + => command.TryGetAnnotation>(ValueConditionAnnotations.ValueConditions, out var valueConditions) + ? valueConditions + : null; + + /// + /// Gets the condition that matches the type, if it exists on this option or argument. + /// + /// The type of condition to return. + /// The option or argument that may contain the condition. + /// The condition if it exists on the option or argument, otherwise null. + // This method feels useful because it clarifies that last should win and returns one, when only one should be applied + // TODO: Consider removing user facing naming, other than the base type, that is Value or CommandCondition and just use Condition + public static TCondition? GetValueCondition(this CliValueSymbol symbol) + where TCondition : ValueCondition + => !symbol.TryGetAnnotation(ValueConditionAnnotations.ValueConditions, out List? valueConditions) + ? null + : valueConditions.OfType().LastOrDefault(); + + /// + /// Gets the condition that matches the type, if it exists on this command. + /// + /// The type of condition to return. + /// The command that may contain the condition. + /// The condition if it exists on the command, otherwise null. + // This method feels useful because it clarifies that last should win and returns one, when only one should be applied + public static TCondition? GetCommandCondition(this CliCommand symbol) + where TCondition : CommandCondition + => !symbol.TryGetAnnotation(ValueConditionAnnotations.ValueConditions, out List? valueConditions) + ? null + : valueConditions.OfType().LastOrDefault(); + + +} diff --git a/src/System.CommandLine.Subsystems/ValueConditions/InclusiveGroup.cs b/src/System.CommandLine.Subsystems/ValueConditions/InclusiveGroup.cs new file mode 100644 index 0000000000..29628a12fe --- /dev/null +++ b/src/System.CommandLine.Subsystems/ValueConditions/InclusiveGroup.cs @@ -0,0 +1,28 @@ +// 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.ValueConditions; + +/// +/// Describes that a set of options and arguments must all be entered +/// if one or more are entered. +/// +public class InclusiveGroup : CommandCondition +{ + private IEnumerable group = []; + + /// + /// The constructor for InclusiveGroup. + /// + /// The group of options and arguments that must all be present, or note be present. + public InclusiveGroup(IEnumerable group) + : base(nameof(InclusiveGroup)) + { + this.group = group; + } + + /// + /// The members of the inclusive group. + /// + public IEnumerable Members => group.ToList(); +} diff --git a/src/System.CommandLine.Subsystems/ValueConditions/Range.cs b/src/System.CommandLine.Subsystems/ValueConditions/Range.cs new file mode 100644 index 0000000000..2d029e5e8e --- /dev/null +++ b/src/System.CommandLine.Subsystems/ValueConditions/Range.cs @@ -0,0 +1,90 @@ +// 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.CommandLine.Parsing; +using System.CommandLine.Validation; +using System.CommandLine.ValueSources; + +namespace System.CommandLine.ValueConditions; + +/// +/// Declares the range condition for the option or argument. +/// +/// +/// The non-generic version is used by the +/// +/// The type of the symbol the range applies to. +public class Range : Range, IValueValidator + where T : IComparable +{ + internal Range(ValueSource? lowerBound, ValueSource? upperBound, RangeBounds rangeBound = 0) : base(typeof(T)) + { + LowerBound = lowerBound; + UpperBound = upperBound; + RangeBound = rangeBound; + } + + /// + public void Validate(object? value, + CliValueSymbol valueSymbol, + CliValueResult? valueResult, + ValueCondition valueCondition, + ValidationContext validationContext) + { + if (valueCondition != this) throw new InvalidOperationException("Unexpected value condition type"); + if (value is not T comparableValue) throw new InvalidOperationException("Unexpected value type"); + + if (comparableValue is null) return; // nothing to do + + // TODO: Replace the strings we are comparing with a diagnostic ID when we update ParseError + if (LowerBound is not null + && validationContext.TryGetTypedValue(LowerBound, out var lowerValue) + && comparableValue.CompareTo(lowerValue) < 0) + { + validationContext.AddError(new ParseError($"The value for '{valueSymbol.Name}' is below the lower bound of {LowerBound}")); + } + + + if (UpperBound is not null + && validationContext.TryGetTypedValue(UpperBound, out var upperValue) + && comparableValue.CompareTo(upperValue) > 0) + { + validationContext.AddError(new ParseError($"The value for '{valueSymbol.Name}' is above the upper bound of {UpperBound}")); + } + } + + /// + /// The lower bound of the range. + /// + public ValueSource? LowerBound { get; init; } + + /// + /// The upper bound of the range. + /// + public ValueSource? UpperBound { get; init; } + + /// + /// Whether values of the range are considered part of the + /// range (inclusive) or not (exclusive) + /// + public RangeBounds RangeBound { get; } + +} diff --git a/src/System.CommandLine.Subsystems/ValueConditions/RangeBounds.cs b/src/System.CommandLine.Subsystems/ValueConditions/RangeBounds.cs new file mode 100644 index 0000000000..7f670d0ffa --- /dev/null +++ b/src/System.CommandLine.Subsystems/ValueConditions/RangeBounds.cs @@ -0,0 +1,39 @@ +// 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.ValueConditions; + +/// +/// Specifies how bounds should be treated. +/// +/// +/// If the lower bound is 2 and the upper bound is 4, +/// and the value is 2, an inclusive lower bound would +/// pass, and an exclusive bound would fail. Similarly, a +/// value of 4 would fail if the upper bound was inclusive, +/// and fail if the bound was exclusive. Exclusive bounds are +/// often used with fractional values, such as double, single and decimal. +/// +[Flags] +public enum RangeBounds +{ + /// + /// If the value equals the upper or lower bound, it passes. + /// + Inclusive = 0, + + /// + /// If the value is greater than the lower bound, and less than or equal to the upper bound, it passes. + /// + ExclusiveLowerBound = 1, + + /// + /// If the value is greater than or equal to the lower bound, and less than the upper bound, it passes. + /// + ExclusiveUpperBound = 2, + + /// + /// The value passes only if it is greater than the lower bound and less than the upper bound. + /// + ExclusiveUpperAndLowerBounds = 3, +} diff --git a/src/System.CommandLine.Subsystems/ValueProvider.cs b/src/System.CommandLine.Subsystems/ValueProvider.cs new file mode 100644 index 0000000000..f51a1edb63 --- /dev/null +++ b/src/System.CommandLine.Subsystems/ValueProvider.cs @@ -0,0 +1,79 @@ +// 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.CommandLine.ValueSources; + +namespace System.CommandLine; + +internal class ValueProvider +{ + private Dictionary cachedValues = []; + private PipelineResult pipelineResult; + + public ValueProvider(PipelineResult pipelineResult) + { + this.pipelineResult = pipelineResult; + } + + private void SetValue(CliSymbol symbol, object? value) + { + cachedValues[symbol] = value; + } + + private bool TryGetValue(CliSymbol symbol, out T? value) + { + if (cachedValues.TryGetValue(symbol, out var objectValue)) + { + value = objectValue is null + ? default + : (T)objectValue; + return true; + } + value = default; + return false; + } + + public T? GetValue(CliValueSymbol valueSymbol) + => GetValueInternal(valueSymbol); + + private T? GetValueInternal(CliValueSymbol valueSymbol) + { + // TODO: Add guard to prevent reentrancy for the same symbol via RelativeToSymbol of custom ValueSource + var _ = valueSymbol ?? throw new ArgumentNullException(nameof(valueSymbol)); + if (TryGetValue(valueSymbol, out var value)) + { + return value; + } + if (pipelineResult.ParseResult?.GetValueResult(valueSymbol) is { } valueResult) + { + return UseValue(valueSymbol, valueResult.GetValue()); + } + if (valueSymbol.TryGetDefaultValueSource(out ValueSource? defaultValueSource)) + { + if (defaultValueSource is not ValueSource typedDefaultValueSource) + { + throw new InvalidOperationException("Unexpected ValueSource type"); + } + if (typedDefaultValueSource.TryGetTypedValue(pipelineResult, out T? defaultValue)) + { + return UseValue(valueSymbol, defaultValue); + } + } + return UseValue(valueSymbol, default(T)); + + TValue UseValue(CliSymbol symbol, TValue value) + { + SetValue(symbol, value); + return value; + } + } + + private static T? CalculatedDefault(CliValueSymbol valueSymbol, Func defaultValueCalculation) + { + var objectValue = defaultValueCalculation(); + var value = objectValue is null + ? default + : (T)objectValue; + return value; + } +} diff --git a/src/System.CommandLine.Subsystems/ValueSources/AggregateValueSource.cs b/src/System.CommandLine.Subsystems/ValueSources/AggregateValueSource.cs new file mode 100644 index 0000000000..751b789794 --- /dev/null +++ b/src/System.CommandLine.Subsystems/ValueSources/AggregateValueSource.cs @@ -0,0 +1,57 @@ +// 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.ValueSources; + +public sealed class AggregateValueSource : ValueSource +{ + private List> valueSources = []; + + internal AggregateValueSource(ValueSource firstSource, + ValueSource secondSource, + string? description = null, + params ValueSource[] otherSources) + { + valueSources.AddRange([firstSource, secondSource, .. otherSources]); + Description = description; + } + + + public override string? Description { get; } + + public bool PrecedenceAsEntered { get; set; } + + public override bool TryGetTypedValue(PipelineResult pipelineResult, out T? value) + { + var orderedSources = PrecedenceAsEntered + ? valueSources + : [.. valueSources.OrderBy(GetPrecedence)]; + foreach (var source in orderedSources) + { + if (source.TryGetTypedValue(pipelineResult, out var newValue)) + { + value = newValue; + return true; + } + } + value = default; + return false; + + } + + // TODO: Discuss precedence vs order entered for aggregates + internal static int GetPrecedence(ValueSource source) + { + return source switch + { + SimpleValueSource => 0, + RelativeToSymbolValueSource => 1, + CalculatedValueSource => 2, + //RelativeToConfigurationValueSource => 3, + RelativeToEnvironmentVariableValueSource => 4, + _ => 5 + }; + } +} + + diff --git a/src/System.CommandLine.Subsystems/ValueSources/CalculatedValueSource.cs b/src/System.CommandLine.Subsystems/ValueSources/CalculatedValueSource.cs new file mode 100644 index 0000000000..18b3e7d04e --- /dev/null +++ b/src/System.CommandLine.Subsystems/ValueSources/CalculatedValueSource.cs @@ -0,0 +1,31 @@ +// 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.ValueSources; + +// Find an example of when this is useful beyond Random and Guid. Is a time lag between building the CLI and validating important (DateTime.Now()) +public sealed class CalculatedValueSource : ValueSource +{ + private readonly Func<(bool success, T? value)> calculation; + + internal CalculatedValueSource(Func<(bool success, T? value)> calculation, string? description = null) + { + this.calculation = calculation; + Description = description; + } + + public override string? Description { get; } + + public override bool TryGetTypedValue(PipelineResult pipelineResult, out T? value) + { + (bool success, T? newValue) = calculation(); + if (success) + { + value = newValue; + return true; + } + value = default; + return false; + } +} + diff --git a/src/System.CommandLine.Subsystems/ValueSources/RelativeToEnvironmentVariableValueSource.cs b/src/System.CommandLine.Subsystems/ValueSources/RelativeToEnvironmentVariableValueSource.cs new file mode 100644 index 0000000000..07faef3ff4 --- /dev/null +++ b/src/System.CommandLine.Subsystems/ValueSources/RelativeToEnvironmentVariableValueSource.cs @@ -0,0 +1,72 @@ +// 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.ValueSources; + +/// +/// that returns the converted value of the specified environment variable. +/// If the calculation delegate is supplied, the returned value of the calculation is returned. +/// +/// The type to be returned, which is almost always the type of the symbol the ValueSource will be used for. +/// The name of then environment variable. Note that for some systems, this is case sensitive. +/// A delegate that returns the requested type. If it is not specified, standard type conversions are used. +/// The description of this value, used to clarify the intent of the values that appear in error messages. +public sealed class RelativeToEnvironmentVariableValueSource + : ValueSource +{ + internal RelativeToEnvironmentVariableValueSource( + string environmentVariableName, + Func? calculation = null, + string? description = null) + { + EnvironmentVariableName = environmentVariableName; + Calculation = calculation; + Description = description; + } + + public string EnvironmentVariableName { get; } + public Func? Calculation { get; } + + /// + /// The description of this value, used to clarify the intent of the values that appear in error messages. + /// + public override string? Description { get; } + + public override bool TryGetTypedValue(PipelineResult pipelineResult, out T? value) + { + string? stringValue = Environment.GetEnvironmentVariable(EnvironmentVariableName); + + if (stringValue is null) + { + value = default; + return false; + } + + // TODO: Unify this with System.CommandLine.ArgumentConverter conversions, which will require changes to that code. + // This will provide consistency, including support for nullable value types, and custom type conversions + try + { + if (Calculation is not null) + { + (var success, var calcValue) = Calculation(stringValue); + if (success) + { + value = calcValue; + return true; + } + value = default; + return false; + } + var newValue = Convert.ChangeType(stringValue, typeof(T)); + value = (T?)newValue; + return true; + } + catch + { + // TODO: This probably represents a failure converting from string, so in user's world to fix. How do we report this? + value = default; + return false; + } + } +} + diff --git a/src/System.CommandLine.Subsystems/ValueSources/RelativeToSymbolValueSource.cs b/src/System.CommandLine.Subsystems/ValueSources/RelativeToSymbolValueSource.cs new file mode 100644 index 0000000000..a53e13c573 --- /dev/null +++ b/src/System.CommandLine.Subsystems/ValueSources/RelativeToSymbolValueSource.cs @@ -0,0 +1,60 @@ +// 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.ValueSources; + +/// +/// that returns the value of the specified other symbol. +/// If the calculation delegate is supplied, the returned value of the calculation is returned. +/// +/// The type to be returned, which is almost always the type of the symbol the ValueSource will be used for. +/// The option or argument to return, with the calculation supplied if it is not null. +/// A delegate that returns the requested type. +/// The description of this value, used to clarify the intent of the values that appear in error messages. +public sealed class RelativeToSymbolValueSource + : ValueSource +{ + internal RelativeToSymbolValueSource( + CliValueSymbol otherSymbol, + bool onlyUserEnteredValues = false, + Func? calculation = null, + string? description = null) + { + OtherSymbol = otherSymbol; + OnlyUserEnteredValues = onlyUserEnteredValues; + Calculation = calculation; + Description = description; + } + + public override string? Description { get; } + public CliValueSymbol OtherSymbol { get; } + public bool OnlyUserEnteredValues { get; } + public Func? Calculation { get; } + + /// + public override bool TryGetTypedValue(PipelineResult pipelineResult, out T? value) + { + if (OnlyUserEnteredValues && pipelineResult.GetValueResult(OtherSymbol) is null) + { + value = default; + return false; + } + + var otherSymbolValue = pipelineResult.GetValue(OtherSymbol); + + if (Calculation is null) + { + value = otherSymbolValue; + return true; + } + (var success, var newValue) = Calculation(otherSymbolValue); + if (success) + { + value = newValue; + return true; + } + value = default; + return false; + } +} + diff --git a/src/System.CommandLine.Subsystems/ValueSources/SimpleValueSource.cs b/src/System.CommandLine.Subsystems/ValueSources/SimpleValueSource.cs new file mode 100644 index 0000000000..6732f8268e --- /dev/null +++ b/src/System.CommandLine.Subsystems/ValueSources/SimpleValueSource.cs @@ -0,0 +1,24 @@ +// 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.ValueSources; + +public sealed class SimpleValueSource + : ValueSource +{ + internal SimpleValueSource(T value, string? description = null) + { + Value = value; + Description = description; + } + + public T Value { get; } + public override string? Description { get; } + + public override bool TryGetTypedValue(PipelineResult pipelineResult, out T? value) + { + value = Value; + return true; + } +} + diff --git a/src/System.CommandLine.Subsystems/ValueSources/ValueSource.cs b/src/System.CommandLine.Subsystems/ValueSources/ValueSource.cs new file mode 100644 index 0000000000..43098896ed --- /dev/null +++ b/src/System.CommandLine.Subsystems/ValueSources/ValueSource.cs @@ -0,0 +1,83 @@ +// 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.Diagnostics.CodeAnalysis; + +namespace System.CommandLine.ValueSources; + +public abstract class ValueSource +{ + internal ValueSource() + { + + } + + /// + /// Supplies the requested value, with the calculation applied if it is not null. + /// + /// The current pipeline result. + /// An out parameter which contains the converted value, with the calculation applied, if it is found. + /// True if a value was found, otherwise false. + public abstract bool TryGetValue(PipelineResult pipelineResult, out object? value); + + // TODO: Should we use ToString() here? + public abstract string? Description { get; } + + public static ValueSource Create(T value, string? description = null) + => new SimpleValueSource(value, description); + + public static ValueSource Create(Func<(bool success, T? value)> calculation, + string? description = null) + => new CalculatedValueSource(calculation, description); + + public static ValueSource Create(CliValueSymbol otherSymbol, + Func? calculation = null, + bool userEnteredValueOnly = false, + string? description = null) + => new RelativeToSymbolValueSource(otherSymbol, userEnteredValueOnly, calculation, description); + + public static ValueSource Create(ValueSource firstSource, + ValueSource secondSource, + string? description = null, + params ValueSource[] otherSources) + => new AggregateValueSource(firstSource, secondSource, description, otherSources); + + public static ValueSource CreateFromEnvironmentVariable(string environmentVariableName, + Func? calculation = null, + string? description = null) + => new RelativeToEnvironmentVariableValueSource(environmentVariableName, calculation, description); +} + +// TODO: Determine philosophy for custom value sources and whether they can build on existing sources. +public abstract class ValueSource : ValueSource +{ + /// + /// Supplies the requested value, with the calculation applied if it is not null. + /// + /// The current pipeline result. + /// An out parameter which contains the converted value, with the calculation applied, if it is found. + /// True if a value was found, otherwise false. + // TODO: Determine whether this and `TryGetValue` should have NotNullWhen(true) attribute. Discussion in /OpenQuestions.md + public abstract bool TryGetTypedValue(PipelineResult pipelineResult, + out T? value); + + /// + public override bool TryGetValue(PipelineResult pipelineResult, + out object? value) + { + + if (TryGetTypedValue(pipelineResult, out T? newValue)) + { + value = newValue; + return true; + } + value = null; + return false; + } + + public static implicit operator ValueSource(T value) => new SimpleValueSource(value); + public static implicit operator ValueSource(Func<(bool success, T? value)> calculated) => new CalculatedValueSource(calculated); + public static implicit operator ValueSource(CliValueSymbol symbol) => new RelativeToSymbolValueSource(symbol); + // Environment variable does not have an explicit operator, because converting to string was too broad +} + diff --git a/src/System.CommandLine.Subsystems/VersionSubsystem.cs b/src/System.CommandLine.Subsystems/VersionSubsystem.cs new file mode 100644 index 0000000000..1aac8b75a1 --- /dev/null +++ b/src/System.CommandLine.Subsystems/VersionSubsystem.cs @@ -0,0 +1,60 @@ +// 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.CommandLine.Subsystems; +using System.CommandLine.Subsystems.Annotations; +using System.Reflection; + +namespace System.CommandLine; + +public class VersionSubsystem : CliSubsystem +{ + private string? specificVersion = null; + + public VersionSubsystem(IAnnotationProvider? annotationProvider = null) + : base(VersionAnnotations.Prefix, SubsystemKind.Version, annotationProvider) + { + } + + // TODO: Should we block adding version anywhere but root? + public string? SpecificVersion + { + get + { + var version = specificVersion is null + ? AssemblyVersion(Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly()) + : specificVersion; + return version ?? ""; + } + set => specificVersion = value; + } + + public static string? AssemblyVersion(Assembly assembly) + => assembly + ?.GetCustomAttribute() + ?.InformationalVersion; + + protected internal override void Initialize(InitializationContext context) + { + var option = new CliOption("--version", ["-v"]) + { + Arity = ArgumentArity.Zero + }; + context.Configuration.RootCommand.Add(option); + } + + // TODO: Stash option rather than using string + protected internal override bool GetIsActivated(ParseResult? parseResult) + => parseResult is not null && parseResult.GetValue("--version"); + + public override void Execute(PipelineResult pipelineResult) + { + var subsystemVersion = SpecificVersion; + var version = subsystemVersion is null + ? CliExecutable.ExecutableVersion + : subsystemVersion; + pipelineResult.ConsoleHack.WriteLine(version); + pipelineResult.SetSuccess(); + } +} + diff --git a/src/System.CommandLine.Subsystems/docs-for-cli-authors.md b/src/System.CommandLine.Subsystems/docs-for-cli-authors.md new file mode 100644 index 0000000000..c6eca3b19b --- /dev/null +++ b/src/System.CommandLine.Subsystems/docs-for-cli-authors.md @@ -0,0 +1,9 @@ +# Docs for CLI authors + +This is a space for tentative aspirational documentation + +## Basic usage for full featured parser + +* Create a custom parser by adding options, arguments and commands + * The process of adding options, arguments and commands is the same for the parser and subcommands +* Run StandardPipeline.Execute(args) \ No newline at end of file diff --git a/src/System.CommandLine.Subsystems/docs-for-cli-extenders.md b/src/System.CommandLine.Subsystems/docs-for-cli-extenders.md new file mode 100644 index 0000000000..04c52ee956 --- /dev/null +++ b/src/System.CommandLine.Subsystems/docs-for-cli-extenders.md @@ -0,0 +1,43 @@ +# Docs for folks extending Systemm.CommandLine subsystem + +This is a space for tentative aspirational documentation + +There are a few ways to extend System.CommandLine subsystems + +* Replace an existing subsystem, such as replacing Help. +* Add a new subsystem we did not implement. +* Supply multiple subsystems for an existing category, such as running multiple Help subsystems. + +This design is based on the following assumptions: + +* There will be between 10 and 10,000 CLI authors for every extender. +* There will be more replacement of existing subsystems than creation of new ones. +* CLI authors will often want to replace subsystems, especially help. +* Some folks will want extreme extensibility. +* Data needs to be exchanged between subsystems (this is the area of most significant change from prior versions). + +## Replacing an existing subsystem or adding a new one + +* Inherit from the existing subsystem or CliSubsystem +* Override `GetIsActivated`, unless your subsystem should never run (such as you have initialization only behavior): + * You will generally not need to do this except for new subsystems that need to add triggers. + * If your subsystem should run even if another subsystem has handled execution (extremely rare), set `ExecuteEvenIfAlreadyHandled` +* Override `Initialize` if needed: + * You will generally not need to do this except for new subsystems that need to respond to their triggers. + * Delay as much work as possible until it is actually needed. +* Override `Execute`: + * Ensure that output is sent to `Console` on the pipeline, not directly to `StdOut`, `StdErr` or `System.Console` +* To manage data: + * For every piece data value, create a public `Get...` and `Set...` method using the accessor pattern that allows CLI authors to use the `With` extension method and implicitly converts to string (replace "Description" with the name of your data value in 6 places and possibly change the type in 2 places): + +```csharp + public void SetDescription(CliSymbol symbol, string description) + => SetAnnotation(symbol, HelpAnnotations.Description, description); + + public AnnotationAccessor Description + => new(this, HelpAnnotations.Description); +``` + +* Let folks know to add your subsystem, or provide an alternative to StandardPipeline. + + diff --git a/src/System.CommandLine.Suggest.Tests/DotnetSuggestEndToEndTests.cs b/src/System.CommandLine.Suggest.Tests/DotnetSuggestEndToEndTests.cs index 7e08fc8d65..2f34068045 100644 --- a/src/System.CommandLine.Suggest.Tests/DotnetSuggestEndToEndTests.cs +++ b/src/System.CommandLine.Suggest.Tests/DotnetSuggestEndToEndTests.cs @@ -71,7 +71,7 @@ private static void PrepareTestHomeDirectoryToAvoidPolluteBuildMachineHome() Directory.CreateDirectory(_testRoot); } - [ReleaseBuildOnlyFact] + [ReleaseBuildOnlyFact(Skip = "Temp E2EApp doesn't have completions wired up yet")] public void Test_app_supplies_suggestions() { var stdOut = new StringBuilder(); @@ -87,7 +87,7 @@ public void Test_app_supplies_suggestions() .Be($"--apple{NewLine}--banana{NewLine}--durian{NewLine}"); } - [ReleaseBuildOnlyFact] + [ReleaseBuildOnlyFact(Skip = "Temp E2EApp doesn't have completions wired up yet")] public void Dotnet_suggest_provides_suggestions_for_app() { // run "dotnet-suggest register" in explicit way @@ -122,7 +122,7 @@ public void Dotnet_suggest_provides_suggestions_for_app() .Be($"--apple{NewLine}--banana{NewLine}--durian{NewLine}"); } - [ReleaseBuildOnlyFact] + [ReleaseBuildOnlyFact(Skip ="Temp E2EApp doesn't have completions wired up yet")] public void Dotnet_suggest_provides_suggestions_for_app_with_only_commandname() { // run "dotnet-suggest register" in explicit way diff --git a/src/System.CommandLine.Suggest.Tests/EndToEndTestApp/EndToEndTestApp.csproj b/src/System.CommandLine.Suggest.Tests/EndToEndTestApp/EndToEndTestApp.csproj index a10ab84566..b41dabb7c2 100644 --- a/src/System.CommandLine.Suggest.Tests/EndToEndTestApp/EndToEndTestApp.csproj +++ b/src/System.CommandLine.Suggest.Tests/EndToEndTestApp/EndToEndTestApp.csproj @@ -7,7 +7,7 @@ Exe $(TargetFrameworkForNETSDK) - win-x64;linux-x64;osx-x64 + win-x64;linux-x64;osx-x64;osx-arm64 diff --git a/src/System.CommandLine.Suggest.Tests/EndToEndTestApp/Program.cs b/src/System.CommandLine.Suggest.Tests/EndToEndTestApp/Program.cs index 637732152a..d5784546bc 100644 --- a/src/System.CommandLine.Suggest.Tests/EndToEndTestApp/Program.cs +++ b/src/System.CommandLine.Suggest.Tests/EndToEndTestApp/Program.cs @@ -1,5 +1,7 @@ using System.CommandLine; +/* using System.CommandLine.Help; +*/ using System.CommandLine.Parsing; using System.Threading.Tasks; using System.Threading; @@ -8,7 +10,7 @@ namespace EndToEndTestApp { public class Program { - static async Task Main(string[] args) + static void Main(string[] args) { CliOption appleOption = new ("--apple" ); CliOption bananaOption = new ("--banana"); @@ -23,19 +25,10 @@ static async Task Main(string[] args) durianOption, }; - rootCommand.SetAction((ParseResult ctx, CancellationToken cancellationToken) => - { - string apple = ctx.GetValue(appleOption); - string banana = ctx.GetValue(bananaOption); - string cherry = ctx.GetValue(cherryOption); - string durian = ctx.GetValue(durianOption); - - return Task.CompletedTask; - }); - CliConfiguration commandLine = new (rootCommand); - await commandLine.InvokeAsync(args); + var result = CliParser.Parse(commandLine.RootCommand, args, commandLine); + } } } diff --git a/src/System.CommandLine.Suggest.Tests/SuggestionDispatcherTests.cs b/src/System.CommandLine.Suggest.Tests/SuggestionDispatcherTests.cs index a0a5c2094d..69277aabd3 100644 --- a/src/System.CommandLine.Suggest.Tests/SuggestionDispatcherTests.cs +++ b/src/System.CommandLine.Suggest.Tests/SuggestionDispatcherTests.cs @@ -14,6 +14,7 @@ namespace System.CommandLine.Suggest.Tests { public class SuggestionDispatcherTests { + /* private static readonly string _currentExeName = CliRootCommand.ExecutableName; private static readonly string _dotnetExeFullPath = @@ -237,5 +238,6 @@ public string GetCompletions(string exeFileName, string suggestionTargetArgument return _getSuggestions(exeFileName, suggestionTargetArguments, timeout); } } + */ } } diff --git a/src/System.CommandLine.Suggest.Tests/SuggestionShellScriptHandlerTest.cs b/src/System.CommandLine.Suggest.Tests/SuggestionShellScriptHandlerTest.cs index 151e17eddc..cde8985ceb 100644 --- a/src/System.CommandLine.Suggest.Tests/SuggestionShellScriptHandlerTest.cs +++ b/src/System.CommandLine.Suggest.Tests/SuggestionShellScriptHandlerTest.cs @@ -10,6 +10,7 @@ namespace System.CommandLine.Suggest.Tests { public class SuggestionShellScriptHandlerTest { + /* private readonly CliConfiguration _configuration; public SuggestionShellScriptHandlerTest() @@ -62,5 +63,6 @@ public async Task It_should_print_zsh_shell_script() _configuration.Output.ToString().Should().Contain("_dotnet_zsh_complete()"); _configuration.Output.ToString().Should().NotContain("\r\n"); } + */ } } diff --git a/src/System.CommandLine.Suggest/SuggestionDispatcher.cs b/src/System.CommandLine.Suggest/SuggestionDispatcher.cs index eea48e5a67..34660e1351 100644 --- a/src/System.CommandLine.Suggest/SuggestionDispatcher.cs +++ b/src/System.CommandLine.Suggest/SuggestionDispatcher.cs @@ -6,12 +6,13 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using System.CommandLine.Completions; +//using System.CommandLine.Completions; namespace System.CommandLine.Suggest { public class SuggestionDispatcher { + /* private readonly ISuggestionRegistration _suggestionRegistration; private readonly ISuggestionStore _suggestionStore; @@ -289,5 +290,11 @@ public static string FormatSuggestionArguments( return $"{suggestDirective} \"{commandLine.Escape()}\""; } + */ + + // Adding these to allow compilation with code commented out + public SuggestionDispatcher(ISuggestionRegistration suggestionRegistration) { } + + public Task InvokeAsync(string[] args) => throw new NotImplementedException(); } } \ No newline at end of file diff --git a/src/System.CommandLine.Suggest/dotnet-suggest.csproj b/src/System.CommandLine.Suggest/dotnet-suggest.csproj index f0ae84d57d..1676a14195 100644 --- a/src/System.CommandLine.Suggest/dotnet-suggest.csproj +++ b/src/System.CommandLine.Suggest/dotnet-suggest.csproj @@ -7,7 +7,7 @@ true dotnet-suggest dotnet-suggest - win-x64;win-x86;osx-x64;linux-x64 + win-x64;win-x86;osx-x64;linux-x64;osx-arm64 $(OutputPath) .1 diff --git a/src/System.CommandLine.Tests/CommandTests.cs b/src/System.CommandLine.Tests/CommandTests.cs index 8e2157932d..628068d7ce 100644 --- a/src/System.CommandLine.Tests/CommandTests.cs +++ b/src/System.CommandLine.Tests/CommandTests.cs @@ -42,10 +42,10 @@ public void Outer_command_is_identified_correctly_by_Parent_property() var result = _outerCommand.Parse("outer inner --option argument1"); result - .CommandResult + .CommandResultInternal .Parent .Should() - .BeOfType() + .BeOfType() .Which .Command .Name @@ -58,9 +58,9 @@ public void Inner_command_is_identified_correctly() { var result = _outerCommand.Parse("outer inner --option argument1"); - result.CommandResult + result.CommandResultInternal .Should() - .BeOfType() + .BeOfType() .Which .Command .Name @@ -73,7 +73,7 @@ public void Inner_command_option_is_identified_correctly() { var result = _outerCommand.Parse("outer inner --option argument1"); - result.CommandResult + result.CommandResultInternal .Children .ElementAt(0) .Should() @@ -90,7 +90,7 @@ public void Inner_command_option_argument_is_identified_correctly() { var result = _outerCommand.Parse("outer inner --option argument1"); - result.CommandResult + result.CommandResultInternal .Children .ElementAt(0) .Tokens @@ -114,14 +114,14 @@ public void Commands_at_multiple_levels_can_have_their_own_arguments() var result = outer.Parse("outer arg1 inner arg2 arg3"); - result.CommandResult + result.CommandResultInternal .Parent .Tokens .Select(t => t.Value) .Should() .BeEquivalentTo("arg1"); - result.CommandResult + result.CommandResultInternal .Tokens .Select(t => t.Value) .Should() @@ -200,7 +200,7 @@ public void ParseResult_Command_identifies_innermost_command(string input, strin var result = outer.Parse(input); - result.CommandResult.Command.Name.Should().Be(expectedCommand); + result.CommandResultInternal.Command.Name.Should().Be(expectedCommand); } [Fact] @@ -214,7 +214,7 @@ public void Commands_can_have_aliases() var result = command.Parse("that"); - result.CommandResult.Command.Should().BeSameAs(command); + result.CommandResultInternal.Command.Should().BeSameAs(command); result.Errors.Should().BeEmpty(); } @@ -228,7 +228,7 @@ public void RootCommand_can_have_aliases() var result = command.Parse("that"); - result.CommandResult.Command.Should().BeSameAs(command); + result.CommandResultInternal.Command.Should().BeSameAs(command); result.Errors.Should().BeEmpty(); } @@ -245,7 +245,7 @@ public void Subcommands_can_have_aliases() var result = rootCommand.Parse("that"); - result.CommandResult.Command.Should().BeSameAs(subcommand); + result.CommandResultInternal.Command.Should().BeSameAs(subcommand); result.Errors.Should().BeEmpty(); } diff --git a/src/System.CommandLine.Tests/CustomParsingTests.cs b/src/System.CommandLine.Tests/CustomParsingTests.cs index e29c5ba32e..b1fe4223e1 100644 --- a/src/System.CommandLine.Tests/CustomParsingTests.cs +++ b/src/System.CommandLine.Tests/CustomParsingTests.cs @@ -95,7 +95,7 @@ public void Validation_failure_message_can_be_specified_when_parsing_tokens() new CliRootCommand { argument }.Parse("x") .Errors .Should() - .ContainSingle(e => ((ArgumentResult)e.SymbolResult).Argument == argument) + .ContainSingle(e => ((ArgumentResult)e.SymbolResultInternal).Argument == argument) .Which .Message .Should() @@ -117,7 +117,7 @@ public void Validation_failure_message_can_be_specified_when_evaluating_default_ new CliRootCommand { argument }.Parse("") .Errors .Should() - .ContainSingle(e => ((ArgumentResult)e.SymbolResult).Argument == argument) + .ContainSingle(e => ((ArgumentResult)e.SymbolResultInternal).Argument == argument) .Which .Message .Should() @@ -256,7 +256,7 @@ public void Option_ArgumentResult_parentage_to_root_symbol_is_set_correctly_when .Parent .Parent .Should() - .BeOfType() + .BeOfType() .Which .Command .Should() @@ -268,7 +268,7 @@ public void Option_ArgumentResult_parentage_to_root_symbol_is_set_correctly_when [InlineData("-y value-y -x value-x")] public void Symbol_can_be_found_without_explicitly_traversing_result_tree(string commandLine) { - SymbolResult resultForOptionX = null; + CliSymbolResultInternal resultForOptionX = null; var optionX = new CliOption("-x") { CustomParser = _ => string.Empty @@ -322,7 +322,7 @@ public void Command_ArgumentResult_Parent_is_set_correctly_when_token_is_implici argumentResult .Parent .Should() - .BeOfType() + .BeOfType() .Which .Command .Should() @@ -447,7 +447,7 @@ public void Custom_parser_can_check_another_option_result_for_custom_errors(stri var parseResult = command.Parse(commandLine); parseResult.Errors - .Single(e => e.SymbolResult is OptionResult optResult && + .Single(e => e.SymbolResultInternal is OptionResult optResult && optResult.Option == optionThatDependsOnOptionWithError) .Message .Should() @@ -482,8 +482,8 @@ public void Validation_reports_all_parse_errors() 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); + parseResult.Errors.Should().Contain(error => error.SymbolResultInternal == firstOptionResult); + parseResult.Errors.Should().Contain(error => error.SymbolResultInternal == secondOptionResult); } [Fact] @@ -504,7 +504,7 @@ public void When_custom_conversion_fails_then_an_option_does_not_accept_further_ var result = command.Parse("the-command -x nope yep"); - result.CommandResult.Tokens.Count.Should().Be(1); + result.CommandResultInternal.Tokens.Count.Should().Be(1); } [Fact] diff --git a/src/System.CommandLine.Tests/ParseResultTests.cs b/src/System.CommandLine.Tests/ParseResultTests.cs index b8f9948e93..207e6d732d 100644 --- a/src/System.CommandLine.Tests/ParseResultTests.cs +++ b/src/System.CommandLine.Tests/ParseResultTests.cs @@ -94,12 +94,12 @@ public void Command_will_not_accept_a_command_if_a_sibling_command_has_already_b var result = CliParser.Parse(command, "outer inner-one inner-two"); - result.CommandResult.Command.Name.Should().Be("inner-one"); + result.CommandResultInternal.Command.Name.Should().Be("inner-one"); result.Errors.Count.Should().Be(1); var result2 = CliParser.Parse(command, "outer inner-two inner-one"); - result2.CommandResult.Command.Name.Should().Be("inner-two"); + result2.CommandResultInternal.Command.Name.Should().Be("inner-two"); result2.Errors.Count.Should().Be(1); } diff --git a/src/System.CommandLine.Tests/ParseResultValueTests.cs b/src/System.CommandLine.Tests/ParseResultValueTests.cs new file mode 100644 index 0000000000..b3d7f986e2 --- /dev/null +++ b/src/System.CommandLine.Tests/ParseResultValueTests.cs @@ -0,0 +1,59 @@ +// 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.CommandLine.Parsing; +using FluentAssertions; +using FluentAssertions.Execution; +using Xunit; + +namespace System.CommandLine.Tests; + +public class ParseResultValueTests +{ + [Fact] + public void Symbol_found_by_name() + { + var option1 = new CliOption("--opt1"); + var option2 = new CliOption("--opt2"); + + var rootCommand = new CliRootCommand + { + option1, + option2 + }; + + var parseResult = CliParser.Parse(rootCommand, "--opt1 Kirk"); + + var symbol1 = parseResult.GetSymbolByName("--opt1"); + var symbol2 = parseResult.GetSymbolByName("--opt2"); + using (new AssertionScope()) + { + symbol1.Should().Be(option1, "because option1 should be found for --opt1" ); + symbol2.Should().Be(option2, "because option2 should be found for --opt2"); + } + } + + [Fact] + public void Nearest_symbol_found_when_multiple() + { + // both options have the same name as that is the point of the test + var optionA = new CliOption("--opt1", "-1"); + var optionB = new CliOption("--opt1", "-2"); + + var command = new CliCommand("subcommand") + { + optionB + }; + + var rootCommand = new CliRootCommand + { + command, + optionA + }; + + var parseResult = CliParser.Parse(rootCommand, "subcommand"); + + var symbol = parseResult.GetSymbolByName("--opt1"); + symbol.Should().Be(optionB, "because it is closer to the leaf/executing command"); + } +} diff --git a/src/System.CommandLine.Tests/ParserTests.MultiplePositions.cs b/src/System.CommandLine.Tests/ParserTests.MultiplePositions.cs index 7c74880137..8480bb967f 100644 --- a/src/System.CommandLine.Tests/ParserTests.MultiplePositions.cs +++ b/src/System.CommandLine.Tests/ParserTests.MultiplePositions.cs @@ -136,10 +136,10 @@ public void A_command_can_be_specified_in_more_than_one_position( var result = outer.Parse(commandLine); result.Errors.Should().BeEmpty(); - result.CommandResult + result.CommandResultInternal .Parent .Should() - .BeOfType() + .BeOfType() .Which .Command .Name diff --git a/src/System.CommandLine.Tests/ParserTests.cs b/src/System.CommandLine.Tests/ParserTests.cs index 5618b41f0f..fffaa5b724 100644 --- a/src/System.CommandLine.Tests/ParserTests.cs +++ b/src/System.CommandLine.Tests/ParserTests.cs @@ -16,21 +16,36 @@ namespace System.CommandLine.Tests { public partial class ParserTests { + // TODO: Update testing strategy if we use Location in equality. Some will break + private readonly Location dummyLocation = new("", Location.Internal, -1, null); + private T GetValue(ParseResult parseResult, CliOption option) => parseResult.GetValue(option); private T GetValue(ParseResult parseResult, CliArgument argument) => parseResult.GetValue(argument); + //[Fact] + //public void FailureTest() + //{ + // Assert.True(false); + //} + [Fact] public void An_option_can_be_checked_by_object_instance() { var option = new CliOption("--flag"); var option2 = new CliOption("--flag2"); - var result = new CliRootCommand { option, option2 } - .Parse("--flag"); + var rootCommand = new CliRootCommand { option, option2 }; + var result = CliParser.Parse(rootCommand, ["--flag"]); result.GetResult(option).Should().NotBeNull(); + result.GetResult(option) + .Option + .Name + .Should() + .BeEquivalentTo("--flag"); + result.GetResult(option2).Should().BeNull(); } @@ -41,8 +56,9 @@ public void Two_options_are_parsed_correctly() var optionTwo = new CliOption("-t", "--two"); - var result = new CliRootCommand { optionOne, optionTwo }.Parse("-o -t"); - + var rootCommand = new CliRootCommand { optionOne, optionTwo }; + var result = CliParser.Parse(rootCommand, "-o -t"); + // TODO: consider more specific test result.GetResult(optionOne).Should().NotBeNull(); result.GetResult(optionTwo).Should().NotBeNull(); } @@ -52,20 +68,22 @@ public void Two_options_are_parsed_correctly() [InlineData("/")] public void When_a_token_is_just_a_prefix_then_an_error_is_returned(string prefix) { - var result = new CliRootCommand().Parse(prefix); + var rootCommand = new CliRootCommand(); + var result = CliParser.Parse(rootCommand, prefix); result.Errors - .Select(e => e.Message) - .Should() - .Contain(LocalizationResources.UnrecognizedCommandOrArgument(prefix)); + .Select(e => e.Message) + .Should() + .Contain(LocalizationResources.UnrecognizedCommandOrArgument(prefix)); } [Fact] public void Short_form_options_can_be_specified_using_equals_delimiter() { var option = new CliOption("-x"); + var rootCommand = new CliRootCommand { option }; - var result = new CliRootCommand { option }.Parse("-x=some-value"); + var result = CliParser.Parse(rootCommand, "-x=some-value"); result.Errors.Should().BeEmpty(); @@ -77,7 +95,8 @@ public void Long_form_options_can_be_specified_using_equals_delimiter() { var option = new CliOption("--hello"); - var result = new CliRootCommand { option }.Parse("--hello=there"); + var rootCommand = new CliRootCommand { option }; + var result = CliParser.Parse(rootCommand, "--hello=there"); result.Errors.Should().BeEmpty(); @@ -89,7 +108,8 @@ public void Short_form_options_can_be_specified_using_colon_delimiter() { var option = new CliOption("-x"); - var result = new CliRootCommand { option }.Parse("-x:some-value"); + var rootCommand = new CliRootCommand { option }; + var result = CliParser.Parse(rootCommand, "-x:some-value"); result.Errors.Should().BeEmpty(); @@ -101,7 +121,8 @@ public void Long_form_options_can_be_specified_using_colon_delimiter() { var option = new CliOption("--hello"); - var result = new CliRootCommand { option }.Parse("--hello:there"); + var rootCommand = new CliRootCommand { option }; + var result = CliParser.Parse(rootCommand, "--hello:there"); result.Errors.Should().BeEmpty(); @@ -116,18 +137,20 @@ public void Option_short_forms_can_be_bundled() command.Options.Add(new CliOption("-y")); command.Options.Add(new CliOption("-z")); - var result = command.Parse("the-command -xyz"); + var result = CliParser.Parse(command, "the-command -xyz"); result.CommandResult - .Children - .Select(o => ((OptionResult)o).Option.Name) - .Should() - .BeEquivalentTo("-x", "-y", "-z"); + .ValueResults + .Select(o => ((CliValueResult)o).ValueSymbol.Name) + .Should() + .BeEquivalentTo("-x", "-y", "-z"); } + /* Retain this test, but not sure how to test outcome. UnmatchedTokens removed for now. [Fact] public void Options_short_forms_do_not_get_unbundled_if_unbundling_is_turned_off() { + // TODO: unmatched tokens has been moved, fix CliRootCommand rootCommand = new CliRootCommand() { new CliCommand("the-command") @@ -143,12 +166,13 @@ public void Options_short_forms_do_not_get_unbundled_if_unbundling_is_turned_off EnablePosixBundling = false }; - var result = rootCommand.Parse("the-command -xyz", configuration); + var result = CliParser.Parse(rootCommand, "the-command -xyz", configuration); - result.UnmatchedTokens - .Should() - .BeEquivalentTo("-xyz"); + result.CommandResult.UnmatchedTokens + .Should() + .BeEquivalentTo("-xyz"); } + */ [Fact] public void Option_long_forms_do_not_get_unbundled() @@ -162,13 +186,13 @@ public void Option_long_forms_do_not_get_unbundled() new CliOption("-z") }; - var result = command.Parse("the-command --xyz"); + var result = CliParser.Parse(command, "the-command --xyz"); - result.CommandResult - .Children - .Select(o => ((OptionResult)o).Option.Name) - .Should() - .BeEquivalentTo("--xyz"); + result.CommandResultInternal + .Children + .Select(o => ((CliOptionResultInternal)o).Option.Name) + .Should() + .BeEquivalentTo("--xyz"); } [Fact] @@ -184,13 +208,13 @@ public void Options_do_not_get_unbundled_unless_all_resulting_options_would_be_v inner.Options.Add(new CliOption("-c")); outer.Subcommands.Add(inner); - ParseResult result = outer.Parse("outer inner -abc"); + ParseResult result = CliParser.Parse(outer, "outer inner -abc"); - result.CommandResult - .Tokens - .Select(t => t.Value) - .Should() - .BeEquivalentTo("-abc"); + result.CommandResultInternal + .Tokens + .Select(t => t.Value) + .Should() + .BeEquivalentTo("-abc"); } [Fact] @@ -207,14 +231,14 @@ public void Required_option_arguments_are_not_unbundled() optionC }; - var result = command.Parse("-a -bc"); + var result = CliParser.Parse(command, "-a -bc"); result.GetResult(optionA) - .Tokens - .Should() - .ContainSingle(t => t.Value == "-bc"); + .Tokens + .Should() + .ContainSingle(t => t.Value == "-bc"); } - + [Fact] public void Last_bundled_option_can_accept_argument_with_no_separator() { @@ -229,7 +253,7 @@ public void Last_bundled_option_can_accept_argument_with_no_separator() optionC }; - var result = command.Parse("-abcvalue"); + var result = CliParser.Parse(command, "-abcvalue"); result.GetResult(optionA).Should().NotBeNull(); result.GetResult(optionB).Should().NotBeNull(); @@ -253,7 +277,7 @@ public void Last_bundled_option_can_accept_argument_with_equals_separator() optionC }; - var result = command.Parse("-abc=value"); + var result = CliParser.Parse(command, "-abc=value"); result.GetResult(optionA).Should().NotBeNull(); result.GetResult(optionB).Should().NotBeNull(); @@ -277,7 +301,7 @@ public void Last_bundled_option_can_accept_argument_with_colon_separator() optionC }; - var result = command.Parse("-abc:value"); + var result = CliParser.Parse(command, "-abc:value"); result.GetResult(optionA).Should().NotBeNull(); result.GetResult(optionB).Should().NotBeNull(); @@ -301,7 +325,7 @@ public void Invalid_char_in_bundle_causes_rest_to_be_interpreted_as_value() optionC }; - var result = command.Parse("-abvcalue"); + var result = CliParser.Parse(command, "-abvcalue"); result.GetResult(optionA).Should().NotBeNull(); result.GetResult(optionB).Should().NotBeNull(); @@ -313,18 +337,19 @@ public void Invalid_char_in_bundle_causes_rest_to_be_interpreted_as_value() result.GetResult(optionC).Should().BeNull(); } + [Fact] public void Parser_root_Options_can_be_specified_multiple_times_and_their_arguments_are_collated() { var animalsOption = new CliOption("-a", "--animals"); var vegetablesOption = new CliOption("-v", "--vegetables"); - var parser = new CliRootCommand + var rootCommand = new CliRootCommand { animalsOption, vegetablesOption }; - var result = parser.Parse("-a cat -v carrot -a dog"); + var result = CliParser.Parse(rootCommand, "-a cat -v carrot -a dog"); result.GetResult(animalsOption) .Tokens @@ -343,7 +368,9 @@ public void Parser_root_Options_can_be_specified_multiple_times_and_their_argume public void Options_can_be_specified_multiple_times_and_their_arguments_are_collated() { var animalsOption = new CliOption("-a", "--animals"); - animalsOption.AcceptOnlyFromAmong("dog", "cat", "sheep"); + // TODO: tests AcceptOnlyFromAmong, fix + // TODO: This test does not appear to use AcceptOnlyFromAmong. Consider if test can just use normal strings + //animalsOption.AcceptOnlyFromAmong("dog", "cat", "sheep"); var vegetablesOption = new CliOption("-v", "--vegetables"); CliCommand command = new CliCommand("the-command") { @@ -351,19 +378,19 @@ public void Options_can_be_specified_multiple_times_and_their_arguments_are_coll vegetablesOption }; - var result = command.Parse("the-command -a cat -v carrot -a dog"); + var result = CliParser.Parse(command, "the-command -a cat -v carrot -a dog"); result.GetResult(animalsOption) - .Tokens - .Select(t => t.Value) - .Should() - .BeEquivalentTo("cat", "dog"); + .Tokens + .Select(t => t.Value) + .Should() + .BeEquivalentTo("cat", "dog"); result.GetResult(vegetablesOption) - .Tokens - .Select(t => t.Value) - .Should() - .BeEquivalentTo("carrot"); + .Tokens + .Select(t => t.Value) + .Should() + .BeEquivalentTo("carrot"); } [Fact] @@ -373,7 +400,7 @@ public void When_an_option_is_not_respecified_but_limit_is_reached_then_the_foll var vegetablesOption = new CliOption("-v", "--vegetables"); - CliCommand command = + CliCommand command = new CliCommand("the-command") { animalsOption, @@ -381,25 +408,25 @@ public void When_an_option_is_not_respecified_but_limit_is_reached_then_the_foll new CliArgument("arg") }; - var result = command.Parse("the-command -a cat some-arg -v carrot"); + var result = CliParser.Parse(command, "the-command -a cat some-arg -v carrot"); result.GetResult(animalsOption) - .Tokens - .Select(t => t.Value) - .Should() - .BeEquivalentTo("cat"); + .Tokens + .Select(t => t.Value) + .Should() + .BeEquivalentTo("cat"); result.GetResult(vegetablesOption) - .Tokens - .Select(t => t.Value) - .Should() - .BeEquivalentTo("carrot"); + .Tokens + .Select(t => t.Value) + .Should() + .BeEquivalentTo("carrot"); - result.CommandResult - .Tokens - .Select(t => t.Value) - .Should() - .BeEquivalentTo("some-arg"); + result.CommandResultInternal + .Tokens + .Select(t => t.Value) + .Should() + .BeEquivalentTo("some-arg"); } [Fact] @@ -411,23 +438,24 @@ public void Command_with_multiple_options_is_parsed_correctly() new CliOption("--inner2") }; - var result = command.Parse("outer --inner1 argument1 --inner2 argument2"); + var result = CliParser.Parse(command, "outer --inner1 argument1 --inner2 argument2"); - result.CommandResult - .Children - .Should() - .ContainSingle(o => - ((OptionResult)o).Option.Name == "--inner1" && - o.Tokens.Single().Value == "argument1"); - result.CommandResult - .Children - .Should() - .ContainSingle(o => - ((OptionResult)o).Option.Name == "--inner2" && - o.Tokens.Single().Value == "argument2"); + result.CommandResultInternal + .Children + .Should() + .ContainSingle(o => + ((CliOptionResultInternal)o).Option.Name == "--inner1" && + o.Tokens.Single().Value == "argument1"); + result.CommandResultInternal + .Children + .Should() + .ContainSingle(o => + ((CliOptionResultInternal)o).Option.Name == "--inner2" && + o.Tokens.Single().Value == "argument2"); } - [Fact] + [Fact(Skip = "Location means these are no longer equivalent.")] + // TODO: Add comparison that ignores locations public void Relative_order_of_arguments_and_options_within_a_command_does_not_matter() { var command = new CliCommand("move") @@ -437,32 +465,37 @@ public void Relative_order_of_arguments_and_options_within_a_command_does_not_ma }; // option before args - ParseResult result1 = command.Parse( + ParseResult result1 = CliParser.Parse( + command, "move -X the-arg-for-option-x ARG1 ARG2"); // option between two args - ParseResult result2 = command.Parse( + ParseResult result2 = CliParser.Parse( + command, "move ARG1 -X the-arg-for-option-x ARG2"); // option after args - ParseResult result3 = command.Parse( + ParseResult result3 = CliParser.Parse( + command, "move ARG1 ARG2 -X the-arg-for-option-x"); // all should be equivalent result1.Should() - .BeEquivalentTo( - result2, - x => x.IgnoringCyclicReferences() - .Excluding(y => y.WhichGetterHas(CSharpAccessModifier.Internal)) - .Excluding(y => y.WhichGetterHas(CSharpAccessModifier.PrivateProtected))); + .BeEquivalentTo( + result2, + x => x.IgnoringCyclicReferences() + .Excluding(y => y.WhichGetterHas(CSharpAccessModifier.Internal)) + .Excluding(y => y.WhichGetterHas(CSharpAccessModifier.PrivateProtected))); result1.Should() - .BeEquivalentTo( - result3, - x => x.IgnoringCyclicReferences() - .Excluding(y => y.WhichGetterHas(CSharpAccessModifier.Internal)) - .Excluding(y => y.WhichGetterHas(CSharpAccessModifier.PrivateProtected))); + .BeEquivalentTo( + result3, + x => x.IgnoringCyclicReferences() + .Excluding(y => y.WhichGetterHas(CSharpAccessModifier.Internal)) + .Excluding(y => y.WhichGetterHas(CSharpAccessModifier.PrivateProtected))); } + // TODO: Tests tokens which is no longer exposed, and should be replaced by tests of location or removed + /* [Theory] [InlineData("--one 1 --many 1 --many 2")] [InlineData("--one 1 --many 1 --many 2 arg1 arg2")] @@ -476,30 +509,33 @@ public void Original_order_of_tokens_is_preserved_in_ParseResult_Tokens(string c var rawSplit = CliParser.SplitCommandLine(commandLine); var command = new CliCommand("the-command") - { - new CliArgument("arg"), - new CliOption("--one"), - new CliOption("--many") - }; + { + new CliArgument("arg"), + new CliOption("--one"), + new CliOption("--many") + }; - var result = command.Parse(commandLine); + var result = CliParser.Parse(command, commandLine); result.Tokens.Select(t => t.Value).Should().Equal(rawSplit); } + */ + /* These two tests should remain in core, this method testing will not work in core. Figure out another way to validate result. [Fact] public void An_outer_command_with_the_same_name_does_not_capture() { + // TODO: uses Diagram, fix var command = new CliCommand("one") - { - new CliCommand("two") - { - new CliCommand("three") - }, - new CliCommand("three") - }; + { + new CliCommand("two") + { + new CliCommand("three") + }, + new CliCommand("three") + }; - ParseResult result = command.Parse("one two three"); + ParseResult result = CliParser.Parse(command, "one two three"); result.Diagram().Should().Be("[ one [ two [ three ] ] ]"); } @@ -507,25 +543,26 @@ public void An_outer_command_with_the_same_name_does_not_capture() [Fact] public void An_inner_command_with_the_same_name_does_not_capture() { + // TODO: uses Diagram, fix var command = new CliCommand("one") - { - new CliCommand("two") - { - new CliCommand("three") - }, - new CliCommand("three") - }; + { + new CliCommand("two") + { + new CliCommand("three") + }, + new CliCommand("three") + }; - ParseResult result = command.Parse("one three"); + ParseResult result = CliParser.Parse(command, "one three"); result.Diagram().Should().Be("[ one [ three ] ]"); } + */ [Fact] public void When_nested_commands_all_accept_arguments_then_the_nearest_captures_the_arguments() { - var command = new CliCommand( - "outer") + var command = new CliCommand("outer") { new CliArgument("arg1"), new CliCommand("inner") @@ -534,24 +571,26 @@ public void When_nested_commands_all_accept_arguments_then_the_nearest_captures_ } }; - var result = command.Parse("outer arg1 inner arg2"); + var result = CliParser.Parse(command, "outer arg1 inner arg2"); - result.CommandResult - .Parent - .Tokens.Select(t => t.Value) - .Should() - .BeEquivalentTo("arg1"); + result.CommandResultInternal + .Parent + .Tokens.Select(t => t.Value) + .Should() + .BeEquivalentTo("arg1"); - result.CommandResult - .Tokens - .Select(t => t.Value) - .Should() - .BeEquivalentTo("arg2"); + result.CommandResultInternal + .Tokens + .Select(t => t.Value) + .Should() + .BeEquivalentTo("arg2"); } + /* This test should remain in core, this method testing will not work in core. Figure out another way to validate result. [Fact] public void Nested_commands_with_colliding_names_cannot_both_be_applied() { + // TODO: uses Diagram, fix var command = new CliCommand("outer") { new CliArgument("arg1"), @@ -573,22 +612,23 @@ public void Nested_commands_with_colliding_names_cannot_both_be_applied() result.Diagram().Should().Be("[ outer [ inner [ non-unique ] ] ]"); } + */ [Fact] public void When_child_option_will_not_accept_arg_then_parent_can() { var option = new CliOption("-x"); var command = new CliCommand("the-command") - { - option, - new CliArgument("arg") - }; + { + option, + new CliArgument("arg") + }; - var result = command.Parse("the-command -x the-argument"); + var result = CliParser.Parse(command, "the-command -x the-argument"); var optionResult = result.GetResult(option); optionResult.Tokens.Should().BeEmpty(); - result.CommandResult.Tokens.Select(t => t.Value).Should().BeEquivalentTo("the-argument"); + result.CommandResultInternal.Tokens.Select(t => t.Value).Should().BeEquivalentTo("the-argument"); } [Fact] @@ -600,10 +640,10 @@ public void When_parent_option_will_not_accept_arg_then_child_can() option }; - var result = command.Parse("the-command -x the-argument"); + var result = CliParser.Parse(command, "the-command -x the-argument"); result.GetResult(option).Tokens.Select(t => t.Value).Should().BeEquivalentTo("the-argument"); - result.CommandResult.Tokens.Should().BeEmpty(); + result.CommandResultInternal.Tokens.Should().BeEmpty(); } [Fact] @@ -617,7 +657,7 @@ public void Required_arguments_on_parent_commands_do_not_create_parse_errors_whe child }; - var result = parent.Parse("child"); + var result = CliParser.Parse(parent, "child"); result.Errors.Should().BeEmpty(); } @@ -636,7 +676,7 @@ public void Required_arguments_on_grandparent_commands_do_not_create_parse_error } }; - var result = grandparent.Parse("parent grandchild"); + var result = CliParser.Parse(grandparent, "parent grandchild"); result.Errors.Should().BeEmpty(); } @@ -645,28 +685,28 @@ public void Required_arguments_on_grandparent_commands_do_not_create_parse_error public void When_options_with_the_same_name_are_defined_on_parent_and_child_commands_and_specified_at_the_end_then_it_attaches_to_the_inner_command() { var outer = new CliCommand("outer") - { - new CliCommand("inner") - { - new CliOption("-x") - }, - new CliOption("-x") - }; + { + new CliCommand("inner") + { + new CliOption("-x") + }, + new CliOption("-x") + }; - ParseResult result = outer.Parse("outer inner -x"); + ParseResult result = CliParser.Parse(outer, "outer inner -x"); - result.CommandResult - .Parent - .Should() - .BeOfType() - .Which - .Children - .Should() - .AllBeAssignableTo(); - result.CommandResult - .Children - .Should() - .ContainSingle(o => ((OptionResult)o).Option.Name == "-x"); + result.CommandResultInternal + .Parent + .Should() + .BeOfType() + .Which + .Children + .Should() + .AllBeAssignableTo(); + result.CommandResultInternal + .Children + .Should() + .ContainSingle(o => ((CliOptionResultInternal)o).Option.Name == "-x"); } [Fact] @@ -678,22 +718,23 @@ public void When_options_with_the_same_name_are_defined_on_parent_and_child_comm inner.Options.Add(new CliOption("-x")); outer.Subcommands.Add(inner); - var result = outer.Parse("outer -x inner"); + var result = CliParser.Parse(outer, "outer -x inner"); - result.CommandResult - .Children - .Should() - .BeEmpty(); - result.CommandResult - .Parent - .Should() - .BeOfType() - .Which - .Children - .Should() - .ContainSingle(o => o is OptionResult && ((OptionResult)o).Option.Name == "-x"); + result.CommandResultInternal + .Children + .Should() + .BeEmpty(); + result.CommandResultInternal + .Parent + .Should() + .BeOfType() + .Which + .Children + .Should() + .ContainSingle(o => o is CliOptionResultInternal && ((CliOptionResultInternal)o).Option.Name == "-x"); } + /* Tests unmatched tokens, needs fix [Fact] public void Arguments_only_apply_to_the_nearest_command() { @@ -708,20 +749,21 @@ public void Arguments_only_apply_to_the_nearest_command() ParseResult result = outer.Parse("outer inner arg1 arg2"); - result.CommandResult - .Parent - .Tokens - .Should() - .BeEmpty(); - result.CommandResult - .Tokens - .Select(t => t.Value) - .Should() - .BeEquivalentTo("arg1"); + result.CommandResultInternal + .Parent + .Tokens + .Should() + .BeEmpty(); + result.CommandResultInternal + .Tokens + .Select(t => t.Value) + .Should() + .BeEquivalentTo("arg1"); result.UnmatchedTokens - .Should() - .BeEquivalentTo("arg2"); + .Should() + .BeEquivalentTo("arg2"); } + */ [Fact] public void Options_only_apply_to_the_nearest_command() @@ -730,20 +772,20 @@ public void Options_only_apply_to_the_nearest_command() var innerOption = new CliOption("-x"); var outer = new CliCommand("outer") - { - new CliCommand("inner") - { - innerOption - }, - outerOption - }; + { + new CliCommand("inner") + { + innerOption + }, + outerOption + }; - var result = outer.Parse("outer inner -x one -x two"); + var result = CliParser.Parse(outer, "outer inner -x one -x two"); result.RootCommandResult - .GetResult(outerOption) - .Should() - .BeNull(); + .GetResult(outerOption) + .Should() + .BeNull(); } [Fact] @@ -758,13 +800,16 @@ public void Subsequent_occurrences_of_tokens_matching_command_names_are_parsed_a } }; - ParseResult result = command.Parse(new[] { "the-command", - "complete", - "--position", - "7", - "the-command" }); + ParseResult result = CliParser.Parse( + command, new[] { + "the-command", + "complete", + "--position", + "7", + "the-command" + }); - CommandResult completeResult = result.CommandResult; + CliCommandResultInternal completeResult = result.CommandResultInternal; completeResult.Tokens.Select(t => t.Value).Should().BeEquivalentTo("the-command"); } @@ -775,18 +820,18 @@ public void Absolute_unix_style_paths_are_lexed_correctly() const string commandText = @"rm ""/temp/the file.txt"""; - CliCommand command = new ("rm") + CliCommand command = new("rm") { new CliArgument("arg") }; - var result = command.Parse(commandText); + var result = CliParser.Parse(command, commandText); - result.CommandResult - .Tokens - .Select(t => t.Value) - .Should() - .OnlyContain(a => a == @"/temp/the file.txt"); + result.CommandResultInternal + .Tokens + .Select(t => t.Value) + .Should() + .OnlyContain(a => a == @"/temp/the file.txt"); } [Fact] @@ -800,14 +845,15 @@ public void Absolute_Windows_style_paths_are_lexed_correctly() new CliArgument("arg") }; - ParseResult result = command.Parse(commandText); + ParseResult result = CliParser.Parse(command, commandText); - result.CommandResult - .Tokens - .Should() - .OnlyContain(a => a.Value == @"c:\temp\the file.txt\"); + result.CommandResultInternal + .Tokens + .Should() + .OnlyContain(a => a.Value == @"c:\temp\the file.txt\"); } + /* These tests should be split and those using an explicit default value moved to subsystem, and those using the type default should remain in core (?). This might not be meaningful if the type conversion is correct. What value other than the type default could be used. [Fact] public void Commands_can_have_default_argument_values() { @@ -821,11 +867,11 @@ public void Commands_can_have_default_argument_values() argument }; - ParseResult result = command.Parse("command"); + ParseResult result = CliParser.Parse(command, "command"); GetValue(result, argument) - .Should() - .Be("default"); + .Should() + .Be("default"); } [Fact] @@ -838,7 +884,7 @@ public void When_an_option_with_a_default_value_is_not_matched_then_the_option_c }; command.Options.Add(option); - ParseResult result = command.Parse("command"); + ParseResult result = CliParser.Parse(command, "command"); result.GetResult(option).Should().NotBeNull(); GetValue(result, option).Should().Be("the-default"); @@ -857,12 +903,12 @@ public void When_an_option_with_a_default_value_is_not_matched_then_the_option_r option }; - var result = command.Parse("command"); + var result = CliParser.Parse(command, "command"); result.GetResult(option) - .Implicit - .Should() - .BeTrue(); + .Implicit + .Should() + .BeTrue(); } [Fact] @@ -878,12 +924,12 @@ public void When_an_option_with_a_default_value_is_not_matched_then_there_are_no option }; - var result = command.Parse("command"); + var result = CliParser.Parse(command, "command"); result.GetResult(option) - .IdentifierToken - .Should() - .BeEquivalentTo(default(CliToken)); + .IdentifierToken + .Should() + .BeEquivalentTo(default(CliToken)); } [Fact] @@ -898,12 +944,12 @@ public void When_an_argument_with_a_default_value_is_not_matched_then_there_are_ { argument }; - var result = command.Parse("command"); + var result = CliParser.Parse(command, "command"); result.GetResult(argument) - .Tokens - .Should() - .BeEmpty(); + .Tokens + .Should() + .BeEmpty(); } [Fact] @@ -919,13 +965,14 @@ public void Command_default_argument_value_does_not_override_parsed_value() argument }; - var result = command.Parse("the-directory"); + var result = CliParser.Parse(command, "the-directory"); GetValue(result, argument) - .Name - .Should() - .Be("the-directory"); + .Name + .Should() + .Be("the-directory"); } + */ [Fact] public void Unmatched_tokens_that_look_like_options_are_not_split_into_smaller_tokens() @@ -941,18 +988,20 @@ public void Unmatched_tokens_that_look_like_options_are_not_split_into_smaller_t } }; - ParseResult result = outer.Parse("outer inner -p:RandomThing=random"); + ParseResult result = CliParser.Parse(outer, "outer inner -p:RandomThing=random"); - result.CommandResult - .Tokens - .Select(t => t.Value) - .Should() - .BeEquivalentTo("-p:RandomThing=random"); + result.CommandResultInternal + .Tokens + .Select(t => t.Value) + .Should() + .BeEquivalentTo("-p:RandomThing=random"); } + /* Unmatched tokens [Fact] public void The_default_behavior_of_unmatched_tokens_resulting_in_errors_can_be_turned_off() { + // TODO: uses UnmatchedTokens, TreatUnmatchedTokensAsErrors, fix var command = new CliCommand("the-command") { new CliArgument("arg") @@ -964,9 +1013,10 @@ public void The_default_behavior_of_unmatched_tokens_resulting_in_errors_can_be_ result.Errors.Should().BeEmpty(); result.UnmatchedTokens - .Should() - .BeEquivalentTo("arg2"); + .Should() + .BeEquivalentTo("arg2"); } + */ [Fact] public void Option_and_Command_can_have_the_same_alias() @@ -985,33 +1035,33 @@ public void Option_and_Command_can_have_the_same_alias() new CliArgument("arg2") }; - outerCommand.Parse("outer inner") - .CommandResult - .Command - .Should() - .BeSameAs(innerCommand); + CliParser.Parse(outerCommand, "outer inner") + .CommandResultInternal + .Command + .Should() + .BeSameAs(innerCommand); - outerCommand.Parse("outer --inner") - .CommandResult - .Command - .Should() - .BeSameAs(outerCommand); + CliParser.Parse(outerCommand, "outer --inner") + .CommandResultInternal + .Command + .Should() + .BeSameAs(outerCommand); - outerCommand.Parse("outer --inner inner") - .CommandResult - .Command - .Should() - .BeSameAs(innerCommand); + CliParser.Parse(outerCommand, "outer --inner inner") + .CommandResultInternal + .Command + .Should() + .BeSameAs(innerCommand); - outerCommand.Parse("outer --inner inner") - .CommandResult - .Parent - .Should() - .BeOfType() - .Which - .Children - .Should() - .Contain(o => ((OptionResult)o).Option == option); + CliParser.Parse(outerCommand, "outer --inner inner") + .CommandResultInternal + .Parent + .Should() + .BeOfType() + .Which + .Children + .Should() + .Contain(o => ((CliOptionResultInternal)o).Option == option); } [Fact] @@ -1020,22 +1070,22 @@ public void Options_can_have_the_same_alias_differentiated_only_by_prefix() var option1 = new CliOption("-a"); var option2 = new CliOption("--a"); - var parser = new CliRootCommand + var rootCommand = new CliRootCommand { - option1, + option1, option2 }; - parser.Parse("-a").CommandResult - .Children - .Select(s => ((OptionResult)s).Option) - .Should() - .BeEquivalentTo(option1); - parser.Parse("--a").CommandResult - .Children - .Select(s => ((OptionResult)s).Option) - .Should() - .BeEquivalentTo(option2); + CliParser.Parse(rootCommand, "-a").CommandResultInternal + .Children + .Select(s => ((CliOptionResultInternal)s).Option) + .Should() + .BeEquivalentTo(option1); + CliParser.Parse(rootCommand, "--a").CommandResultInternal + .Children + .Select(s => ((CliOptionResultInternal)s).Option) + .Should() + .BeEquivalentTo(option2); } [Theory] @@ -1050,8 +1100,8 @@ public void When_an_option_argument_is_enclosed_in_double_quotes_its_value_retai string arg2) { var option = new CliOption("-x"); - - var parseResult = new CliRootCommand { option }.Parse(new[] { arg1, arg2 }); + var rootCommand = new CliRootCommand { option }; + var parseResult = CliParser.Parse(rootCommand, new[] { arg1, arg2 }); parseResult .GetResult(option) @@ -1061,8 +1111,9 @@ public void When_an_option_argument_is_enclosed_in_double_quotes_its_value_retai .BeEquivalentTo(new[] { arg2 }); } + /* Tests tokens which is no longer exposed, and should be replaced by tests of location or removed [Fact] // https://github.com/dotnet/command-line-api/issues/1445 - public void Trailing_option_delimiters_are_ignored() + public void Trailing_option_delimiters_are_ignored() // (colon after directory) { var rootCommand = new CliRootCommand { @@ -1074,15 +1125,16 @@ public void Trailing_option_delimiters_are_ignored() var args = new[] { "subcommand", "--directory:", @"c:\" }; - var result = rootCommand.Parse(args); + var result = CliParser.Parse(rootCommand, args); result.Errors.Should().BeEmpty(); result.Tokens - .Select(t => t.Value) - .Should() - .BeEquivalentSequenceTo(new[] { "subcommand", "--directory", @"c:\" }); + .Select(t => t.Value) + .Should() + .BeEquivalentSequenceTo(new[] { "subcommand", "--directory", @"c:\" }); } + */ [Theory] [InlineData("-x -y")] @@ -1098,11 +1150,11 @@ public void Option_arguments_can_start_with_prefixes_that_make_them_look_like_op new CliOption("-z") }; - var result = command.Parse(input); + var result = CliParser.Parse(command, input); GetValue(result, optionX).Should().Be("-y"); } - + [Fact] public void Option_arguments_can_start_with_prefixes_that_make_them_look_like_bundled_options() { @@ -1117,7 +1169,7 @@ public void Option_arguments_can_start_with_prefixes_that_make_them_look_like_bu optionC }; - var result = command.Parse("-a -bc"); + var result = CliParser.Parse(command, "-a -bc"); GetValue(result, optionA).Should().Be("-bc"); GetValue(result, optionB).Should().BeFalse(); @@ -1128,16 +1180,16 @@ public void Option_arguments_can_start_with_prefixes_that_make_them_look_like_bu public void Option_arguments_can_match_subcommands() { var optionA = new CliOption("-a"); - var root = new CliRootCommand + var rootCommand = new CliRootCommand { new CliCommand("subcommand"), optionA }; - var result = root.Parse("-a subcommand"); + var result = CliParser.Parse(rootCommand, "-a subcommand"); GetValue(result, optionA).Should().Be("subcommand"); - result.CommandResult.Command.Should().BeSameAs(root); + result.CommandResultInternal.Command.Should().BeSameAs(rootCommand); } [Fact] @@ -1148,18 +1200,18 @@ public void Arguments_can_match_subcommands() { argument }; - var root = new CliRootCommand + var rootCommand = new CliRootCommand { subcommand }; - var result = root.Parse("subcommand one two three subcommand four"); + var result = CliParser.Parse(rootCommand, "subcommand one two three subcommand four"); - result.CommandResult.Command.Should().BeSameAs(subcommand); + result.CommandResultInternal.Command.Should().BeSameAs(subcommand); GetValue(result, argument) - .Should() - .BeEquivalentSequenceTo("one", "two", "three", "subcommand", "four"); + .Should() + .BeEquivalentSequenceTo("one", "two", "three", "subcommand", "four"); } [Theory] @@ -1175,7 +1227,7 @@ public void Option_arguments_can_match_the_aliases_of_sibling_options_when_non_s new CliOption("-y") }; - var result = command.Parse(input); + var result = CliParser.Parse(command, input); result.Errors.Should().BeEmpty(); GetValue(result, optionX).Should().Be("-y"); @@ -1191,7 +1243,7 @@ public void Single_option_arguments_that_match_option_aliases_are_parsed_correct optionX }; - var result = command.Parse("-x -x"); + var result = CliParser.Parse(command, "-x -x"); GetValue(result, optionX).Should().Be("-x"); } @@ -1210,13 +1262,13 @@ public void Boolean_options_are_not_greedy(string commandLine) var optX = new CliOption("-x"); var optY = new CliOption("-y"); - var root = new CliRootCommand("parent") + var root = new CliRootCommand() { optX, optY, }; - var result = root.Parse(commandLine); + var result = CliParser.Parse(root, commandLine); result.Errors.Should().BeEmpty(); @@ -1236,7 +1288,7 @@ public void Multiple_option_arguments_that_match_multiple_arity_option_aliases_a optionY }; - var result = command.Parse("-x -x -x -y -y -x -y -y -y -x -x -y"); + var result = CliParser.Parse(command, "-x -x -x -y -y -x -y -y -y -x -x -y"); GetValue(result, optionX).Should().BeEquivalentTo(new[] { "-x", "-y", "-y" }); GetValue(result, optionY).Should().BeEquivalentTo(new[] { "-x", "-y", "-x" }); @@ -1254,7 +1306,7 @@ public void Bundled_option_arguments_that_match_option_aliases_are_parsed_correc optionY }; - var result = command.Parse("-yxx"); + var result = CliParser.Parse(command, "-yxx"); GetValue(result, optionX).Should().Be("x"); } @@ -1265,13 +1317,13 @@ public void Argument_name_is_not_matched_as_a_token() var nameArg = new CliArgument("name"); var columnsArg = new CliArgument>("columns"); - var command = new CliCommand("add", "Adds a new series") + var command = new CliCommand("add") { nameArg, columnsArg }; - var result = command.Parse("name one two three"); + var result = CliParser.Parse(command, "name one two three"); GetValue(result, nameArg).Should().Be("name"); GetValue(result, columnsArg).Should().BeEquivalentTo("one", "two", "three"); @@ -1282,7 +1334,8 @@ public void Option_aliases_do_not_need_to_be_prefixed() { var option = new CliOption("noprefix"); - var result = new CliRootCommand { option }.Parse("noprefix"); + var rootCommand = new CliRootCommand { option }; + var result = CliParser.Parse(rootCommand, "noprefix"); result.GetResult(option).Should().NotBeNull(); } @@ -1297,14 +1350,16 @@ public void Boolean_options_with_no_argument_specified_do_not_match_subsequent_a option }; - var result = command.Parse("-v an-argument"); + var result = CliParser.Parse(command, "-v an-argument"); GetValue(result, option).Should().BeTrue(); } + /* Unmatched tokens [Fact] public void When_a_command_line_has_unmatched_tokens_they_are_not_applied_to_subsequent_options() { + // TODO: uses TreatUnmatchedTokensAsErrors, fix var command = new CliCommand("command") { TreatUnmatchedTokensAsErrors = false @@ -1326,6 +1381,7 @@ public void When_a_command_line_has_unmatched_tokens_they_are_not_applied_to_sub [InlineData(false)] public void When_a_command_line_has_unmatched_tokens_the_parse_result_action_should_depend_on_parsed_command_TreatUnmatchedTokensAsErrors(bool treatUnmatchedTokensAsErrors) { + // TODO: uses TreatUnmatchedTokensAsErrors, fix CliRootCommand rootCommand = new(); CliCommand subcommand = new("vstest") { @@ -1343,18 +1399,19 @@ public void When_a_command_line_has_unmatched_tokens_the_parse_result_action_sho if (treatUnmatchedTokensAsErrors) { result.Errors.Should().NotBeEmpty(); - result.Action.Should().NotBeSameAs(result.CommandResult.Command.Action); + result.Action.Should().NotBeSameAs(result.CommandResultInternal.Command.Action); } else { result.Errors.Should().BeEmpty(); - result.Action.Should().BeSameAs(result.CommandResult.Command.Action); + result.Action.Should().BeSameAs(result.CommandResultInternal.Command.Action); } } [Fact] public void RootCommand_TreatUnmatchedTokensAsErrors_set_to_false_has_precedence_over_subcommands() { + // TODO: uses TreatUnmatchedTokensAsErrors, fix CliRootCommand rootCommand = new(); rootCommand.TreatUnmatchedTokensAsErrors = false; CliCommand subcommand = new("vstest") @@ -1371,13 +1428,14 @@ public void RootCommand_TreatUnmatchedTokensAsErrors_set_to_false_has_precedence result.UnmatchedTokens.Should().BeEquivalentTo("test1.dll", "test2.dll"); result.Errors.Should().BeEmpty(); - result.Action.Should().BeSameAs(result.CommandResult.Command.Action); + result.Action.Should().BeSameAs(result.CommandResultInternal.Command.Action); } + */ [Fact] public void Parse_can_not_be_called_with_null_args() { - Action passNull = () => new CliRootCommand().Parse(args: null); + Action passNull = () => CliParser.Parse(new CliRootCommand(), args: null); passNull.Should().Throw(); } @@ -1394,14 +1452,14 @@ public void Command_argument_arity_can_be_a_fixed_value_greater_than_1() argument }; - command.Parse("1 2 3") - .CommandResult - .Tokens - .Should() - .BeEquivalentTo( - new CliToken("1", CliTokenType.Argument, argument), - new CliToken("2", CliTokenType.Argument, argument), - new CliToken("3", CliTokenType.Argument, argument)); + CliParser.Parse(command, "1 2 3") + .CommandResultInternal + .Tokens + .Should() + .BeEquivalentTo( + new CliToken("1", CliTokenType.Argument, argument, dummyLocation), + new CliToken("2", CliTokenType.Argument, argument, dummyLocation), + new CliToken("3", CliTokenType.Argument, argument, dummyLocation)); } [Fact] @@ -1416,27 +1474,27 @@ public void Command_argument_arity_can_be_a_range_with_a_lower_bound_greater_tha argument }; - command.Parse("1 2 3") - .CommandResult - .Tokens - .Should() - .BeEquivalentTo( - new CliToken("1", CliTokenType.Argument, argument), - new CliToken("2", CliTokenType.Argument, argument), - new CliToken("3", CliTokenType.Argument, argument)); - command.Parse("1 2 3 4 5") - .CommandResult - .Tokens - .Should() - .BeEquivalentTo( - new CliToken("1", CliTokenType.Argument, argument), - new CliToken("2", CliTokenType.Argument, argument), - new CliToken("3", CliTokenType.Argument, argument), - new CliToken("4", CliTokenType.Argument, argument), - new CliToken("5", CliTokenType.Argument, argument)); + CliParser.Parse(command, "1 2 3") + .CommandResultInternal + .Tokens + .Should() + .BeEquivalentTo( + new CliToken("1", CliTokenType.Argument, argument, dummyLocation), + new CliToken("2", CliTokenType.Argument, argument, dummyLocation), + new CliToken("3", CliTokenType.Argument, argument, dummyLocation)); + CliParser.Parse(command, "1 2 3 4 5") + .CommandResultInternal + .Tokens + .Should() + .BeEquivalentTo( + new CliToken("1", CliTokenType.Argument, argument, dummyLocation), + new CliToken("2", CliTokenType.Argument, argument, dummyLocation), + new CliToken("3", CliTokenType.Argument, argument, dummyLocation), + new CliToken("4", CliTokenType.Argument, argument, dummyLocation), + new CliToken("5", CliTokenType.Argument, argument, dummyLocation)); } - [Fact] + [Fact(Skip = "Waiting for CliError work")] public void When_command_arguments_are_fewer_than_minimum_arity_then_an_error_is_returned() { var command = new CliCommand("the-command") @@ -1447,12 +1505,12 @@ public void When_command_arguments_are_fewer_than_minimum_arity_then_an_error_is } }; - var result = command.Parse("1"); + var result = CliParser.Parse(command, "1"); result.Errors - .Select(e => e.Message) - .Should() - .Contain(LocalizationResources.RequiredArgumentMissing(result.GetResult(command.Arguments[0]))); + .Select(e => e.Message) + .Should() + .Contain(LocalizationResources.RequiredArgumentMissing(result.GetResult(command.Arguments[0]))); } [Fact] @@ -1466,33 +1524,33 @@ public void When_command_arguments_are_greater_than_maximum_arity_then_an_error_ } }; - ParseResult parseResult = command.Parse("1 2 3 4"); + ParseResult parseResult = CliParser.Parse(command, "1 2 3 4"); parseResult - .Errors - .Select(e => e.Message) - .Should() - .Contain(LocalizationResources.UnrecognizedCommandOrArgument("4")); + .Errors + .Select(e => e.Message) + .Should() + .Contain(LocalizationResources.UnrecognizedCommandOrArgument("4")); } [Fact] public void Option_argument_arity_can_be_a_fixed_value_greater_than_1() { - var option = new CliOption("-x") { Arity = new ArgumentArity(3, 3)}; + var option = new CliOption("-x") { Arity = new ArgumentArity(3, 3) }; var command = new CliCommand("the-command") { option }; - command.Parse("-x 1 -x 2 -x 3") - .GetResult(option) - .Tokens - .Should() - .BeEquivalentTo( - new CliToken("1", CliTokenType.Argument, default), - new CliToken("2", CliTokenType.Argument, default), - new CliToken("3", CliTokenType.Argument, default)); + CliParser.Parse(command, "-x 1 -x 2 -x 3") + .GetResult(option) + .Tokens + .Should() + .BeEquivalentTo( + new CliToken("1", CliTokenType.Argument, default, dummyLocation), + new CliToken("2", CliTokenType.Argument, default, dummyLocation), + new CliToken("3", CliTokenType.Argument, default, dummyLocation)); } [Fact] @@ -1505,31 +1563,31 @@ public void Option_argument_arity_can_be_a_range_with_a_lower_bound_greater_than option }; - command.Parse("-x 1 -x 2 -x 3") - .GetResult(option) - .Tokens - .Should() - .BeEquivalentTo( - new CliToken("1", CliTokenType.Argument, default), - new CliToken("2", CliTokenType.Argument, default), - new CliToken("3", CliTokenType.Argument, default)); - command.Parse("-x 1 -x 2 -x 3 -x 4 -x 5") - .GetResult(option) - .Tokens - .Should() - .BeEquivalentTo( - new CliToken("1", CliTokenType.Argument, default), - new CliToken("2", CliTokenType.Argument, default), - new CliToken("3", CliTokenType.Argument, default), - new CliToken("4", CliTokenType.Argument, default), - new CliToken("5", CliTokenType.Argument, default)); + CliParser.Parse(command, "-x 1 -x 2 -x 3") + .GetResult(option) + .Tokens + .Should() + .BeEquivalentTo( + new CliToken("1", CliTokenType.Argument, default, dummyLocation), + new CliToken("2", CliTokenType.Argument, default, dummyLocation), + new CliToken("3", CliTokenType.Argument, default, dummyLocation)); + CliParser.Parse(command, "-x 1 -x 2 -x 3 -x 4 -x 5") + .GetResult(option) + .Tokens + .Should() + .BeEquivalentTo( + new CliToken("1", CliTokenType.Argument, default, dummyLocation), + new CliToken("2", CliTokenType.Argument, default, dummyLocation), + new CliToken("3", CliTokenType.Argument, default, dummyLocation), + new CliToken("4", CliTokenType.Argument, default, dummyLocation), + new CliToken("5", CliTokenType.Argument, default, dummyLocation)); } - [Fact] + [Fact(Skip = "Waiting for CliError work")] public void When_option_arguments_are_fewer_than_minimum_arity_then_an_error_is_returned() { - var option = new CliOption("-x") - { + var option = new CliOption("-x") + { Arity = new ArgumentArity(2, 3) }; @@ -1538,12 +1596,12 @@ public void When_option_arguments_are_fewer_than_minimum_arity_then_an_error_is_ option }; - var result = command.Parse("-x 1"); + var result = CliParser.Parse(command, "-x 1"); result.Errors - .Select(e => e.Message) - .Should() - .Contain(LocalizationResources.RequiredArgumentMissing(result.GetResult(option))); + .Select(e => e.Message) + .Should() + .Contain(LocalizationResources.RequiredArgumentMissing(result.GetResult(option))); } [Fact] @@ -1554,30 +1612,34 @@ public void When_option_arguments_are_greater_than_maximum_arity_then_an_error_i new CliOption("-x") { Arity = new ArgumentArity(2, 3)} }; - command.Parse("-x 1 2 3 4") - .Errors - .Select(e => e.Message) - .Should() - .Contain(LocalizationResources.UnrecognizedCommandOrArgument("4")); + CliParser.Parse(command, "-x 1 2 3 4") + .Errors + .Select(e => e.Message) + .Should() + .Contain(LocalizationResources.UnrecognizedCommandOrArgument("4")); } - + + /* Tokens [Fact] public void Tokens_are_not_split_if_the_part_before_the_delimiter_is_not_an_option() { var rootCommand = new CliCommand("jdbc"); rootCommand.Add(new CliOption("url")); - var result = rootCommand.Parse("jdbc url \"jdbc:sqlserver://10.0.0.2;databaseName=main\""); + var result = CliParser.Parse(rootCommand, "jdbc url \"jdbc:sqlserver://10.0.0.2;databaseName=main\""); result.Tokens - .Select(t => t.Value) - .Should() - .BeEquivalentTo("url", - "jdbc:sqlserver://10.0.0.2;databaseName=main"); + .Select(t => t.Value) + .Should() + .BeEquivalentTo("url", + "jdbc:sqlserver://10.0.0.2;databaseName=main"); } + */ + /* Completions. Move to subsystem. [Fact] public void A_subcommand_wont_overflow_when_checking_maximum_argument_capacity() { + // TODO: uses GetCompletions, fix // Tests bug identified in https://github.com/dotnet/command-line-api/issues/997 var argument1 = new CliArgument("arg1"); @@ -1600,6 +1662,7 @@ public void A_subcommand_wont_overflow_when_checking_maximum_argument_capacity() Action act = () => parseResult.GetCompletions(); act.Should().NotThrow(); } + */ [Theory] // https://github.com/dotnet/command-line-api/issues/1551, https://github.com/dotnet/command-line-api/issues/1533 [InlineData("--exec-prefix", "")] @@ -1617,9 +1680,259 @@ public void Parsed_value_of_empty_string_arg_is_an_empty_string(string arg1, str option }; - var result = rootCommand.Parse(new[] { arg1, arg2 }); + var result = CliParser.Parse(rootCommand, new[] { arg1, arg2 }); GetValue(result, option).Should().BeEmpty(); } + + // TODO: Tests below are from Powderhouse. Consider whether this the right location considering how large the file is. Consider `Trait("Version", "Powderhouse")]` + [Fact] + public void CommandResult_contains_argument_ValueResults() + { + var argument1 = new CliArgument("arg1"); + var argument2 = new CliArgument("arg2"); + var command = new CliCommand("subcommand") + { + argument1, + argument2 + }; + var rootCommand = new CliRootCommand + { + command + }; + + var parseResult = CliParser.Parse(rootCommand, "subcommand Kirk Spock"); + + var commandResult = parseResult.CommandResult; + commandResult.ValueResults.Should().HaveCount(2); + var result1 = commandResult.ValueResults[0]; + result1.GetValue().Should().Be("Kirk"); + var result2 = commandResult.ValueResults[1]; + result2.GetValue().Should().Be("Spock"); + } + + [Fact] + public void CommandResult_contains_option_ValueResults() + { + var argument1 = new CliOption("--opt1"); + var argument2 = new CliOption("--opt2"); + var command = new CliCommand("subcommand") + { + argument1, + argument2 + }; + var rootCommand = new CliRootCommand + { + command + }; + + var parseResult = CliParser.Parse(rootCommand, "subcommand arg1 --opt1 Kirk --opt2 Spock"); + + var commandResult = parseResult.CommandResult; + commandResult.ValueResults.Should().HaveCount(2); + var result1 = commandResult.ValueResults[0]; + result1.GetValue().Should().Be("Kirk"); + var result2 = commandResult.ValueResults[1]; + result2.GetValue().Should().Be("Spock"); + } + + [Fact] + public void Location_in_ValueResult_correct_for_arguments() + { + var argument1 = new CliArgument("arg1"); + var argument2 = new CliArgument("arg2"); + var command = new CliCommand("subcommand") + { + argument1, + argument2 + }; + var rootCommand = new CliRootCommand + { + command + }; + var expectedOuterLocation = new Location(CliExecutable.ExecutableName, Location.User, -1, null); + var expectedLocation1 = new Location("Kirk", Location.User, 1, expectedOuterLocation); + var expectedLocation2 = new Location("Spock", Location.User, 2, expectedOuterLocation); + + var parseResult = CliParser.Parse(rootCommand, "subcommand Kirk Spock"); + + var commandResult = parseResult.CommandResult; + var result1 = commandResult.ValueResults[0]; + var result2 = commandResult.ValueResults[1]; + result1.Locations.Single().Should().Be(expectedLocation1); + result2.Locations.Single().Should().Be(expectedLocation2); + } + + [Fact] + public void Location_in_ValueResult_correct_for_options() + { + var option1 = new CliOption("--opt1"); + var option2 = new CliOption("--opt2"); + var command = new CliCommand("subcommand") + { + option1, + option2 + }; + var rootCommand = new CliRootCommand + { + command + }; + var expectedOuterLocation = new Location(CliExecutable.ExecutableName, Location.User, -1, null); + var expectedLocation1 = new Location("Kirk", Location.User, 3, expectedOuterLocation); + var expectedLocation2 = new Location("Spock", Location.User, 5, expectedOuterLocation); + + var parseResult = CliParser.Parse(rootCommand, "subcommand arg1 --opt1 Kirk --opt2 Spock"); + + var commandResult = parseResult.CommandResult; + var result1 = commandResult.ValueResults[0]; + var result2 = commandResult.ValueResults[1]; + result1.Locations.Single().Should().Be(expectedLocation1); + result2.Locations.Single().Should().Be(expectedLocation2); + } + + [Fact] + public void Location_offsets_in_ValueResult_correct_for_arguments() + { + var argument1 = new CliArgument("arg1"); + + var command = new CliCommand("subcommand") + { + argument1, + }; + + var rootCommand = new CliRootCommand + { + command + }; + var expectedOuterLocation = new Location(CliExecutable.ExecutableName, Location.User, -1, null); + var expectedLocation1 = new Location("Kirk", Location.User, 1, expectedOuterLocation); + var expectedLocation2 = new Location("Spock", Location.User, 2, expectedOuterLocation); + + var parseResult = CliParser.Parse(rootCommand, "subcommand Kirk Spock"); + + var commandResult = parseResult.CommandResult; + var result1 = commandResult.ValueResults.Single(); + result1.Locations.First().Should().Be(expectedLocation1); + result1.Locations.Skip(1).Single().Should().Be(expectedLocation2); + } + + [Fact] + public void Location_offsets_in_ValueResult_correct_for_options() + { + var option1 = new CliOption("--opt1"); + var command = new CliCommand("subcommand") + { + option1, + }; + var rootCommand = new CliRootCommand + { + command + }; + var expectedOuterLocation = new Location(CliExecutable.ExecutableName, Location.User, -1, null); + var expectedLocation1 = new Location("Kirk", Location.User, 3, expectedOuterLocation); + var expectedLocation2 = new Location("Spock", Location.User, 5, expectedOuterLocation); + + var parseResult = CliParser.Parse(rootCommand, "subcommand arg1 --opt1 Kirk --opt1 Spock"); + + var commandResult = parseResult.CommandResult; + var result1 = commandResult.ValueResults.Single(); + result1.Locations.First().Should().Be(expectedLocation1); + result1.Locations.Skip(1).Single().Should().Be(expectedLocation2); + } + + [Fact] + public void Location_offset_correct_when_colon_or_equal_used() + { + var option1 = new CliOption("--opt1"); + var option2 = new CliOption("--opt11"); + var command = new CliCommand("subcommand") + { + option1, + option2 + }; + var rootCommand = new CliRootCommand + { + command + }; + var expectedOuterLocation = new Location(CliExecutable.ExecutableName, Location.User, -1, null); + var expectedLocation1 = new Location("Kirk", Location.User, 2, expectedOuterLocation, 7); + var expectedLocation2 = new Location("Spock", Location.User, 3, expectedOuterLocation, 8); + + var parseResult = CliParser.Parse(rootCommand, "subcommand arg1 --opt1:Kirk --opt11=Spock"); + + var commandResult = parseResult.CommandResult; + var result1 = commandResult.ValueResults[0]; + var result2 = commandResult.ValueResults[1]; + result1.Locations.Single().Should().Be(expectedLocation1); + result2.Locations.Single().Should().Be(expectedLocation2); + } + + [Fact] + public void Locations_correct_for_collection() + { + var option1 = new CliOption("--opt1"); + option1.AllowMultipleArgumentsPerToken = true; + var rootCommand = new CliRootCommand + { + option1 + }; + var expectedOuterLocation = new Location(CliExecutable.ExecutableName, Location.User, -1, null); + var expectedLocation1 = new Location("Kirk", Location.User, 2, expectedOuterLocation); + var expectedLocation2 = new Location("Spock", Location.User, 3, expectedOuterLocation); + var expectedLocation3 = new Location("Uhura", Location.User, 4, expectedOuterLocation); + + var parseResult = CliParser.Parse(rootCommand, "subcommand --opt1 Kirk Spock Uhura"); + + var result = parseResult.GetValueResult(option1); + result.Locations.Should().BeEquivalentTo([expectedLocation1, expectedLocation2, expectedLocation3]); + } + + [Fact] + public void ParseResult_contains_argument_ValueResults() + { + var argument1 = new CliArgument("arg1"); + var argument2 = new CliArgument("arg2"); + var command = new CliCommand("subcommand") + { + argument1, + argument2 + }; + var rootCommand = new CliRootCommand + { + command + }; + + var parseResult = CliParser.Parse(rootCommand, "subcommand Kirk Spock"); + + + var result1 = parseResult.GetValueResult(argument1); + var result2 = parseResult.GetValueResult(argument2); + result1.GetValue().Should().Be("Kirk"); + result2.GetValue().Should().Be("Spock"); + } + + [Fact] + public void ParseResult_contains_option_ValueResults() + { + var option1 = new CliOption("--opt1"); + var option2 = new CliOption("--opt2"); + var command = new CliCommand("subcommand") + { + option1, + option2 + }; + var rootCommand = new CliRootCommand + { + command + }; + + var parseResult = CliParser.Parse(rootCommand, "subcommand arg1 --opt1 Kirk --opt2 Spock"); + + + var result1 = parseResult.GetValueResult(option1); + var result2 = parseResult.GetValueResult(option2); + result1.GetValue().Should().Be("Kirk"); + result2.GetValue().Should().Be("Spock"); + } } } diff --git a/src/System.CommandLine.Tests/ParsingValidationTests.cs b/src/System.CommandLine.Tests/ParsingValidationTests.cs index bd9687b2d2..f9bf9e5281 100644 --- a/src/System.CommandLine.Tests/ParsingValidationTests.cs +++ b/src/System.CommandLine.Tests/ParsingValidationTests.cs @@ -46,9 +46,9 @@ public void When_an_option_has_en_error_then_the_error_has_a_reference_to_the_op var result = new CliRootCommand { option }.Parse("-x something_else"); result.Errors - .Where(e => e.SymbolResult != null) + .Where(e => e.SymbolResultInternal != null) .Should() - .Contain(e => ((OptionResult)e.SymbolResult).Option.Name == option.Name); + .Contain(e => ((OptionResult)e.SymbolResultInternal).Option.Name == option.Name); } [Fact] // https://github.com/dotnet/command-line-api/issues/1475 @@ -67,7 +67,7 @@ public void When_FromAmong_is_used_then_the_OptionResult_ErrorMessage_is_set() .Should() .Be(LocalizationResources.UnrecognizedArgument("c", new []{ "a", "b"})); error - .SymbolResult + .SymbolResultInternal .Should() .BeOfType(); @@ -90,7 +90,7 @@ public void When_FromAmong_is_used_then_the_ArgumentResult_ErrorMessage_is_set() .Should() .Be(LocalizationResources.UnrecognizedArgument("c", new []{ "a", "b"})); error - .SymbolResult + .SymbolResultInternal .Should() .BeOfType(); } @@ -380,7 +380,7 @@ public void A_custom_validator_can_be_added_to_an_option() .Should() .HaveCount(1) .And - .Contain(e => ((OptionResult)e.SymbolResult).Option == option) + .Contain(e => ((OptionResult)e.SymbolResultInternal).Option == option) .Which .Message .Should() @@ -407,7 +407,7 @@ public void A_custom_validator_can_be_added_to_an_argument() .Should() .HaveCount(1) .And - .Contain(e => ((ArgumentResult)e.SymbolResult).Argument == argument) + .Contain(e => ((ArgumentResult)e.SymbolResultInternal).Argument == argument) .Which .Message .Should() @@ -476,7 +476,7 @@ public void Validators_on_global_options_are_executed_when_invoking_a_subcommand .Should() .HaveCount(1) .And - .Contain(e => ((OptionResult)e.SymbolResult).Option == option) + .Contain(e => ((OptionResult)e.SymbolResultInternal).Option == option) .Which .Message .Should() @@ -613,7 +613,7 @@ public void LegalFilePathsOnly_rejects_command_arguments_containing_invalid_path .Should() .HaveCount(1) .And - .Contain(e => ((ArgumentResult)e.SymbolResult).Argument == command.Arguments.First() && + .Contain(e => ((ArgumentResult)e.SymbolResultInternal).Argument == command.Arguments.First() && e.Message == $"Character not allowed in a path: '{invalidCharacter}'."); } @@ -635,7 +635,7 @@ public void LegalFilePathsOnly_rejects_option_arguments_containing_invalid_path_ .Should() .HaveCount(1) .And - .Contain(e => ((OptionResult)e.SymbolResult).Option.Name == "-x" && + .Contain(e => ((OptionResult)e.SymbolResultInternal).Option.Name == "-x" && e.Message == $"Character not allowed in a path: '{invalidCharacter}'."); } @@ -698,7 +698,7 @@ public void LegalFileNamesOnly_rejects_command_arguments_containing_invalid_file .Should() .HaveCount(1) .And - .Contain(e => ((ArgumentResult)e.SymbolResult).Argument == command.Arguments.First() && + .Contain(e => ((ArgumentResult)e.SymbolResultInternal).Argument == command.Arguments.First() && e.Message == $"Character not allowed in a file name: '{invalidCharacter}'."); } @@ -721,7 +721,7 @@ public void LegalFileNamesOnly_rejects_option_arguments_containing_invalid_file_ .Should() .HaveCount(1) .And - .Contain(e => ((OptionResult)e.SymbolResult).Option.Name == "-x" && + .Contain(e => ((OptionResult)e.SymbolResultInternal).Option.Name == "-x" && e.Message == $"Character not allowed in a file name: '{invalidCharacter}'."); } @@ -781,7 +781,7 @@ public void A_command_argument_can_be_invalid_based_on_file_existence() .Should() .HaveCount(1) .And - .Contain(e => ((ArgumentResult)e.SymbolResult).Argument.Name == "to" && + .Contain(e => ((ArgumentResult)e.SymbolResultInternal).Argument.Name == "to" && e.Message == $"File does not exist: '{path}'."); } @@ -800,7 +800,7 @@ public void An_option_argument_can_be_invalid_based_on_file_existence() .Should() .HaveCount(1) .And - .Contain(e => ((OptionResult)e.SymbolResult).Option.Name == "--to" && + .Contain(e => ((OptionResult)e.SymbolResultInternal).Option.Name == "--to" && e.Message == $"File does not exist: '{path}'."); } @@ -819,7 +819,7 @@ public void A_command_argument_can_be_invalid_based_on_directory_existence() .Should() .HaveCount(1) .And - .Contain(e => ((ArgumentResult)e.SymbolResult).Argument.Name == "to" && + .Contain(e => ((ArgumentResult)e.SymbolResultInternal).Argument.Name == "to" && e.Message == $"Directory does not exist: '{path}'."); } @@ -838,7 +838,7 @@ public void An_option_argument_can_be_invalid_based_on_directory_existence() .Should() .HaveCount(1) .And - .Contain(e => ((OptionResult)e.SymbolResult).Option.Name == "--to" && + .Contain(e => ((OptionResult)e.SymbolResultInternal).Option.Name == "--to" && e.Message == $"Directory does not exist: '{path}'."); } @@ -857,7 +857,7 @@ public void A_command_argument_can_be_invalid_based_on_file_or_directory_existen .Should() .HaveCount(1) .And - .Contain(e => ((ArgumentResult)e.SymbolResult).Argument == command.Arguments.First() && + .Contain(e => ((ArgumentResult)e.SymbolResultInternal).Argument == command.Arguments.First() && e.Message == $"File or directory does not exist: '{path}'."); } @@ -876,7 +876,7 @@ public void An_option_argument_can_be_invalid_based_on_file_or_directory_existen .Should() .HaveCount(1) .And - .Contain(e => ((OptionResult)e.SymbolResult).Option.Name == "--to" && + .Contain(e => ((OptionResult)e.SymbolResultInternal).Option.Name == "--to" && e.Message == $"File or directory does not exist: '{path}'."); } @@ -895,7 +895,7 @@ public void A_command_argument_with_multiple_files_can_be_invalid_based_on_file_ .Should() .HaveCount(1) .And - .Contain(e => ((ArgumentResult)e.SymbolResult).Argument.Name == "to" && + .Contain(e => ((ArgumentResult)e.SymbolResultInternal).Argument.Name == "to" && e.Message == $"File does not exist: '{path}'."); } @@ -914,7 +914,7 @@ public void An_option_argument_with_multiple_files_can_be_invalid_based_on_file_ .Should() .HaveCount(1) .And - .Contain(e => ((OptionResult)e.SymbolResult).Option.Name == "--to" && + .Contain(e => ((OptionResult)e.SymbolResultInternal).Option.Name == "--to" && e.Message == $"File does not exist: '{path}'."); } @@ -933,7 +933,7 @@ public void A_command_argument_with_multiple_directories_can_be_invalid_based_on .Should() .HaveCount(1) .And - .ContainSingle(e => ((ArgumentResult)e.SymbolResult).Argument.Name == "to" && + .ContainSingle(e => ((ArgumentResult)e.SymbolResultInternal).Argument.Name == "to" && e.Message == $"Directory does not exist: '{path}'."); } @@ -952,7 +952,7 @@ public void An_option_argument_with_multiple_directories_can_be_invalid_based_on .Should() .HaveCount(1) .And - .ContainSingle(e => ((OptionResult)e.SymbolResult).Option.Name == "--to" && + .ContainSingle(e => ((OptionResult)e.SymbolResultInternal).Option.Name == "--to" && e.Message == $"Directory does not exist: '{path}'."); } @@ -973,7 +973,7 @@ public void A_command_argument_with_multiple_FileSystemInfos_can_be_invalid_base result.Errors .Should() - .ContainSingle(e => ((ArgumentResult)e.SymbolResult).Argument.Name == "to" && + .ContainSingle(e => ((ArgumentResult)e.SymbolResultInternal).Argument.Name == "to" && e.Message == $"File or directory does not exist: '{path}'."); } @@ -992,7 +992,7 @@ public void An_option_argument_with_multiple_FileSystemInfos_can_be_invalid_base result.Errors .Should() - .ContainSingle(e => ((OptionResult)e.SymbolResult).Option.Name == "--to" && + .ContainSingle(e => ((OptionResult)e.SymbolResultInternal).Option.Name == "--to" && e.Message == $"File or directory does not exist: '{path}'."); } @@ -1011,7 +1011,7 @@ public void A_command_argument_with_multiple_FileSystemInfos_can_be_invalid_base .Should() .HaveCount(1) .And - .ContainSingle(e => ((ArgumentResult)e.SymbolResult).Argument.Name == "to" && + .ContainSingle(e => ((ArgumentResult)e.SymbolResultInternal).Argument.Name == "to" && e.Message == $"File or directory does not exist: '{path}'."); } @@ -1030,7 +1030,7 @@ public void An_option_argument_with_multiple_FileSystemInfos_can_be_invalid_base .Should() .HaveCount(1) .And - .ContainSingle(e => ((OptionResult)e.SymbolResult).Option.Name == "--to" && + .ContainSingle(e => ((OptionResult)e.SymbolResultInternal).Option.Name == "--to" && e.Message == $"File or directory does not exist: '{path}'."); } @@ -1123,7 +1123,7 @@ public void A_command_with_subcommands_is_invalid_to_invoke_if_it_has_no_handler .Should() .ContainSingle( e => e.Message.Equals(LocalizationResources.RequiredCommandWasNotProvided()) && - ((CommandResult)e.SymbolResult).Command.Name.Equals("inner")); + ((CliCommandResultInternal)e.SymbolResultInternal).Command.Name.Equals("inner")); } [Fact] @@ -1139,7 +1139,7 @@ public void A_root_command_with_subcommands_is_invalid_to_invoke_if_it_has_no_ha .Should() .ContainSingle( e => e.Message.Equals(LocalizationResources.RequiredCommandWasNotProvided()) && - ((CommandResult)e.SymbolResult).Command == rootCommand); + ((CliCommandResultInternal)e.SymbolResultInternal).Command == rootCommand); } [Fact] @@ -1155,7 +1155,7 @@ public void A_command_with_subcommands_is_valid_to_invoke_if_it_has_a_handler() var result = outer.Parse("outer inner"); result.Errors.Should().BeEmpty(); - result.CommandResult.Command.Should().BeSameAs(inner); + result.CommandResultInternal.Command.Should().BeSameAs(inner); } [Fact] diff --git a/src/System.CommandLine.Tests/ResponseFileTests.cs b/src/System.CommandLine.Tests/ResponseFileTests.cs index 886116ea98..c3725ba443 100644 --- a/src/System.CommandLine.Tests/ResponseFileTests.cs +++ b/src/System.CommandLine.Tests/ResponseFileTests.cs @@ -87,7 +87,7 @@ public void When_response_file_is_specified_it_loads_command_arguments_from_resp } .Parse($"@{responseFile}"); - result.CommandResult + result.CliCommandResultInternal .Tokens .Select(t => t.Value) .Should() @@ -111,7 +111,7 @@ public void Response_file_can_provide_subcommand_arguments() } .Parse($"subcommand @{responseFile}"); - result.CommandResult + result.CommandResultInternal .Tokens .Select(t => t.Value) .Should() @@ -132,7 +132,7 @@ public void Response_file_can_provide_subcommand() } .Parse($"@{responseFile} one two three"); - result.CommandResult + result.CommandResultInternal .Tokens .Select(t => t.Value) .Should() @@ -156,7 +156,7 @@ public void When_response_file_is_specified_it_loads_subcommand_arguments_from_r } .Parse($"subcommand @{responseFile}"); - result.CommandResult + result.CommandResultInternal .Tokens .Select(t => t.Value) .Should() diff --git a/src/System.CommandLine.Tests/System.CommandLine.Tests.csproj b/src/System.CommandLine.Tests/System.CommandLine.Tests.csproj index 8bbb414657..9c42cc79b6 100644 --- a/src/System.CommandLine.Tests/System.CommandLine.Tests.csproj +++ b/src/System.CommandLine.Tests/System.CommandLine.Tests.csproj @@ -4,6 +4,7 @@ $(TargetFrameworkForNETSDK);$(NetFrameworkCurrent) false $(DefaultExcludesInProjectFolder);TestApps\** + False @@ -12,11 +13,17 @@ - - - - + + + + + + + + + @@ -35,8 +42,7 @@ - + diff --git a/src/System.CommandLine.Tests/TokenizerTests.cs b/src/System.CommandLine.Tests/TokenizerTests.cs new file mode 100644 index 0000000000..f55922e511 --- /dev/null +++ b/src/System.CommandLine.Tests/TokenizerTests.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using System.CommandLine.Parsing; +using System.IO; +using FluentAssertions; +using FluentAssertions.Equivalency; +using System.Linq; +using FluentAssertions.Common; +using Xunit; +using Xunit.Abstractions; + + +namespace System.CommandLine.Tests +{ + public partial class TokenizerTests + { + + [Fact] + public void The_tokenizer_can_handle_single_option() + { + var option = new CliOption("--hello"); + var command = new CliRootCommand { option }; + IReadOnlyList args = ["--hello", "world"]; + List tokens = null; + List errors = null; + Tokenizer.Tokenize(args, command, new CliConfiguration(command), true, out tokens, out errors); + + tokens + .Skip(1) + .Select(t => t.Value) + .Should() + .BeEquivalentTo("--hello", "world"); + + errors.Should().BeNull(); + } + + [Fact] + public void Location_stack_ToString_is_correct() + { + var option = new CliOption("--hello"); + var command = new CliRootCommand { option }; + IReadOnlyList args = ["--hello", "world"]; + List tokens = null; + List errors = null; + + Tokenizer.Tokenize(args, + command, + new CliConfiguration(command), + true, + out tokens, + out errors); + + var locations = tokens + .Skip(1) + .Select(t => t.Location.ToString()) + .ToList(); + errors.Should().BeNull(); + tokens.Count.Should().Be(3); + locations.Count.Should().Be(2); + locations[0].Should().Be($"{CliExecutable.ExecutableName} from User[-1, {CliExecutable.ExecutableName.Length}, 0]; --hello from User[0, 7, 0]"); + locations[1].Should().Be($"{CliExecutable.ExecutableName} from User[-1, {CliExecutable.ExecutableName.Length}, 0]; world from User[1, 5, 0]"); + } + + [Fact] + public void Directives_are_skipped() + { + var option = new CliOption("--hello"); + var command = new CliRootCommand { option }; + var configuration = new CliConfiguration(command); + configuration.AddPreprocessedLocation(new Location("[diagram]", Location.User, 0, null)); + IReadOnlyList args = ["[diagram] --hello", "world"]; + + List tokens = null; + List errors = null; + + Tokenizer.Tokenize(args, + command, + new CliConfiguration(command), + true, + out tokens, + out errors); + + var hasDiagram = tokens + .Any(t => t.Value == "[diagram]"); + errors.Should().BeNull(); + tokens.Count.Should().Be(3); // root is a token + hasDiagram .Should().BeFalse(); + } + } +} diff --git a/src/System.CommandLine.Tests/VersionOptionTests.cs b/src/System.CommandLine.Tests/VersionOptionTests.cs deleted file mode 100644 index e331d72261..0000000000 --- a/src/System.CommandLine.Tests/VersionOptionTests.cs +++ /dev/null @@ -1,206 +0,0 @@ -// 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.CommandLine.Help; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Threading.Tasks; -using FluentAssertions; -using Xunit; -using static System.Environment; - -namespace System.CommandLine.Tests -{ - public class VersionOptionTests - { - private static readonly string version = (Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly()) - .GetCustomAttribute() - .InformationalVersion; - - [Fact] - public async Task When_the_version_option_is_specified_then_the_version_is_written_to_standard_out() - { - CliConfiguration configuration = new(new CliRootCommand()) - { - Output = new StringWriter() - }; - - await configuration.InvokeAsync("--version"); - - configuration.Output.ToString().Should().Be($"{version}{NewLine}"); - } - - [Fact] - public async Task When_the_version_option_is_specified_then_invocation_is_short_circuited() - { - var wasCalled = false; - var rootCommand = new CliRootCommand(); - rootCommand.SetAction((_) => wasCalled = true); - - CliConfiguration configuration = new(rootCommand) - { - Output = new StringWriter() - }; - - await configuration.InvokeAsync("--version"); - - wasCalled.Should().BeFalse(); - } - - [Fact] - public async Task Version_option_appears_in_help() - { - CliConfiguration configuration = new(new CliRootCommand()) - { - Output = new StringWriter() - }; - - await configuration.InvokeAsync("--help"); - - configuration.Output - .ToString() - .Should() - .Match("*Options:*--version*Show version information*"); - } - - [Fact] - public async Task When_the_version_option_is_specified_and_there_are_default_options_then_the_version_is_written_to_standard_out() - { - var rootCommand = new CliRootCommand - { - new CliOption("-x") - { - DefaultValueFactory = (_) => true - }, - }; - rootCommand.SetAction((_) => { }); - - CliConfiguration configuration = new(rootCommand) - { - Output = new StringWriter() - }; - - await configuration.InvokeAsync("--version"); - - configuration.Output.ToString().Should().Be($"{version}{NewLine}"); - } - - [Fact] - public async Task When_the_version_option_is_specified_and_there_are_default_arguments_then_the_version_is_written_to_standard_out() - { - CliRootCommand rootCommand = new () - { - new CliArgument("x") { DefaultValueFactory =(_) => true }, - }; - rootCommand.SetAction((_) => { }); - - CliConfiguration configuration = new(rootCommand) - { - Output = new StringWriter() - }; - - await configuration.InvokeAsync("--version"); - - configuration.Output.ToString().Should().Be($"{version}{NewLine}"); - } - - [Theory] - [InlineData("--version -x")] - [InlineData("--version subcommand")] - public void Version_is_not_valid_with_other_tokens(string commandLine) - { - var subcommand = new CliCommand("subcommand"); - subcommand.SetAction(_ => { }); - var rootCommand = new CliRootCommand - { - subcommand, - new CliOption("-x") - }; - rootCommand.SetAction(_ => { }); - - CliConfiguration configuration = new(rootCommand) - { - Output = new StringWriter() - }; - - var result = rootCommand.Parse(commandLine, configuration); - - result.Errors.Should().Contain(e => e.Message == "--version option cannot be combined with other arguments."); - } - - [Fact] - public void Version_option_is_not_added_to_subcommands() - { - var childCommand = new CliCommand("subcommand"); - childCommand.SetAction(_ => { }); - - var rootCommand = new CliRootCommand - { - childCommand - }; - rootCommand.SetAction(_ => { }); - - CliConfiguration configuration = new(rootCommand) - { - Output = new StringWriter() - }; - - configuration - .RootCommand - .Subcommands - .Single(c => c.Name == "subcommand") - .Options - .Should() - .BeEmpty(); - } - - [Fact] - public async Task Version_can_specify_additional_alias() - { - CliRootCommand rootCommand = new(); - - for (int i = 0; i < rootCommand.Options.Count; i++) - { - if (rootCommand.Options[i] is VersionOption) - rootCommand.Options[i] = new VersionOption("-v", "-version"); - } - - CliConfiguration configuration = new(rootCommand) - { - Output = new StringWriter() - }; - - await configuration.InvokeAsync("-v"); - configuration.Output.ToString().Should().Be($"{version}{NewLine}"); - - configuration.Output = new StringWriter(); - await configuration.InvokeAsync("-version"); - configuration.Output.ToString().Should().Be($"{version}{NewLine}"); - } - - [Fact] - public void Version_is_not_valid_with_other_tokens_uses_custom_alias() - { - var childCommand = new CliCommand("subcommand"); - childCommand.SetAction((_) => { }); - var rootCommand = new CliRootCommand - { - childCommand - }; - - rootCommand.Options[1] = new VersionOption("-v"); - - rootCommand.SetAction((_) => { }); - - CliConfiguration configuration = new(rootCommand) - { - Output = new StringWriter() - }; - - var result = rootCommand.Parse("-v subcommand", configuration); - - result.Errors.Should().ContainSingle(e => e.Message == "-v option cannot be combined with other arguments."); - } - } -} diff --git a/src/System.CommandLine/ArgumentArity.cs b/src/System.CommandLine/ArgumentArity.cs index 72acdbdbec..6a96acdaf9 100644 --- a/src/System.CommandLine/ArgumentArity.cs +++ b/src/System.CommandLine/ArgumentArity.cs @@ -58,11 +58,11 @@ public ArgumentArity(int minimumNumberOfValues, int maximumNumberOfValues) /// public int MaximumNumberOfValues { get; } - internal bool IsNonDefault { get; } + internal bool IsNonDefault { get; } /// - public bool Equals(ArgumentArity other) => - other.MaximumNumberOfValues == MaximumNumberOfValues && + public bool Equals(ArgumentArity other) => + other.MaximumNumberOfValues == MaximumNumberOfValues && other.MinimumNumberOfValues == MinimumNumberOfValues && other.IsNonDefault == IsNonDefault; @@ -73,11 +73,11 @@ public bool Equals(ArgumentArity other) => public override int GetHashCode() => MaximumNumberOfValues ^ MinimumNumberOfValues ^ IsNonDefault.GetHashCode(); - internal static bool Validate(ArgumentResult argumentResult, [NotNullWhen(false)] out ArgumentConversionResult? error) + internal static bool Validate(CliArgumentResultInternal argumentResult, [NotNullWhen(false)] out ArgumentConversionResult? error) { error = null; - if (argumentResult.Parent is null or OptionResult { Implicit: true }) + if (argumentResult.Parent is null or CliOptionResultInternal { Implicit: true }) { return true; } @@ -95,7 +95,7 @@ internal static bool Validate(ArgumentResult argumentResult, [NotNullWhen(false) if (tokenCount > argumentResult.Argument.Arity.MaximumNumberOfValues) { - if (argumentResult.Parent is OptionResult optionResult) + if (argumentResult.Parent is CliOptionResultInternal optionResult) { if (!optionResult.Option.AllowMultipleArgumentsPerToken) { diff --git a/src/System.CommandLine/Binding/ArgumentConversionResult.cs b/src/System.CommandLine/Binding/ArgumentConversionResult.cs index 03bd2f85f0..7e03e86034 100644 --- a/src/System.CommandLine/Binding/ArgumentConversionResult.cs +++ b/src/System.CommandLine/Binding/ArgumentConversionResult.cs @@ -1,7 +1,6 @@ // 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.CommandLine.Completions; using System.CommandLine.Parsing; using System.Linq; @@ -9,45 +8,47 @@ namespace System.CommandLine.Binding { internal sealed class ArgumentConversionResult { - internal readonly ArgumentResult ArgumentResult; + internal readonly CliArgumentResultInternal ArgumentResultInternal; internal readonly object? Value; internal readonly string? ErrorMessage; internal ArgumentConversionResultType Result; - private ArgumentConversionResult(ArgumentResult argumentResult, string error, ArgumentConversionResultType failure) + private ArgumentConversionResult(CliArgumentResultInternal argumentResult, string error, ArgumentConversionResultType failure) { - ArgumentResult = argumentResult; + ArgumentResultInternal = argumentResult; ErrorMessage = error; Result = failure; } - private ArgumentConversionResult(ArgumentResult argumentResult, object? value, ArgumentConversionResultType result) + private ArgumentConversionResult(CliArgumentResultInternal argumentResult, object? value, ArgumentConversionResultType result) { - ArgumentResult = argumentResult; + ArgumentResultInternal = argumentResult; Value = value; Result = result; } - internal static ArgumentConversionResult Failure(ArgumentResult argumentResult, string error, ArgumentConversionResultType reason) + internal static ArgumentConversionResult Failure(CliArgumentResultInternal argumentResult, string error, ArgumentConversionResultType reason) => new(argumentResult, error, reason); - internal static ArgumentConversionResult ArgumentConversionCannotParse(ArgumentResult argumentResult, Type expectedType, string value) + internal static ArgumentConversionResult ArgumentConversionCannotParse(CliArgumentResultInternal argumentResult, Type expectedType, string value) => new(argumentResult, FormatErrorMessage(argumentResult, expectedType, value), ArgumentConversionResultType.FailedType); - public static ArgumentConversionResult Success(ArgumentResult argumentResult, object? value) + public static ArgumentConversionResult Success(CliArgumentResultInternal argumentResult, object? value) => new(argumentResult, value, ArgumentConversionResultType.Successful); - internal static ArgumentConversionResult None(ArgumentResult argumentResult) + internal static ArgumentConversionResult None(CliArgumentResultInternal argumentResult) => new(argumentResult, value: null, ArgumentConversionResultType.NoArgument); private static string FormatErrorMessage( - ArgumentResult argumentResult, + CliArgumentResultInternal argumentResult, Type expectedType, string value) { - if (argumentResult.Parent is CommandResult commandResult) + if (argumentResult.Parent is CliCommandResultInternal commandResult) { string alias = commandResult.Command.Name; +// TODO: completion +/* CompletionItem[] completionItems = argumentResult.Argument.GetCompletions(CompletionContext.Empty).ToArray(); if (completionItems.Length > 0) @@ -56,13 +57,16 @@ private static string FormatErrorMessage( value, alias, expectedType, completionItems.Select(ci => ci.Label)); } else +*/ { return LocalizationResources.ArgumentConversionCannotParseForCommand(value, alias, expectedType); } } - else if (argumentResult.Parent is OptionResult optionResult) + else if (argumentResult.Parent is CliOptionResultInternal optionResult) { string alias = optionResult.Option.Name; +// TODO: completion +/* CompletionItem[] completionItems = optionResult.Option.GetCompletions(CompletionContext.Empty).ToArray(); if (completionItems.Length > 0) @@ -71,6 +75,7 @@ private static string FormatErrorMessage( value, alias, expectedType, completionItems.Select(ci => ci.Label)); } else +*/ { return LocalizationResources.ArgumentConversionCannotParseForOption(value, alias, expectedType); } diff --git a/src/System.CommandLine/Binding/ArgumentConverter.DefaultValues.cs b/src/System.CommandLine/Binding/ArgumentConverter.DefaultValues.cs index a44a8114d9..f921b0603e 100644 --- a/src/System.CommandLine/Binding/ArgumentConverter.DefaultValues.cs +++ b/src/System.CommandLine/Binding/ArgumentConverter.DefaultValues.cs @@ -14,7 +14,6 @@ internal static partial class ArgumentConverter private static ConstructorInfo? _listCtor; #endif - [UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050", Justification = "https://github.com/dotnet/command-line-api/issues/1638")] private static Array CreateArray(Type itemType, int capacity) => Array.CreateInstance(itemType, capacity); diff --git a/src/System.CommandLine/Binding/ArgumentConverter.cs b/src/System.CommandLine/Binding/ArgumentConverter.cs index 9fa921348a..e70a0110d9 100644 --- a/src/System.CommandLine/Binding/ArgumentConverter.cs +++ b/src/System.CommandLine/Binding/ArgumentConverter.cs @@ -10,7 +10,7 @@ namespace System.CommandLine.Binding internal static partial class ArgumentConverter { internal static ArgumentConversionResult ConvertObject( - ArgumentResult argumentResult, + CliArgumentResultInternal argumentResult, Type type, object? value) { @@ -36,7 +36,7 @@ internal static ArgumentConversionResult ConvertObject( } private static ArgumentConversionResult ConvertToken( - ArgumentResult argumentResult, + CliArgumentResultInternal argumentResult, Type type, CliToken token) { @@ -81,7 +81,7 @@ private static ArgumentConversionResult ConvertToken( } private static ArgumentConversionResult ConvertTokens( - ArgumentResult argumentResult, + CliArgumentResultInternal argumentResult, Type type, IReadOnlyList tokens) { @@ -110,7 +110,7 @@ private static ArgumentConversionResult ConvertTokens( break; default: // failures - if (argumentResult.Parent is CommandResult) + if (argumentResult.Parent is CliCommandResultInternal) { argumentResult.OnlyTake(i); @@ -132,15 +132,15 @@ private static ArgumentConversionResult ConvertTokens( if (argument.ValueType.TryGetNullableType(out var nullableType) && StringConverters.TryGetValue(nullableType, out var convertNullable)) { - return (ArgumentResult result, out object? value) => ConvertSingleString(result, convertNullable, out value); + return (CliArgumentResultInternal result, out object? value) => ConvertSingleString(result, convertNullable, out value); } if (StringConverters.TryGetValue(argument.ValueType, out var convert1)) { - return (ArgumentResult result, out object? value) => ConvertSingleString(result, convert1, out value); + return (CliArgumentResultInternal result, out object? value) => ConvertSingleString(result, convert1, out value); } - static bool ConvertSingleString(ArgumentResult result, TryConvertString convert, out object? value) => + static bool ConvertSingleString(CliArgumentResultInternal result, TryConvertString convert, out object? value) => convert(result.Tokens[result.Tokens.Count - 1].Value, out value); } @@ -183,12 +183,12 @@ internal static ArgumentConversionResult ConvertIfNeeded( return conversionResult.Result switch { ArgumentConversionResultType.Successful when !toType.IsInstanceOfType(conversionResult.Value) => - ConvertObject(conversionResult.ArgumentResult, + ConvertObject(conversionResult.ArgumentResultInternal, toType, conversionResult.Value), - ArgumentConversionResultType.NoArgument when conversionResult.ArgumentResult.Argument.IsBoolean() => - Success(conversionResult.ArgumentResult, true), + ArgumentConversionResultType.NoArgument when conversionResult.ArgumentResultInternal.Argument.IsBoolean() => + Success(conversionResult.ArgumentResultInternal, true), _ => conversionResult }; @@ -204,7 +204,7 @@ internal static T GetValueOrDefault(this ArgumentConversionResult result) }; } - public static bool TryConvertArgument(ArgumentResult argumentResult, out object? value) + public static bool TryConvertArgument(CliArgumentResultInternal argumentResult, out object? value) { var argument = argumentResult.Argument; diff --git a/src/System.CommandLine/Binding/TryConvertArgument.cs b/src/System.CommandLine/Binding/TryConvertArgument.cs index 44d777e939..f8fb30d9f4 100644 --- a/src/System.CommandLine/Binding/TryConvertArgument.cs +++ b/src/System.CommandLine/Binding/TryConvertArgument.cs @@ -6,6 +6,6 @@ namespace System.CommandLine.Binding { internal delegate bool TryConvertArgument( - ArgumentResult argumentResult, + CliArgumentResultInternal argumentResult, out object? value); } \ No newline at end of file diff --git a/src/System.CommandLine/CliArgument.cs b/src/System.CommandLine/CliArgument.cs index aa453bfd72..34d4e1b228 100644 --- a/src/System.CommandLine/CliArgument.cs +++ b/src/System.CommandLine/CliArgument.cs @@ -1,25 +1,25 @@ // 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.Binding; using System.CommandLine.Parsing; -using System.CommandLine.Completions; -using System.Linq; namespace System.CommandLine { /// /// A symbol defining a value that can be passed on the command line to a command or option. /// - public abstract class CliArgument : CliSymbol + public abstract class CliArgument : CliValueSymbol { private ArgumentArity _arity; + // TODO: custom parser, completion, validators + /* private TryConvertArgument? _convertArguments; private List>>? _completionSources = null; private List>? _validators = null; - - private protected CliArgument(string name) : base(name, allowWhitespace: true) + */ + private protected CliArgument(string name) + : base(name, allowWhitespace: true) { } @@ -39,18 +39,24 @@ public ArgumentArity Arity } set => _arity = value; } - + // TODO: help, completion + /* /// /// The name used in help output to describe the argument. /// public string? HelpName { get; set; } - - internal TryConvertArgument? ConvertArguments + */ + internal TryConvertArgument? ConvertArguments => ArgumentConverter.GetConverter(this); + // TODO: custom parsers + /* { get => _convertArguments ??= ArgumentConverter.GetConverter(this); set => _convertArguments = value; } + */ + // TODO: completion; + /* /// /// Gets the list of completion sources for the argument. /// @@ -89,11 +95,7 @@ public List>> CompletionSour } } - /// - /// Gets or sets the that the argument's parsed tokens will be converted to. - /// - public abstract Type ValueType { get; } - + /* TODO: validators /// /// Provides a list of argument validators. Validators can be used /// to provide custom errors based on user input. @@ -101,23 +103,24 @@ public List>> CompletionSour public List> Validators => _validators ??= new (); internal bool HasValidators => (_validators?.Count ?? 0) > 0; - + */ /// /// Gets the default value for the argument. /// /// Returns the default value for the argument, if defined. Null otherwise. public object? GetDefaultValue() { - return GetDefaultValue(new ArgumentResult(this, null!, null)); + return GetDefaultValue(new CliArgumentResultInternal(this, null!, null)); } - internal abstract object? GetDefaultValue(ArgumentResult argumentResult); + internal abstract object? GetDefaultValue(CliArgumentResultInternal argumentResult); /// /// Specifies if a default value is defined for the argument. /// public abstract bool HasDefaultValue { get; } - + // TODO: completion + /* /// public override IEnumerable GetCompletions(CompletionContext context) { @@ -126,10 +129,8 @@ public override IEnumerable GetCompletions(CompletionContext con .Distinct() .OrderBy(c => c.SortText, StringComparer.OrdinalIgnoreCase); } - + */ /// public override string ToString() => $"{nameof(CliArgument)}: {Name}"; - - internal bool IsBoolean() => ValueType == typeof(bool) || ValueType == typeof(bool?); } } diff --git a/src/System.CommandLine/Argument{T}.cs b/src/System.CommandLine/CliArgument{T}.cs similarity index 95% rename from src/System.CommandLine/Argument{T}.cs rename to src/System.CommandLine/CliArgument{T}.cs index 8cf1981020..14015b7ac5 100644 --- a/src/System.CommandLine/Argument{T}.cs +++ b/src/System.CommandLine/CliArgument{T}.cs @@ -11,12 +11,15 @@ namespace System.CommandLine /// public class CliArgument : CliArgument { + // TODO: custom parser + /* private Func? _customParser; - + */ /// /// Initializes a new instance of the Argument class. /// /// The name of the argument. It's not used for parsing, only when displaying Help or creating parse errors.> + /// public CliArgument(string name) : base(name) { } @@ -24,13 +27,17 @@ public CliArgument(string name) : base(name) /// /// The delegate to invoke to create the default value. /// + /* /// /// It's invoked when there was no parse input provided for given Argument. /// The same instance can be set as , in such case /// the delegate is also invoked when an input was provided. /// - public Func? DefaultValueFactory { get; set; } + */ + internal Func? DefaultValueFactory { get; set; } + // TODO: custom parsers + /* /// /// A custom argument parser. /// @@ -67,14 +74,14 @@ public CliArgument(string name) : base(name) } } } - + */ /// public override Type ValueType => typeof(T); /// public override bool HasDefaultValue => DefaultValueFactory is not null; - internal override object? GetDefaultValue(ArgumentResult argumentResult) + internal override object? GetDefaultValue(CliArgumentResultInternal argumentResult) { if (DefaultValueFactory is null) { @@ -83,7 +90,8 @@ public CliArgument(string name) : base(name) return DefaultValueFactory.Invoke(argumentResult); } - + // TODO: completion, validators + /* /// /// Configures the argument to accept only the specified values, and to suggest them as command line completions. /// @@ -162,6 +170,7 @@ public void AcceptLegalFileNamesOnly() } }); } + */ [UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050", Justification = "https://github.com/dotnet/command-line-api/issues/1638")] [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2091", Justification = "https://github.com/dotnet/command-line-api/issues/1638")] diff --git a/src/System.CommandLine/CliCommand.cs b/src/System.CommandLine/CliCommand.cs index e101bf3948..8adccb5d9d 100644 --- a/src/System.CommandLine/CliCommand.cs +++ b/src/System.CommandLine/CliCommand.cs @@ -3,8 +3,6 @@ using System.Collections; using System.Collections.Generic; -using System.CommandLine.Completions; -using System.CommandLine.Invocation; using System.CommandLine.Parsing; using System.ComponentModel; using System.Diagnostics; @@ -14,6 +12,7 @@ namespace System.CommandLine { + /// /// Represents a specific action that the application performs. /// @@ -22,21 +21,31 @@ namespace System.CommandLine /// for simple applications that only have one action. For example, dotnet run /// uses run as the command. /// - public class CliCommand : CliSymbol, IEnumerable + public class CliCommand : CliSymbol, IEnumerable { + // TODO: don't expose field internal AliasSet? _aliases; private ChildSymbolList? _arguments; private ChildSymbolList? _options; private ChildSymbolList? _subcommands; - private List>? _validators; +// TODO: validators + /* + private List>? _validators; + */ /// /// Initializes a new instance of the Command class. /// /// The name of the command. + /* /// The description of the command, shown in help. - public CliCommand(string name, string? description = null) : base(name) - => Description = description; + */ + public CliCommand(string name)/*, string? description = null) */ + : base(name) + { + } +// TODO: help + //=> Description = description; /// /// Gets the child symbols. @@ -66,6 +75,7 @@ public IEnumerable Children /// /// Represents all of the options for the command, inherited options that have been applied to any of the command's ancestors. /// + // TODO: Consider value of lazy here. It sets up a desire to use awkward approach (HasOptions) for a perf win. Applies to Options and Subcommands also. public IList Options => _options ??= new (this); internal bool HasOptions => _options?.Count > 0; @@ -76,12 +86,12 @@ public IEnumerable Children public IList Subcommands => _subcommands ??= new(this); internal bool HasSubcommands => _subcommands is not null && _subcommands.Count > 0; - +/* /// /// Validators to the command. Validators can be used /// to create custom validation logic. /// - public List> Validators => _validators ??= new (); + public List> Validators => _validators ??= new (); internal bool HasValidators => _validators is not null && _validators.Count > 0; @@ -129,7 +139,7 @@ public void SetAction(Func action) throw new ArgumentNullException(nameof(action)); } - Action = new AnonymousSynchronousCliAction(action); + Action = new AnonymousSynchronousCliAction(action); } /// @@ -163,6 +173,8 @@ public void SetAction(Func> action) Action = new AnonymousAsynchronousCliAction(action); } + */ + /// /// Adds a to the command. /// @@ -181,17 +193,51 @@ public void SetAction(Func> action) /// The Command to add to the command. public void Add(CliCommand command) => Subcommands.Add(command); + // Hide from IntelliSense as it's only to support initializing via C# collection expression + // More specific efficient overloads are available for all supported symbol types. + [DebuggerStepThrough] + [EditorBrowsable(EditorBrowsableState.Never)] + public void Add(CliSymbol symbol) + { + if (symbol is CliCommand cmd) + { + Add(cmd); + } + else if (symbol is CliOption option) + { + Add(option); + } + else if (symbol is CliCommand command) + { + Add(command); + } + else + { +// TODO: add a localized message here + throw new ArgumentException(null, nameof(symbol)); + } + } + +// TODO: umatched tokens + /* /// /// Gets or sets a value that indicates whether unmatched tokens should be treated as errors. For example, /// if set to and an extra command or argument is provided, validation will fail. /// public bool TreatUnmatchedTokensAsErrors { get; set; } = true; - +*/ /// + // Hide from IntelliSense as it's only to support C# collection initializer [DebuggerStepThrough] - [EditorBrowsable(EditorBrowsableState.Never)] // hide from intellisense, it's public for C# collection initializer + [EditorBrowsable(EditorBrowsableState.Never)] IEnumerator IEnumerable.GetEnumerator() => Children.GetEnumerator(); + /// + // Hide from IntelliSense as it's only to support initializing via C# collection expression + [DebuggerStepThrough] + [EditorBrowsable(EditorBrowsableState.Never)] + IEnumerator IEnumerable.GetEnumerator() => Children.GetEnumerator(); +/* /// /// Parses an array strings using the command. /// @@ -306,8 +352,7 @@ void AddCompletionsFor(CliSymbol identifier, AliasSet? aliases) } } } - - internal bool EqualsNameOrAlias(string name) - => Name.Equals(name, StringComparison.Ordinal) || (_aliases is not null && _aliases.Contains(name)); +*/ + internal bool EqualsNameOrAlias(string name) => Name.Equals(name, StringComparison.Ordinal) || (_aliases is not null && _aliases.Contains(name)); } } diff --git a/src/System.CommandLine/CliConfiguration.cs b/src/System.CommandLine/CliConfiguration.cs index dc02b4e512..8b19f68f99 100644 --- a/src/System.CommandLine/CliConfiguration.cs +++ b/src/System.CommandLine/CliConfiguration.cs @@ -7,7 +7,6 @@ using System.Threading.Tasks; using System.Threading; using System.IO; -using System.CommandLine.Invocation; namespace System.CommandLine { @@ -16,6 +15,7 @@ namespace System.CommandLine /// public class CliConfiguration { + /* private TextWriter? _output, _error; /// @@ -33,7 +33,7 @@ public CliConfiguration(CliCommand rootCommand) CliRootCommand root => root.Directives.Count > 0, _ => false }; - + */ /// /// Enables the parser to recognize and expand POSIX-style bundled options. /// @@ -56,6 +56,45 @@ public CliConfiguration(CliCommand rootCommand) /// public bool EnablePosixBundling { get; set; } = true; + /// + /// Indicates whether the first argument of the passed string is the exe name + /// + /// The args of a command line, such as those passed to Main(string[] args) + /// + // TODO: If this is the right model, tuck this away because it should only be used by subsystems. + public bool FirstArgumentIsRootCommand(IReadOnlyList args) + { + // TODO: This logic was previously that rawInput was null. Seems more sensible to look for an empty args array.From private static ParseResult Parse(CliCommand ,IReadOnlyList< string > ,string? ,CliConfiguration? ). CHeck logic and ensure test coverage + return args.Any() + ? FirstArgLooksLikeRoot(args.First(), RootCommand) + : false; + + static bool FirstArgLooksLikeRoot(string firstArg, CliCommand rootCommand) + { + try + { + return firstArg == CliExecutable.ExecutablePath || rootCommand.EqualsNameOrAlias(Path.GetFileName(firstArg)); + } + catch // possible exception for illegal characters in path on .NET Framework + { + return false; + } + + } + } + + private List? preprocessedLocations = null; + public IEnumerable? PreProcessedLocations => preprocessedLocations; + public void AddPreprocessedLocation(Location location) + { + if (preprocessedLocations is null) + { + preprocessedLocations = new List(); + } + preprocessedLocations.Add(location); + } + + /* /// /// Enables a default exception handler to catch any unhandled exceptions thrown during invocation. Enabled by default. /// @@ -67,6 +106,7 @@ public CliConfiguration(CliCommand rootCommand) /// If not provided, a default timeout of 2 seconds is enforced. /// public TimeSpan? ProcessTerminationTimeout { get; set; } = TimeSpan.FromSeconds(2); + */ /// /// Response file token replacer, enabled by default. @@ -75,13 +115,13 @@ public CliConfiguration(CliCommand rootCommand) /// /// When enabled, any token prefixed with @ can be replaced with zero or more other tokens. This is mostly commonly used to expand tokens from response files and interpolate them into a command line prior to parsing. /// - public TryReplaceToken? ResponseFileTokenReplacer { get; set; } = StringExtensions.TryReadResponseFile; + public Func? tokens, List? errors)>? ResponseFileTokenReplacer { get; set; } /// /// Gets the root command. /// public CliCommand RootCommand { get; } - + /* /// /// The standard output. Used by Help and other facilities that write non-error information. /// By default it's set to . @@ -221,5 +261,10 @@ static CliSymbol GetChild(int index, CliCommand command, out AliasSet? aliases) return command.Options[index - command.Subcommands.Count]; } } + */ + public CliConfiguration(CliCommand command) + { + RootCommand = command; + } } } \ No newline at end of file diff --git a/src/System.CommandLine/CliDirective.cs b/src/System.CommandLine/CliDirective.cs index cb7930f5fe..406bdebc40 100644 --- a/src/System.CommandLine/CliDirective.cs +++ b/src/System.CommandLine/CliDirective.cs @@ -2,8 +2,6 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Collections.Generic; -using System.CommandLine.Completions; -using System.CommandLine.Invocation; namespace System.CommandLine { @@ -27,6 +25,7 @@ public CliDirective(string name) { } + /* /// /// Gets or sets the for the Directive. The handler represents the action /// that will be performed when the Directive is invoked. @@ -36,5 +35,6 @@ public CliDirective(string name) /// public override IEnumerable GetCompletions(CompletionContext context) => Array.Empty(); + */ } } diff --git a/src/System.CommandLine/CliExecutable.cs b/src/System.CommandLine/CliExecutable.cs new file mode 100644 index 0000000000..52816e9d93 --- /dev/null +++ b/src/System.CommandLine/CliExecutable.cs @@ -0,0 +1,51 @@ +// 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.IO; +using System.Reflection; + +namespace System.CommandLine; + +//TODO: cull unused member, consider making public again. KAD: Made public because used by version. If not needed in core, move to S.CL.Subsystems +/// +/// Static helpers for determining information about the CLI executable. +/// +public static class CliExecutable +{ + private static Assembly? _assembly; + private static string? _executablePath; + private static string? _executableName; + private static string? _executableVersion; + + internal static Assembly GetAssembly() + => _assembly ??= (Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly()); + + /// + /// The name of the currently running executable. + /// + public static string ExecutableName + => _executableName ??= Path.GetFileNameWithoutExtension(ExecutablePath).Replace(" ", ""); + + /// + /// The path to the currently running executable. + /// + public static string ExecutablePath => _executablePath ??= Environment.GetCommandLineArgs()[0]; + + public static string ExecutableVersion => _executableVersion ??= GetExecutableVersion(); + + private static string GetExecutableVersion() + { + var assembly = GetAssembly(); + + var assemblyVersionAttribute = assembly.GetCustomAttribute(); + + if (assemblyVersionAttribute is null) + { + return assembly.GetName().Version?.ToString() ?? ""; + } + else + { + return assemblyVersionAttribute.InformationalVersion; + } + } +} diff --git a/src/System.CommandLine/CliOption.cs b/src/System.CommandLine/CliOption.cs index fd204a8be4..ae0a9b7ebf 100644 --- a/src/System.CommandLine/CliOption.cs +++ b/src/System.CommandLine/CliOption.cs @@ -2,8 +2,6 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Collections.Generic; -using System.CommandLine.Completions; -using System.CommandLine.Invocation; using System.CommandLine.Parsing; using System.Linq; @@ -12,19 +10,25 @@ namespace System.CommandLine /// /// A symbol defining a named parameter and a value for that parameter. /// - public abstract class CliOption : CliSymbol + public abstract class CliOption : CliValueSymbol { internal AliasSet? _aliases; + /* private List>? _validators; - private protected CliOption(string name, string[] aliases) : base(name) + */ + + private protected CliOption(string name, string[] aliases) + : base(name) { - if (aliases is { Length: > 0 }) + if (aliases is { Length: > 0 }) { _aliases = new(aliases); } } + public override Type ValueType => Argument.ValueType; + /// /// Gets the argument for the option. /// @@ -35,6 +39,8 @@ private protected CliOption(string name, string[] aliases) : base(name) /// public bool HasDefaultValue => Argument.HasDefaultValue; + // TODO: help + /* /// /// Gets or sets the name of the Option when displayed in help. /// @@ -47,6 +53,7 @@ public string? HelpName get => Argument.HelpName; set => Argument.HelpName = value; } + */ /// /// Gets or sets the arity of the option. @@ -57,6 +64,8 @@ public ArgumentArity Arity set => Argument.Arity = value; } + // TODO: recursive options, validators, completion + /* /// /// When set to true, this option will be applied to its immediate parent command or commands and recursively to their subcommands. /// @@ -73,7 +82,9 @@ public ArgumentArity Arity /// Gets the list of completion sources for the option. /// public List>> CompletionSources => Argument.CompletionSources; + */ + // TODO: what does this even mean? /// /// Gets a value that indicates whether multiple argument tokens are allowed for each option identifier token. /// @@ -89,9 +100,10 @@ public ArgumentArity Arity /// public bool AllowMultipleArgumentsPerToken { get; set; } - internal virtual bool Greedy - => Argument.Arity.MinimumNumberOfValues > 0 && Argument.ValueType != typeof(bool); + // TODO: rename to IsGreedy + internal virtual bool Greedy => Argument.Arity.MinimumNumberOfValues > 0 && Argument.ValueType != typeof(bool); + // TODO: rename to IsRequired and move to Validation /// /// Indicates whether the option is required when its parent command is invoked. /// @@ -104,6 +116,8 @@ internal virtual bool Greedy /// The collection does not contain the of the Option. public ICollection Aliases => _aliases ??= new(); + // TODO: invocation, completion + /* /// /// Gets or sets the for the Option. The handler represents the action /// that will be performed when the Option is invoked. @@ -132,5 +146,6 @@ public override IEnumerable GetCompletions(CompletionContext con .OrderBy(item => item.SortText.IndexOfCaseInsensitive(context.WordToComplete)) .ThenBy(symbol => symbol.Label, StringComparer.OrdinalIgnoreCase); } + */ } } diff --git a/src/System.CommandLine/CliOption{T}.cs b/src/System.CommandLine/CliOption{T}.cs index 0a9e857578..f2ca874d1a 100644 --- a/src/System.CommandLine/CliOption{T}.cs +++ b/src/System.CommandLine/CliOption{T}.cs @@ -9,6 +9,7 @@ namespace System.CommandLine /// The that the option's arguments are expected to be parsed as. public class CliOption : CliOption { +// TODO: do not expose private fields internal readonly CliArgument _argument; /// @@ -29,21 +30,26 @@ private protected CliOption(string name, string[] aliases, CliArgument argume } /// - public Func? DefaultValueFactory + internal Func? DefaultValueFactory { get => _argument.DefaultValueFactory; set => _argument.DefaultValueFactory = value; } +// TODO: custom parser +/* /// public Func? CustomParser { get => _argument.CustomParser; set => _argument.CustomParser = value; } +*/ internal sealed override CliArgument Argument => _argument; +// TODO: completion, validator +/* /// /// Configures the option to accept only the specified values, and to suggest them as command line completions. /// @@ -60,5 +66,6 @@ public Func? DefaultValueFactory /// /// A parse error will result, for example, if file path separators are found in the parsed value. public void AcceptLegalFileNamesOnly() => _argument.AcceptLegalFileNamesOnly(); + */ } } \ No newline at end of file diff --git a/src/System.CommandLine/CliRootCommand.cs b/src/System.CommandLine/CliRootCommand.cs index 7c150b2440..b1cde62ddf 100644 --- a/src/System.CommandLine/CliRootCommand.cs +++ b/src/System.CommandLine/CliRootCommand.cs @@ -2,8 +2,6 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Collections.Generic; -using System.CommandLine.Completions; -using System.CommandLine.Help; using System.IO; using System.Reflection; @@ -19,22 +17,24 @@ namespace System.CommandLine /// public class CliRootCommand : CliCommand { - private static Assembly? _assembly; - private static string? _executablePath; - private static string? _executableName; - private static string? _executableVersion; - + /* /// The description of the command, shown in help. - public CliRootCommand(string description = "") : base(ExecutableName, description) + */ + public CliRootCommand(/*string description = "" */) + : base(CliExecutable.ExecutableName/*, description*/) { + /* Options.Add(new HelpOption()); - Options.Add(new VersionOption()); + Options.Add(new VersionOption()); Directives = new ChildSymbolList(this) { new SuggestDirective() }; + */ } - + +// TODO: directives +/* /// /// Represents all of the directives that are valid under the root command. /// @@ -44,37 +44,6 @@ public CliRootCommand(string description = "") : base(ExecutableName, descriptio /// Adds a to the command. /// public void Add(CliDirective directive) => Directives.Add(directive); - - internal static Assembly GetAssembly() - => _assembly ??= (Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly()); - - /// - /// The name of the currently running executable. - /// - public static string ExecutableName - => _executableName ??= Path.GetFileNameWithoutExtension(ExecutablePath).Replace(" ", ""); - - /// - /// The path to the currently running executable. - /// - public static string ExecutablePath => _executablePath ??= Environment.GetCommandLineArgs()[0]; - - internal static string ExecutableVersion => _executableVersion ??= GetExecutableVersion(); - - private static string GetExecutableVersion() - { - var assembly = GetAssembly(); - - var assemblyVersionAttribute = assembly.GetCustomAttribute(); - - if (assemblyVersionAttribute is null) - { - return assembly.GetName().Version?.ToString() ?? ""; - } - else - { - return assemblyVersionAttribute.InformationalVersion; - } - } +*/ } } diff --git a/src/System.CommandLine/CliSymbol.cs b/src/System.CommandLine/CliSymbol.cs index 35ccd1887e..5ecd7320cd 100644 --- a/src/System.CommandLine/CliSymbol.cs +++ b/src/System.CommandLine/CliSymbol.cs @@ -2,7 +2,6 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Collections.Generic; -using System.CommandLine.Completions; using System.Diagnostics; namespace System.CommandLine @@ -16,12 +15,13 @@ private protected CliSymbol(string name, bool allowWhitespace = false) { Name = ThrowIfEmptyOrWithWhitespaces(name, nameof(name), allowWhitespace); } - + // TODO: help + /* /// /// Gets or sets the description of the symbol. /// public string? Description { get; set; } - + */ /// /// Gets the name of the symbol. /// @@ -48,6 +48,7 @@ internal void AddParent(CliSymbol symbol) current.Next = new SymbolNode(symbol); } } + /* /// /// Gets or sets a value indicating whether the symbol is hidden. @@ -74,7 +75,7 @@ public IEnumerable Parents /// Gets completions for the symbol. /// public abstract IEnumerable GetCompletions(CompletionContext context); - +*/ /// public override string ToString() => $"{GetType().Name}: {Name}"; diff --git a/src/System.CommandLine/CliValueSymbol.cs b/src/System.CommandLine/CliValueSymbol.cs new file mode 100644 index 0000000000..812961b75a --- /dev/null +++ b/src/System.CommandLine/CliValueSymbol.cs @@ -0,0 +1,19 @@ +// 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; + +public abstract class CliValueSymbol : CliSymbol +{ + protected CliValueSymbol(string name, bool allowWhitespace = false) + : base(name, allowWhitespace) + { } + + /// + /// Gets or sets the that the argument's parsed tokens will be converted to. + /// + public abstract Type ValueType { get; } + + internal bool IsBoolean() => ValueType == typeof(bool) || ValueType == typeof(bool?); + +} diff --git a/src/System.CommandLine/LocalizationResources.cs b/src/System.CommandLine/LocalizationResources.cs index 18f3c33d76..cfde47ccbd 100644 --- a/src/System.CommandLine/LocalizationResources.cs +++ b/src/System.CommandLine/LocalizationResources.cs @@ -16,9 +16,9 @@ internal static class LocalizationResources /// /// Interpolates values into a localized string similar to Command '{0}' expects a single argument but {1} were provided. /// - internal static string ExpectsOneArgument(OptionResult optionResult) + internal static string ExpectsOneArgument(CliOptionResultInternal optionResult) => GetResourceString(Properties.Resources.OptionExpectsOneArgument, GetOptionName(optionResult), optionResult.Tokens.Count); - +/* /// /// Interpolates values into a localized string similar to Directory does not exist: {0}. /// @@ -48,19 +48,19 @@ internal static string InvalidCharactersInPath(char invalidChar) => /// internal static string InvalidCharactersInFileName(char invalidChar) => GetResourceString(Properties.Resources.InvalidCharactersInFileName, invalidChar); - +*/ /// /// Interpolates values into a localized string similar to Required argument missing for command: {0}. /// - internal static string RequiredArgumentMissing(ArgumentResult argumentResult) => - argumentResult.Parent is CommandResult commandResult + internal static string RequiredArgumentMissing(CliArgumentResultInternal argumentResult) => + argumentResult.Parent is CliCommandResultInternal commandResult ? GetResourceString(Properties.Resources.CommandRequiredArgumentMissing, commandResult.IdentifierToken.Value) - : RequiredArgumentMissing((OptionResult)argumentResult.Parent!); + : RequiredArgumentMissing((CliOptionResultInternal)argumentResult.Parent!); /// /// Interpolates values into a localized string similar to Required argument missing for option: {0}. /// - internal static string RequiredArgumentMissing(OptionResult optionResult) => + internal static string RequiredArgumentMissing(CliOptionResultInternal optionResult) => GetResourceString(Properties.Resources.OptionRequiredArgumentMissing, GetOptionName(optionResult)); /// @@ -98,7 +98,7 @@ internal static string ResponseFileNotFound(string filePath) => /// internal static string ErrorReadingResponseFile(string filePath, IOException e) => GetResourceString(Properties.Resources.ErrorReadingResponseFile, filePath, e.Message); - +/* /// /// Interpolates values into a localized string similar to Show help and usage information. /// @@ -200,7 +200,7 @@ internal static string VersionOptionCannotBeCombinedWithOtherArguments(string op /// internal static string ExceptionHandlerHeader() => GetResourceString(Properties.Resources.ExceptionHandlerHeader); - +*/ /// /// Interpolates values into a localized string similar to Cannot parse argument '{0}' as expected type {1}.. /// @@ -252,6 +252,6 @@ private static string GetResourceString(string resourceString, params object[] f return resourceString; } - private static string GetOptionName(OptionResult optionResult) => optionResult.IdentifierToken?.Value ?? optionResult.Option.Name; + private static string GetOptionName(CliOptionResultInternal optionResult) => optionResult.IdentifierToken?.Value ?? optionResult.Option.Name; } } diff --git a/src/System.CommandLine/ParseResult.cs b/src/System.CommandLine/ParseResult.cs index c7683060a8..07d068f747 100644 --- a/src/System.CommandLine/ParseResult.cs +++ b/src/System.CommandLine/ParseResult.cs @@ -2,8 +2,6 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Collections.Generic; -using System.CommandLine.Completions; -using System.CommandLine.Invocation; using System.CommandLine.Parsing; using System.Linq; using System.Threading.Tasks; @@ -16,30 +14,55 @@ namespace System.CommandLine /// public sealed class ParseResult { - private readonly CommandResult _rootCommandResult; + private readonly IReadOnlyDictionary valueResultDictionary = new Dictionary(); + private SymbolLookupByName? symbolLookupByName = null; + + // TODO: Remove usage and remove + private readonly CliCommandResultInternal _rootCommandResult; + // TODO: unmatched tokens, invocation, completion + /* private readonly IReadOnlyList _unmatchedTokens; private CompletionContext? _completionContext; private readonly CliAction? _action; private readonly List? _preActions; + */ internal ParseResult( CliConfiguration configuration, - CommandResult rootCommandResult, - CommandResult commandResult, + // TODO: Remove RootCommandResult - it is the root of the CommandValueResult ancestors (fix that) + CliCommandResultInternal rootCommandResult, + // TODO: Replace with CommandValueResult and remove CommandResult + CliCommandResultInternal commandResultInternal, + IReadOnlyDictionary valueResultDictionary, + /* List tokens, - List? unmatchedTokens, + */ + // TODO: unmatched tokens + // List? unmatchedTokens, List? errors, - string? commandLineText = null, + // TODO: commandLineText should be string array + string? commandLineText = null //, + // TODO: invocation + /* CliAction? action = null, List? preActions = null) + */ + ) { Configuration = configuration; _rootCommandResult = rootCommandResult; - CommandResult = commandResult; + // TODO: Why do we need this? + CommandResultInternal = commandResultInternal; + CommandResult = commandResultInternal.CommandResult; + this.valueResultDictionary = valueResultDictionary; + // TODO: invocation + /* _action = action; _preActions = preActions; - + */ + /* // skip the root command when populating Tokens property + /* if (tokens.Count > 1) { // Since TokenizeResult.Tokens is not public and not used anywhere after the parsing, @@ -52,18 +75,37 @@ internal ParseResult( { Tokens = Array.Empty(); } + */ CommandLineText = commandLineText; - _unmatchedTokens = unmatchedTokens is null ? Array.Empty() : unmatchedTokens; + + // TODO: unmatched tokens + // _unmatchedTokens = unmatchedTokens is null ? Array.Empty() : unmatchedTokens; + Errors = errors is not null ? errors : Array.Empty(); } + public CliSymbol? GetSymbolByName(string name, bool valuesOnly = false) + { + + symbolLookupByName ??= new SymbolLookupByName(this); + return symbolLookupByName.TryGetSymbol(name, out var symbol, valuesOnly: valuesOnly) + ? symbol + : throw new ArgumentException($"No symbol result found with name \"{name}\".", nameof(name)); + } + + // TODO: check that constructing empty ParseResult directly is correct + /* internal static ParseResult Empty() => new CliRootCommand().Parse(Array.Empty()); + */ /// /// A result indicating the command specified in the command line input. /// - public CommandResult CommandResult { get; } + // TODO: Update SymbolLookupByName to use CommandValueResult, then remove + internal CliCommandResultInternal CommandResultInternal { get; } + + public CliCommandResult CommandResult { get; } /// /// The configuration used to produce the parse result. @@ -73,24 +115,32 @@ internal ParseResult( /// /// Gets the root command result. /// - public CommandResult RootCommandResult => _rootCommandResult; + // TODO: Update usage and then remove + internal CliCommandResultInternal RootCommandResult => _rootCommandResult; /// /// Gets the parse errors found while parsing command line input. /// public IReadOnlyList Errors { get; } + /* + // TODO: don't expose tokens + // TODO: This appears to be set, but only read during testing. Consider removing. /// /// Gets the tokens identified while parsing command line input. /// - public IReadOnlyList Tokens { get; } + internal IReadOnlyList Tokens { get; } + */ + // TODO: This appears to be set, but never used. Consider removing. /// /// Holds the value of a complete command line input prior to splitting and tokenization, when provided. /// /// This will not be set when the parser is called from Program.Main. It is primarily used when calculating suggestions via the dotnet-suggest tool. internal string? CommandLineText { get; } + // TODO: CommandLineText, completion + /* /// /// Gets the list of tokens used on the command line that were not matched by the parser. /// @@ -105,14 +155,14 @@ public CompletionContext GetCompletionContext() => CommandLineText is null ? new CompletionContext(this) : new TextCompletionContext(this, CommandLineText); - + */ /// /// Gets the parsed or default value for the specified argument. /// /// The argument for which to get a value. /// The parsed value or a configured default. public T? GetValue(CliArgument argument) - => RootCommandResult.GetValue(argument); + => GetValueInternal(argument); /// /// Gets the parsed or default value for the specified option. @@ -120,7 +170,12 @@ CommandLineText is null /// The option for which to get a value. /// The parsed value or a configured default. public T? GetValue(CliOption option) - => RootCommandResult.GetValue(option); + => GetValueInternal(option); + + private T? GetValueInternal(CliValueSymbol valueSymbol) + => valueResultDictionary.TryGetValue(valueSymbol, out var result) + ? (T?)result.Value + : default; /// /// Gets the parsed or default value for the specified symbol name, in the context of parsed command (not entire symbol tree). @@ -131,50 +186,82 @@ CommandLineText is null /// Thrown when there was no symbol defined for given name for the parsed command. /// Thrown when parsed result can not be cast to . public T? GetValue(string name) - => RootCommandResult.GetValue(name); + { + var symbol = GetSymbolByName(name, valuesOnly: true); + return symbol switch + { + CliArgument argument => GetValue(argument), + CliOption option => GetValue(option), + _ => throw new InvalidOperationException("Unexpected symbol type") + }; + } + // TODO: diagramming + /* /// public override string ToString() => ParseDiagramAction.Diagram(this).ToString(); + */ + + /// + /// Gets the if any, for the specified option or argument + /// + /// The option or argument for which to find a result. + /// A result for the specified option, or if it was not entered by the user. + public CliValueResult? GetValueResult(CliValueSymbol valueSymbol) + => GetValueResultInternal(valueSymbol); + + private CliValueResult? GetValueResultInternal(CliValueSymbol valueSymbol) + => valueResultDictionary.TryGetValue(valueSymbol, out var result) + ? result + : null; + // TODO: Update tests and remove all use of things deriving from SymbolResult from this class /// /// Gets the result, if any, for the specified argument. /// /// The argument for which to find a result. /// A result for the specified argument, or if it was not provided and no default was configured. - public ArgumentResult? GetResult(CliArgument argument) => + internal CliArgumentResultInternal? GetResult(CliArgument argument) => _rootCommandResult.GetResult(argument); + /* Not used /// /// Gets the result, if any, for the specified command. /// /// The command for which to find a result. /// A result for the specified command, or if it was not provided. - public CommandResult? GetResult(CliCommand command) => + internal CliCommandResultInternal? GetResult(CliCommand command) => _rootCommandResult.GetResult(command); + */ /// /// Gets the result, if any, for the specified option. /// /// The option for which to find a result. /// A result for the specified option, or if it was not provided and no default was configured. - public OptionResult? GetResult(CliOption option) => + internal CliOptionResultInternal? GetResult(CliOption option) => _rootCommandResult.GetResult(option); + // TODO: Directives + /* /// /// Gets the result, if any, for the specified directive. /// /// The directive for which to find a result. /// A result for the specified directive, or if it was not provided. public DirectiveResult? GetResult(CliDirective directive) => _rootCommandResult.GetResult(directive); - + */ + /* Replaced with GetValueResult /// /// Gets the result, if any, for the specified symbol. /// /// The symbol for which to find a result. /// A result for the specified symbol, or if it was not provided and no default was configured. - public SymbolResult? GetResult(CliSymbol symbol) - => _rootCommandResult.SymbolResultTree.TryGetValue(symbol, out SymbolResult? result) ? result : null; - + public CliSymbolResultInternal? GetResult(CliSymbol symbol) + => _rootCommandResult.SymbolResultTree.TryGetValue(symbol, out CliSymbolResultInternal? result) ? result : null; + */ + // TODO: completion, invocation + /* /// /// Gets completions based on a given parse result. /// @@ -183,14 +270,14 @@ CommandLineText is null public IEnumerable GetCompletions( int? position = null) { - SymbolResult currentSymbolResult = SymbolToComplete(position); + CliSymbolResultInternal currentSymbolResult = SymbolToComplete(position); CliSymbol currentSymbol = currentSymbolResult switch { ArgumentResult argumentResult => argumentResult.Argument, OptionResult optionResult => optionResult.Option, DirectiveResult directiveResult => directiveResult.Directive, - _ => ((CommandResult)currentSymbolResult).Command + _ => ((CliCommandResultInternal)currentSymbolResult).Command }; var context = GetCompletionContext(); @@ -203,16 +290,16 @@ public IEnumerable GetCompletions( var completions = currentSymbol.GetCompletions(context); - string[] optionsWithArgumentLimitReached = currentSymbolResult is CommandResult commandResult - ? OptionsWithArgumentLimitReached(commandResult) - : Array.Empty(); + string[] optionsWithArgumentLimitReached = currentSymbolResult is CliCommandResultInternal commandResult + ? OptionsWithArgumentLimitReached(commandResult) + : Array.Empty(); completions = completions.Where(item => optionsWithArgumentLimitReached.All(s => s != item.Label)); return completions; - static string[] OptionsWithArgumentLimitReached(CommandResult commandResult) => + static string[] OptionsWithArgumentLimitReached(CliCommandResultInternal commandResult) => commandResult .Children .OfType() @@ -269,13 +356,13 @@ public int Invoke() /// Gets the for parsed result. The handler represents the action /// that will be performed when the parse result is invoked. /// - public CliAction? Action => _action ?? CommandResult.Command.Action; + public CliAction? Action => _action ?? CliCommandResultInternal.Command.Action; internal IReadOnlyList? PreActions => _preActions; - private SymbolResult SymbolToComplete(int? position = null) + private CliSymbolResultInternal SymbolToComplete(int? position = null) { - var commandResult = CommandResult; + var commandResult = CliCommandResultInternal; var allSymbolResultsForCompletion = AllSymbolResultsForCompletion(); @@ -283,11 +370,11 @@ private SymbolResult SymbolToComplete(int? position = null) return currentSymbol; - IEnumerable AllSymbolResultsForCompletion() + IEnumerable AllSymbolResultsForCompletion() { foreach (var item in commandResult.AllSymbolResults()) { - if (item is CommandResult command) + if (item is CliCommandResultInternal command) { yield return command; } @@ -336,5 +423,6 @@ static bool WillAcceptAnArgument( return !optionResult.IsArgumentLimitReached; } } + */ } } \ No newline at end of file diff --git a/src/System.CommandLine/Parsing/ArgumentResult.cs b/src/System.CommandLine/Parsing/CliArgumentResultInternal.cs similarity index 74% rename from src/System.CommandLine/Parsing/ArgumentResult.cs rename to src/System.CommandLine/Parsing/CliArgumentResultInternal.cs index a6c3042518..a6479af553 100644 --- a/src/System.CommandLine/Parsing/ArgumentResult.cs +++ b/src/System.CommandLine/Parsing/CliArgumentResultInternal.cs @@ -9,19 +9,36 @@ namespace System.CommandLine.Parsing /// /// A result produced when parsing an . /// - public sealed class ArgumentResult : SymbolResult + internal sealed class CliArgumentResultInternal : CliSymbolResultInternal { private ArgumentConversionResult? _conversionResult; private bool _onlyTakeHasBeenCalled; - internal ArgumentResult( + internal CliArgumentResultInternal( CliArgument argument, SymbolResultTree symbolResultTree, - SymbolResult? parent) : base(symbolResultTree, parent) + CliSymbolResultInternal? parent) : base(symbolResultTree, parent) { Argument = argument ?? throw new ArgumentNullException(nameof(argument)); } + private CliValueResult? _valueResult; + public CliValueResult ValueResult + { + get + { + if (_valueResult is null) + { + // This is not lazy on the assumption that almost everything the user enters will be used, and ArgumentResult is no longer used for defaults + // TODO: Make sure errors are added + var conversionValue = GetArgumentConversionResult().Value; + var locations = Tokens.Select(token => token.Location).ToArray(); + _valueResult = new CliValueResult(Argument, conversionValue, locations, CliArgumentResultInternal.GetValueResultOutcome(GetArgumentConversionResult()?.Result)); // null is temporary here + } + return _valueResult; + } + } + /// /// The argument to which the result applies. /// @@ -41,13 +58,14 @@ public T GetValueOrDefault() => .ConvertIfNeeded(typeof(T)) .GetValueOrDefault(); + // TODO: Fix cref for unmatched tokens /// - /// Specifies the maximum number of tokens to consume for the argument. Remaining tokens are passed on and can be consumed by later arguments, or will otherwise be added to + /// Specifies the maximum number of tokens to consume for the argument. Remaining tokens are passed on and can be consumed by later arguments, or will otherwise be added to see cref="ParseResult.UnmatchedTokens"/> /// /// The number of tokens to take. The rest are passed on. /// numberOfTokens - Value must be at least 1. /// Thrown if this method is called more than once. - /// Thrown if this method is called by Option-owned ArgumentResult. + /// Thrown if this method is called by Option-owned CliArgumentResultInternal. public void OnlyTake(int numberOfTokens) { if (numberOfTokens < 0) @@ -60,9 +78,9 @@ public void OnlyTake(int numberOfTokens) throw new InvalidOperationException($"{nameof(OnlyTake)} can only be called once."); } - if (Parent is OptionResult) + if (Parent is CliOptionResultInternal) { - throw new NotSupportedException($"{nameof(OnlyTake)} is supported only for a {nameof(CliCommand)}-owned {nameof(ArgumentResult)}"); + throw new NotSupportedException($"{nameof(OnlyTake)} is supported only for a {nameof(CliCommand)}-owned {nameof(CliArgumentResultInternal)}"); } _onlyTakeHasBeenCalled = true; @@ -72,7 +90,7 @@ public void OnlyTake(int numberOfTokens) return; } - CommandResult parent = (CommandResult)Parent!; + CliCommandResultInternal parent = (CliCommandResultInternal)Parent!; var arguments = parent.Command.Arguments; int argumentIndex = arguments.IndexOf(Argument); int nextArgumentIndex = argumentIndex + 1; @@ -81,16 +99,16 @@ public void OnlyTake(int numberOfTokens) while (tokensToPass > 0 && nextArgumentIndex < arguments.Count) { CliArgument nextArgument = parent.Command.Arguments[nextArgumentIndex]; - ArgumentResult nextArgumentResult; + CliArgumentResultInternal nextArgumentResult; - if (SymbolResultTree.TryGetValue(nextArgument, out SymbolResult? symbolResult)) + if (SymbolResultTree.TryGetValue(nextArgument, out CliSymbolResultInternal? symbolResult)) { - nextArgumentResult = (ArgumentResult)symbolResult; + nextArgumentResult = (CliArgumentResultInternal)symbolResult; } else { // it might have not been parsed yet or due too few arguments, so we add it now - nextArgumentResult = new ArgumentResult(nextArgument, SymbolResultTree, Parent); + nextArgumentResult = new CliArgumentResultInternal(nextArgument, SymbolResultTree, Parent); SymbolResultTree.Add(nextArgument, nextArgumentResult); } @@ -105,7 +123,7 @@ public void OnlyTake(int numberOfTokens) nextArgumentIndex++; } - CommandResult rootCommand = parent; + CliCommandResultInternal rootCommand = parent; // When_tokens_are_passed_on_by_custom_parser_on_last_argument_then_they_become_unmatched_tokens while (tokensToPass > 0) { @@ -117,10 +135,10 @@ public void OnlyTake(int numberOfTokens) } /// - public override string ToString() => $"{nameof(ArgumentResult)} {Argument.Name}: {string.Join(" ", Tokens.Select(t => $"<{t.Value}>"))}"; + public override string ToString() => $"{nameof(CliArgumentResultInternal)} {Argument.Name}: {string.Join(" ", Tokens.Select(t => $"<{t.Value}>"))}"; /// - public override void AddError(string errorMessage) + internal override void AddError(string errorMessage) { SymbolResultTree.AddError(new ParseError(errorMessage, AppliesToPublicSymbolResult)); _conversionResult = ArgumentConversionResult.Failure(this, errorMessage, ArgumentConversionResultType.Failed); @@ -132,7 +150,8 @@ private ArgumentConversionResult ValidateAndConvert(bool useValidators) { return ReportErrorIfNeeded(arityFailure); } - +// TODO: validators +/* // There is nothing that stops user-defined Validator from calling ArgumentResult.GetValueOrDefault. // In such cases, we can't call the validators again, as it would create infinite recursion. // GetArgumentConversionResult => ValidateAndConvert => Validator @@ -150,7 +169,10 @@ private ArgumentConversionResult ValidateAndConvert(bool useValidators) return _conversionResult; } } +*/ + // TODO: defaults + /* if (Parent!.UseDefaultValueFor(this)) { var defaultValue = Argument.GetDefaultValue(this); @@ -158,6 +180,7 @@ private ArgumentConversionResult ValidateAndConvert(bool useValidators) // default value factory provided by the user might report an error, which sets _conversionResult return _conversionResult ?? ArgumentConversionResult.Success(this, defaultValue); } + */ if (Argument.ConvertArguments is null) { @@ -191,7 +214,7 @@ private ArgumentConversionResult ValidateAndConvert(bool useValidators) ArgumentConversionResult.ArgumentConversionCannotParse( this, Argument.ValueType, - Tokens.Count > 0 + Tokens.Count > 0 ? Tokens[0].Value : "")); @@ -209,7 +232,15 @@ ArgumentConversionResult ReportErrorIfNeeded(ArgumentConversionResult result) /// /// Since Option.Argument is an internal implementation detail, this ArgumentResult applies to the OptionResult in public API if the parent is an OptionResult. /// - private SymbolResult AppliesToPublicSymbolResult => - Parent is OptionResult optionResult ? optionResult : this; + private CliSymbolResultInternal AppliesToPublicSymbolResult => + Parent is CliOptionResultInternal optionResult ? optionResult : this; + + internal static ValueResultOutcome GetValueResultOutcome(ArgumentConversionResultType? resultType) + => resultType switch + { + ArgumentConversionResultType.NoArgument => ValueResultOutcome.NoArgument, + ArgumentConversionResultType.Successful => ValueResultOutcome.Success, + _ => ValueResultOutcome.HasErrors + }; } } diff --git a/src/System.CommandLine/Parsing/CliCommandResult.cs b/src/System.CommandLine/Parsing/CliCommandResult.cs new file mode 100644 index 0000000000..2d1fa10848 --- /dev/null +++ b/src/System.CommandLine/Parsing/CliCommandResult.cs @@ -0,0 +1,47 @@ +// 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; + +namespace System.CommandLine.Parsing; + +/// +/// Provides the publicly facing command result +/// +/// +/// The name is temporary as we expect to later name this CliCommandResultInternal and the previous one to CommandResultInternal +/// +public class CliCommandResult : CliSymbolResult +{ + /// + /// Creates a CommandValueResult instance + /// + /// The CliCommand that the result is for. + /// + /// The parent command in the case of a CLI hierarchy, or null if there is no parent. + internal CliCommandResult( + CliCommand command, + IEnumerable locations, + CliCommandResult? parent = null) + : base(locations) + { + Command = command; + Parent = parent; + } + + /// + /// The ValueResult instances for user entered data. This is a sparse list. + /// + public IReadOnlyList ValueResults { get; internal set; } = []; + + /// + /// The CliCommand that the result is for. + /// + public CliCommand Command { get; } + + /// + /// The command's parent if one exists, otherwise, null + /// + public CliCommandResult? Parent { get; } + +} diff --git a/src/System.CommandLine/Parsing/CommandResult.cs b/src/System.CommandLine/Parsing/CliCommandResultInternal.cs similarity index 64% rename from src/System.CommandLine/Parsing/CommandResult.cs rename to src/System.CommandLine/Parsing/CliCommandResultInternal.cs index 9c7c008fa6..764ecf824b 100644 --- a/src/System.CommandLine/Parsing/CommandResult.cs +++ b/src/System.CommandLine/Parsing/CliCommandResultInternal.cs @@ -9,13 +9,14 @@ namespace System.CommandLine.Parsing /// /// A result produced when parsing a . /// - public sealed class CommandResult : SymbolResult + internal sealed class CliCommandResultInternal + : CliSymbolResultInternal { - internal CommandResult( + internal CliCommandResultInternal( CliCommand command, CliToken token, SymbolResultTree symbolResultTree, - CommandResult? parent = null) : + CliCommandResultInternal? parent = null) : base(symbolResultTree, parent) { Command = command ?? throw new ArgumentNullException(nameof(command)); @@ -27,33 +28,66 @@ internal CommandResult( /// public CliCommand Command { get; } + // FIXME: should CliToken be public or internal? /// /// The token that was parsed to specify the command. /// - public CliToken IdentifierToken { get; } + internal CliToken IdentifierToken { get; } /// /// Child symbol results in the parse tree. /// - public IEnumerable Children => SymbolResultTree.GetChildren(this); + public IEnumerable Children => SymbolResultTree.GetChildren(this); + + private CliCommandResult? commandResult; + public CliCommandResult CommandResult + { + get + { + if (commandResult is null) + { + var parent = Parent is CliCommandResultInternal commandResultInternal + ? commandResultInternal.CommandResult + : null; + commandResult = new CliCommandResult(Command, Tokens.Select(t => t.Location), parent); + } + // Reset unless we put tests in place to ensure it is not called in error handling before SymbolTree processing is complete + commandResult.ValueResults = Children.Select(GetValueResult).OfType().ToList(); + return commandResult; + } + } + + private CliValueResult? GetValueResult(CliSymbolResultInternal symbolResult) + => symbolResult switch + { + CliArgumentResultInternal argumentResult => argumentResult.ValueResult, + CliOptionResultInternal optionResult => optionResult.ValueResult, + _ => null! + }; /// - public override string ToString() => $"{nameof(CommandResult)}: {IdentifierToken.Value} {string.Join(" ", Tokens.Select(t => t.Value))}"; + public override string ToString() => $"{nameof(CliCommandResultInternal)}: {IdentifierToken.Value} {string.Join(" ", Tokens.Select(t => t.Value))}"; + // TODO: DefaultValues + /* internal override bool UseDefaultValueFor(ArgumentResult argumentResult) => argumentResult.Argument.HasDefaultValue && argumentResult.Tokens.Count == 0; + */ + // TODO: Validation + /* /// Only the inner most command goes through complete validation. internal void Validate(bool completeValidation) { if (completeValidation) { - if (Command.Action is null && Command.HasSubcommands) + if (Command.HasSubcommands) { SymbolResultTree.InsertFirstError( new ParseError(LocalizationResources.RequiredCommandWasNotProvided(), this)); } + // TODO: validators if (Command.HasValidators) { int errorCountBefore = SymbolResultTree.ErrorCount; @@ -69,6 +103,7 @@ internal void Validate(bool completeValidation) } } + // TODO: Validation if (Command.HasOptions) { ValidateOptions(completeValidation); @@ -87,15 +122,17 @@ private void ValidateOptions(bool completeValidation) { var option = options[i]; - if (!completeValidation && !(option.Recursive || option.Argument.HasDefaultValue || option is VersionOption)) + // TODO: VersionOption, recursive options + // if (!completeValidation && !(option.Recursive || option.Argument.HasDefaultValue || option is VersionOption)) + if (!completeValidation && !option.Argument.HasDefaultValue) { continue; } - OptionResult optionResult; - ArgumentResult argumentResult; + CliOptionResultInternal optionResult; + CliArgumentResultInternal argumentResult; - if (!SymbolResultTree.TryGetValue(option, out SymbolResult? symbolResult)) + if (!SymbolResultTree.TryGetValue(option, out CliSymbolResultInternal? symbolResult)) { if (option.Required || option.Argument.HasDefaultValue) { @@ -118,8 +155,8 @@ private void ValidateOptions(bool completeValidation) } else { - optionResult = (OptionResult)symbolResult; - argumentResult = (ArgumentResult)SymbolResultTree[option.Argument]; + optionResult = (CliOptionResultInternal)symbolResult; + argumentResult = (CliArgumentResultInternal)SymbolResultTree[option.Argument]; } // When_there_is_an_arity_error_then_further_errors_are_not_reported @@ -129,6 +166,7 @@ private void ValidateOptions(bool completeValidation) continue; } + // TODO: validators if (optionResult.Option.HasValidators) { int errorsBefore = SymbolResultTree.ErrorCount; @@ -144,10 +182,12 @@ private void ValidateOptions(bool completeValidation) } } + // TODO: Ensure all argument conversions are run for entered values _ = argumentResult.GetArgumentConversionResult(); } } + // TODO: Validation private void ValidateArguments(bool completeValidation) { var arguments = Command.Arguments; @@ -160,14 +200,14 @@ private void ValidateArguments(bool completeValidation) continue; } - ArgumentResult? argumentResult; - if (SymbolResultTree.TryGetValue(argument, out SymbolResult? symbolResult)) + CliArgumentResultInternal? argumentResult; + if (SymbolResultTree.TryGetValue(argument, out CliSymbolResultInternal? symbolResult)) { - argumentResult = (ArgumentResult)symbolResult; + argumentResult = (CliArgumentResultInternal)symbolResult; } else if (argument.HasDefaultValue || argument.Arity.MinimumNumberOfValues > 0) { - argumentResult = new ArgumentResult(argument, SymbolResultTree, this); + argumentResult = new CliArgumentResultInternal(argument, SymbolResultTree, this); SymbolResultTree[argument] = argumentResult; if (!argument.HasDefaultValue && argument.Arity.MinimumNumberOfValues > 0) @@ -184,5 +224,6 @@ private void ValidateArguments(bool completeValidation) _ = argumentResult.GetArgumentConversionResult(); } } + */ } } diff --git a/src/System.CommandLine/Parsing/OptionResult.cs b/src/System.CommandLine/Parsing/CliOptionResultInternal.cs similarity index 56% rename from src/System.CommandLine/Parsing/OptionResult.cs rename to src/System.CommandLine/Parsing/CliOptionResultInternal.cs index 805b30d9d4..3c235d4195 100644 --- a/src/System.CommandLine/Parsing/OptionResult.cs +++ b/src/System.CommandLine/Parsing/CliOptionResultInternal.cs @@ -10,21 +10,46 @@ namespace System.CommandLine.Parsing /// /// A result produced when parsing an . /// - public sealed class OptionResult : SymbolResult + internal sealed class CliOptionResultInternal : CliSymbolResultInternal { private ArgumentConversionResult? _argumentConversionResult; - internal OptionResult( + internal CliOptionResultInternal( CliOption option, SymbolResultTree symbolResultTree, CliToken? token = null, - CommandResult? parent = null) : + CliCommandResultInternal? parent = null) : base(symbolResultTree, parent) { Option = option ?? throw new ArgumentNullException(nameof(option)); IdentifierToken = token; } + private CliValueResult? _valueResult; + public CliValueResult ValueResult + { + get + { + if (_valueResult is null) + { + // This is not lazy on the assumption that almost everything the user enters will be used, and ArgumentResult is no longer used for defaults + // TODO: Make sure errors are added + var conversionResult = ArgumentConversionResult + .ConvertIfNeeded(Option.Argument.ValueType); + object? conversionValue = conversionResult.Result switch + { + ArgumentConversionResultType.Successful => conversionResult.Value, + ArgumentConversionResultType.NoArgument => default, + _ => default // This is an error condition, and is handled below + }; + var locations = Tokens.Select(token => token.Location).ToArray(); + //TODO: Remove this wrapper later + _valueResult = new CliValueResult(Option, conversionValue, locations, CliArgumentResultInternal.GetValueResultOutcome(ArgumentConversionResult?.Result), conversionResult.ErrorMessage); + } + return _valueResult; + } + } + /// /// The option to which the result applies. /// @@ -36,19 +61,22 @@ internal OptionResult( /// Implicit results commonly result from options having a default value. public bool Implicit => IdentifierToken is null || IdentifierToken.Implicit; + // TODO: make internal because exposes tokens /// /// The token that was parsed to specify the option. /// /// An identifier token is a token that matches either the option's name or one of its aliases. - public CliToken? IdentifierToken { get; } + internal CliToken? IdentifierToken { get; } + // TODO: do we even need IdentifierTokenCount + /* /// /// The number of occurrences of an identifier token matching the option. /// public int IdentifierTokenCount { get; internal set; } - + */ /// - public override string ToString() => $"{nameof(OptionResult)}: {IdentifierToken?.Value ?? Option.Name} {string.Join(" ", Tokens.Select(t => t.Value))}"; + public override string ToString() => $"{nameof(CliOptionResultInternal)}: {IdentifierToken?.Value ?? Option.Name} {string.Join(" ", Tokens.Select(t => t.Value))}"; /// /// Gets the parsed value or the default value for . @@ -65,6 +93,9 @@ internal bool IsArgumentLimitReached internal ArgumentConversionResult ArgumentConversionResult => _argumentConversionResult ??= GetResult(Option.Argument)!.GetArgumentConversionResult(); + // TODO: Default values + /* internal override bool UseDefaultValueFor(ArgumentResult argument) => Implicit; + */ } } diff --git a/src/System.CommandLine/Parsing/CliParser.cs b/src/System.CommandLine/Parsing/CliParser.cs index d05b9f0552..1ab9ddd41e 100644 --- a/src/System.CommandLine/Parsing/CliParser.cs +++ b/src/System.CommandLine/Parsing/CliParser.cs @@ -11,26 +11,29 @@ namespace System.CommandLine.Parsing /// public static class CliParser { + /* + /// The command to use to parse the command line input. + */ /// /// Parses a list of arguments. /// - /// The command to use to parse the command line input. + /// /// The string array typically passed to a program's Main method. /// The configuration on which the parser's grammar and behaviors are based. /// A providing details about the parse operation. - public static ParseResult Parse(CliCommand command, IReadOnlyList args, CliConfiguration? configuration = null) - => Parse(command, args, null, configuration); + public static ParseResult Parse(CliCommand rootCommand, IReadOnlyList args, CliConfiguration? configuration = null) + => Parse(rootCommand, args, null, configuration); /// /// Parses a command line string. /// - /// The command to use to parse the command line input. + /// The command to use to parse the command line input. /// The complete command line input prior to splitting and tokenization. This input is not typically available when the parser is called from Program.Main. It is primarily used when calculating completions via the dotnet-suggest tool. /// The configuration on which the parser's grammar and behaviors are based. /// The command line string input will be split into tokens as if it had been passed on the command line. /// A providing details about the parse operation. - public static ParseResult Parse(CliCommand command, string commandLine, CliConfiguration? configuration = null) - => Parse(command, SplitCommandLine(commandLine).ToArray(), commandLine, configuration); + public static ParseResult Parse(CliCommand rootCommand, string commandLine, CliConfiguration? configuration = null) + => Parse(rootCommand, SplitCommandLine(commandLine).ToArray(), commandLine, configuration); /// /// Splits a string into a sequence of strings based on whitespace and quotation marks. @@ -135,8 +138,9 @@ string CurrentToken() bool IsAtEndOfInput() => pos == memory.Length; } + // TODO: I'd like a name change where all refs to the string args passed to main are "args" and arguments refers to CLI arguments private static ParseResult Parse( - CliCommand command, + CliCommand rootCommand, IReadOnlyList arguments, string? rawInput, CliConfiguration? configuration) @@ -146,9 +150,11 @@ private static ParseResult Parse( throw new ArgumentNullException(nameof(arguments)); } - configuration ??= new CliConfiguration(command); + configuration ??= new CliConfiguration(rootCommand); - arguments.Tokenize( + Tokenizer.Tokenize( + arguments, + rootCommand, configuration, inferRootCommand: rawInput is not null, out List tokens, @@ -156,6 +162,7 @@ private static ParseResult Parse( var operation = new ParseOperation( tokens, + rootCommand, configuration, tokenizationErrors, rawInput); diff --git a/src/System.CommandLine/Parsing/CliSymbolResult.cs b/src/System.CommandLine/Parsing/CliSymbolResult.cs new file mode 100644 index 0000000000..35353a7987 --- /dev/null +++ b/src/System.CommandLine/Parsing/CliSymbolResult.cs @@ -0,0 +1,25 @@ +// 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; + +namespace System.CommandLine.Parsing; + +/// +/// Base class for CliValueResult and CliCommandResult. +/// +/// +/// Common values such as `TextForDisplay` are expected +/// +public abstract class CliSymbolResult(IEnumerable locations) +{ + /// + /// Gets the locations at which the tokens that made up the value appeared. + /// + /// + /// This needs to be a collection for CliValueType because collection types have + /// multiple tokens and they will not be simple offsets when response files are used. + /// + public IEnumerable Locations { get; } = locations; + +} diff --git a/src/System.CommandLine/Parsing/SymbolResult.cs b/src/System.CommandLine/Parsing/CliSymbolResultInternal.cs similarity index 80% rename from src/System.CommandLine/Parsing/SymbolResult.cs rename to src/System.CommandLine/Parsing/CliSymbolResultInternal.cs index dc0a0ba3e8..bf09249159 100644 --- a/src/System.CommandLine/Parsing/SymbolResult.cs +++ b/src/System.CommandLine/Parsing/CliSymbolResultInternal.cs @@ -8,17 +8,19 @@ namespace System.CommandLine.Parsing /// /// A result produced during parsing for a specific symbol. /// - public abstract class SymbolResult + internal abstract class CliSymbolResultInternal { +// TODO: make this a property and protected if possible internal readonly SymbolResultTree SymbolResultTree; private protected List? _tokens; - private protected SymbolResult(SymbolResultTree symbolResultTree, SymbolResult? parent) + private protected CliSymbolResultInternal(SymbolResultTree symbolResultTree, CliSymbolResultInternal? parent) { SymbolResultTree = symbolResultTree; Parent = parent; } - +// TODO: this can be an extension method, do we need it? +/* /// /// The parse errors associated with this symbol result. /// @@ -36,66 +38,72 @@ public IEnumerable Errors for (var i = 0; i < parseErrors.Count; i++) { var parseError = parseErrors[i]; - if (parseError.SymbolResult == this) + if (parseError.CliSymbolResultInternal == this) { yield return parseError; } } } } - +*/ /// /// The parent symbol result in the parse tree. /// - public SymbolResult? Parent { get; } + public CliSymbolResultInternal? Parent { get; } +// TODO: make internal because exposes tokens /// /// The list of tokens associated with this symbol result during parsing. /// - public IReadOnlyList Tokens => _tokens is not null ? _tokens : Array.Empty(); + internal IReadOnlyList Tokens => _tokens is not null ? _tokens : Array.Empty(); internal void AddToken(CliToken token) => (_tokens ??= new()).Add(token); +// TODO: made nonpublic, should we make public again? /// /// Adds an error message for this symbol result to it's parse tree. /// /// Setting an error will cause the parser to indicate an error for the user and prevent invocation of the command line. - public virtual void AddError(string errorMessage) => SymbolResultTree.AddError(new ParseError(errorMessage, this)); - + internal virtual void AddError(string errorMessage) => SymbolResultTree.AddError(new ParseError(errorMessage, this)); /// /// Finds a result for the specific argument anywhere in the parse tree, including parent and child symbol results. /// /// The argument for which to find a result. /// An argument result if the argument was matched by the parser or has a default value; otherwise, null. - public ArgumentResult? GetResult(CliArgument argument) => SymbolResultTree.GetResult(argument); + internal CliArgumentResultInternal? GetResult(CliArgument argument) => SymbolResultTree.GetResultInternal(argument); + /* Not used /// /// Finds a result for the specific command anywhere in the parse tree, including parent and child symbol results. /// /// The command for which to find a result. /// An command result if the command was matched by the parser; otherwise, null. - public CommandResult? GetResult(CliCommand command) => SymbolResultTree.GetResult(command); + internal CliCommandResultInternal? GetResult(CliCommand command) => SymbolResultTree.GetResult(command); + */ /// /// Finds a result for the specific option anywhere in the parse tree, including parent and child symbol results. /// /// The option for which to find a result. /// An option result if the option was matched by the parser or has a default value; otherwise, null. - public OptionResult? GetResult(CliOption option) => SymbolResultTree.GetResult(option); + internal CliOptionResultInternal? GetResult(CliOption option) => SymbolResultTree.GetResultInternal(option); +// TODO: directives +/* /// /// Finds a result for the specific directive anywhere in the parse tree. /// /// The directive for which to find a result. /// A directive result if the directive was matched by the parser, null otherwise. public DirectiveResult? GetResult(CliDirective directive) => SymbolResultTree.GetResult(directive); - +*/ + /* No longer used /// /// Finds a result for a symbol having the specified name anywhere in the parse tree. /// /// The name of the symbol for which to find a result. /// An argument result if the argument was matched by the parser or has a default value; otherwise, null. - public SymbolResult? GetResult(string name) => + public CliSymbolResultInternal? GetResult(string name) => SymbolResultTree.GetResult(name); /// @@ -146,7 +154,11 @@ public IEnumerable Errors return CliArgument.CreateDefaultValue(); } + */ + // TODO: DefaultValues + /* internal virtual bool UseDefaultValueFor(ArgumentResult argumentResult) => false; + */ } } diff --git a/src/System.CommandLine/Parsing/CliToken.cs b/src/System.CommandLine/Parsing/CliToken.cs index e268459468..e10ab3a986 100644 --- a/src/System.CommandLine/Parsing/CliToken.cs +++ b/src/System.CommandLine/Parsing/CliToken.cs @@ -3,13 +3,19 @@ namespace System.CommandLine.Parsing { + // TODO: Include location in equality + + // FIXME: should CliToken be public or internal? made internal for now + // FIXME: should CliToken be a struct? /// /// A unit of significant text on the command line. /// - public sealed class CliToken : IEquatable + internal sealed class CliToken : IEquatable { - internal const int ImplicitPosition = -1; + public static CliToken CreateFromOtherToken(CliToken otherToken, string? arg, Location location) + => new(arg, otherToken.Type, otherToken.Symbol, location); + /* /// The string value of the token. /// The type of the token. /// The symbol represented by the token @@ -18,25 +24,30 @@ public CliToken(string? value, CliTokenType type, CliSymbol symbol) Value = value ?? ""; Type = type; Symbol = symbol; - Position = ImplicitPosition; + Location = Location.CreateImplicit(value, value is null ? 0 : value.Length); } - - internal CliToken(string? value, CliTokenType type, CliSymbol? symbol, int position) + */ + + /// The string value of the token. + /// The type of the token. + /// The symbol represented by the token + /// The location of the token in the args array or a response file + internal CliToken(string? value, CliTokenType type, CliSymbol? symbol, Location location) { Value = value ?? ""; Type = type; Symbol = symbol; - Position = position; + Location = location; } - internal int Position { get; } + internal Location Location { get; } /// /// The string value of the token. /// public string Value { get; } - internal bool Implicit => Position == ImplicitPosition; + internal bool Implicit => Location.IsImplicit; /// /// The type of the token. diff --git a/src/System.CommandLine/Parsing/CliValueResult.cs b/src/System.CommandLine/Parsing/CliValueResult.cs new file mode 100644 index 0000000000..7c2b524bed --- /dev/null +++ b/src/System.CommandLine/Parsing/CliValueResult.cs @@ -0,0 +1,116 @@ +// 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; + +namespace System.CommandLine.Parsing; + +/// +/// The publicly facing class for argument and option data. +/// +public class CliValueResult : CliSymbolResult +{ + internal CliValueResult( + CliValueSymbol valueSymbol, + object? value, + IEnumerable locations, + ValueResultOutcome outcome, + // TODO: Error should be an Enumerable and perhaps should not be here at all, only on ParseResult + string? error = null) + : base(locations) + { + ValueSymbol = valueSymbol; + Value = value; + Outcome = outcome; + // TODO: Probably a collection of errors here + Error = error; + } + + /// + /// The CliSymbol the value is for. This is always a CliOption or CliArgument. + /// + public CliValueSymbol ValueSymbol { get; } + + internal object? Value { get; } + + /// + /// Returns the value, or the default for the type. + /// + /// The type to return + /// The value, cast to the requested type. + public T? GetValue() + => Value is null + ? default + : (T?)Value; + + /// + /// Returns the value, or the default for the type. + /// + /// The value, cast to the requested type. + public object? GetValue() + => Value is null + ? default + : Value; + + /// + /// True when parsing and converting the value was successful + /// + public ValueResultOutcome Outcome { get; } + + /// + /// Parsing and conversion errors when parsing or converting failed. + /// + public string? Error { get; } + + /// + /// Returns text suitable for display. + /// + /// + /// + public IEnumerable TextForDisplay() + { + throw new NotImplementedException(); + } + + /// + /// Retrieve the portion of the user's entry that was used for this ValuResult. + /// + /// The text the user entered that resulted in this ValueResult. + /// + public IEnumerable TextForCommandReconstruction() + { + // TODO: Write method to retrieve from location. + throw new NotImplementedException(); + } + + /// + /// Retrieve the portion of the user's entry that was used for this ValueResult. + /// + /// The text the user entered that resulted in this ValueResult. + /// + public override string ToString() + => $"{nameof(CliArgumentResultInternal)} {ValueSymbol.Name}: {string.Join(" ", TextForDisplay())}"; + + + // TODO: This might not be the right place for this, (Some completion stuff was stripped out. This was a private method in ArgumentConversionResult) + /* + private string FormatOutcomeMessage() + => ValueSymbol switch + { + CliOption option + => LocalizationResources.ArgumentConversionCannotParseForOption(Value?.ToString() ?? "", option.Name, ValueSymbolType), + CliCommand command + => LocalizationResources.ArgumentConversionCannotParseForCommand(Value?.ToString() ?? "", command.Name, ValueSymbolType), + //TODO + _ => throw new NotImplementedException() + }; + + private Type ValueSymbolType + => ValueSymbol switch + { + CliArgument argument => argument.ValueType, + CliOption option => option.Argument.ValueType, + _ => throw new NotImplementedException() + }; + */ +} diff --git a/src/System.CommandLine/Parsing/DirectiveResult.cs b/src/System.CommandLine/Parsing/DirectiveResult.cs index a1d08544a7..3479071239 100644 --- a/src/System.CommandLine/Parsing/DirectiveResult.cs +++ b/src/System.CommandLine/Parsing/DirectiveResult.cs @@ -5,7 +5,7 @@ namespace System.CommandLine.Parsing /// /// A result produced when parsing an . /// - public sealed class DirectiveResult : SymbolResult + public sealed class DirectiveResult : CliSymbolResultInternal { private List? _values; diff --git a/src/System.CommandLine/Parsing/Location.cs b/src/System.CommandLine/Parsing/Location.cs new file mode 100644 index 0000000000..30d3a5285e --- /dev/null +++ b/src/System.CommandLine/Parsing/Location.cs @@ -0,0 +1,56 @@ +// 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; +using System.Collections.Generic; +using System.ComponentModel; +using static System.Net.Mime.MediaTypeNames; + +namespace System.CommandLine.Parsing +{ + public record Location + { + public const string Implicit = "Implicit"; + public const string Internal = "Internal"; + public const string User = "User"; + public const string Response = "Response"; + + internal static Location CreateRoot(string exeName, bool isImplicit, int start) + => new(exeName, isImplicit ? Internal : User, start, null); + internal static Location CreateImplicit(string text, Location outerLocation, int offset = 0) + => new(text, Implicit, -1, outerLocation, offset); + internal static Location CreateInternal(string text, Location? outerLocation = null, int offset = 0) + => new(text, Internal, -1, outerLocation, offset); + internal static Location CreateUser(string text, int start, Location outerLocation, int offset = 0) + => new(text, User, start, outerLocation, offset); + internal static Location CreateResponse(string responseSourceName, int start, Location outerLocation, int offset = 0) + => new(responseSourceName, $"{Response}:{responseSourceName}", start, outerLocation, offset); + + internal static Location FromOuterLocation(string text, int start, Location outerLocation, int offset = 0) + => new(text, outerLocation.Source, start, outerLocation, offset); + + public Location(string text, string source, int index, Location? outerLocation, int offset = 0) + { + Text = text; + Source = source; + Index = index; + Length = text.Length; + Offset = offset; + OuterLocation = outerLocation; + } + + public string Text { get; } + public string Source { get; } + public int Index { get; } + public int Offset { get; } + public int Length { get; } + public Location? OuterLocation { get; } + + public bool IsImplicit + => Source == Implicit; + + public override string ToString() + => $"{(OuterLocation is null ? "" : OuterLocation.ToString() + "; ")}{Text} from {Source}[{Index}, {Length}, {Offset}]"; + + } +} \ No newline at end of file diff --git a/src/System.CommandLine/Parsing/ParseDiagramAction.cs b/src/System.CommandLine/Parsing/ParseDiagramAction.cs index 474fcfd3af..d8a7b30296 100644 --- a/src/System.CommandLine/Parsing/ParseDiagramAction.cs +++ b/src/System.CommandLine/Parsing/ParseDiagramAction.cs @@ -53,10 +53,10 @@ internal static StringBuilder Diagram(ParseResult parseResult) private static void Diagram( StringBuilder builder, - SymbolResult symbolResult, + CliSymbolResultInternal symbolResult, ParseResult parseResult) { - if (parseResult.Errors.Any(e => e.SymbolResult == symbolResult)) + if (parseResult.Errors.Any(e => e.SymbolResultInternal == symbolResult)) { builder.Append('!'); } @@ -143,10 +143,10 @@ private static void Diagram( } else { - builder.Append(((CommandResult)symbolResult).IdentifierToken.Value); + builder.Append(((CliCommandResultInternal)symbolResult).IdentifierToken.Value); } - foreach (SymbolResult child in symbolResult.SymbolResultTree.GetChildren(symbolResult)) + foreach (CliSymbolResultInternal child in symbolResult.SymbolResultTree.GetChildren(symbolResult)) { if (child is ArgumentResult arg && (arg.Argument.ValueType == typeof(bool) || diff --git a/src/System.CommandLine/Parsing/ParseError.cs b/src/System.CommandLine/Parsing/ParseError.cs index a079b58c8b..9be6314ef3 100644 --- a/src/System.CommandLine/Parsing/ParseError.cs +++ b/src/System.CommandLine/Parsing/ParseError.cs @@ -8,9 +8,11 @@ namespace System.CommandLine.Parsing /// public sealed class ParseError { + // TODO: add position + // TODO: reevaluate whether we should be exposing a CliSymbolResultInternal here internal ParseError( string message, - SymbolResult? symbolResult = null) + CliSymbolResultInternal? symbolResult = null) { if (string.IsNullOrWhiteSpace(message)) { @@ -18,7 +20,20 @@ internal ParseError( } Message = message; - SymbolResult = symbolResult; + /* + CliSymbolResultInternal = symbolResult; + */ + } + + public ParseError( + string message) + { + if (string.IsNullOrWhiteSpace(message)) + { + throw new ArgumentException("Value cannot be null or whitespace.", nameof(message)); + } + + Message = message; } /// @@ -26,10 +41,12 @@ internal ParseError( /// public string Message { get; } + /* Consider how results are attached to errors now that we have ValueResult and CommandValueResult. Should there be a common base? /// /// The symbol result detailing the symbol that failed to parse and the tokens involved. /// - public SymbolResult? SymbolResult { get; } + public CliSymbolResultInternal? CliSymbolResultInternal { get; } + */ /// public override string ToString() => Message; diff --git a/src/System.CommandLine/Parsing/ParseOperation.cs b/src/System.CommandLine/Parsing/ParseOperation.cs index 7b26fa9b9a..3c941ecf97 100644 --- a/src/System.CommandLine/Parsing/ParseOperation.cs +++ b/src/System.CommandLine/Parsing/ParseOperation.cs @@ -2,8 +2,6 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Collections.Generic; -using System.CommandLine.Help; -using System.CommandLine.Invocation; namespace System.CommandLine.Parsing { @@ -13,30 +11,36 @@ internal sealed class ParseOperation private readonly CliConfiguration _configuration; private readonly string? _rawInput; private readonly SymbolResultTree _symbolResultTree; - private readonly CommandResult _rootCommandResult; + private readonly CliCommandResultInternal _rootCommandResult; private int _index; - private CommandResult _innermostCommandResult; + private CliCommandResultInternal _innermostCommandResult; + /* private bool _isHelpRequested; private bool _isTerminatingDirectiveSpecified; + */ +// TODO: invocation +/* private CliAction? _primaryAction; private List? _preActions; - +*/ public ParseOperation( List tokens, + CliCommand rootCommand, CliConfiguration configuration, - List? tokenizeErrors, + List? tokenizationErrors, string? rawInput) { _tokens = tokens; _configuration = configuration; _rawInput = rawInput; - _symbolResultTree = new(_configuration.RootCommand, tokenizeErrors); - _innermostCommandResult = _rootCommandResult = new CommandResult( - _configuration.RootCommand, + _symbolResultTree = new(rootCommand, tokenizationErrors); + + _innermostCommandResult = _rootCommandResult = new CliCommandResultInternal( + rootCommand, CurrentToken, _symbolResultTree); - _symbolResultTree.Add(_configuration.RootCommand, _rootCommandResult); + _symbolResultTree.Add(rootCommand, _rootCommandResult); Advance(); } @@ -54,15 +58,20 @@ private bool More(out CliTokenType currentTokenType) internal ParseResult Parse() { +// TODO: directives +/* ParseDirectives(); - +*/ ParseCommandChildren(); - + /* if (!_isHelpRequested) { Validate(); } + */ +// TODO: invocation +/* if (_primaryAction is null) { if (_symbolResultTree.ErrorCount > 0) @@ -70,24 +79,33 @@ internal ParseResult Parse() _primaryAction = new ParseErrorAction(); } } +*/ - return new ( + return new( _configuration, _rootCommandResult, _innermostCommandResult, + _rootCommandResult.SymbolResultTree.BuildValueResultDictionary(), + /* _tokens, - _symbolResultTree.UnmatchedTokens, + */ +// TODO: unmatched tokens +// _symbolResultTree.UnmatchedTokens, _symbolResultTree.Errors, - _rawInput, + _rawInput +// TODO: invocation +/* _primaryAction, _preActions); +*/ + ); } private void ParseSubcommand() { CliCommand command = (CliCommand)CurrentToken.Symbol!; - _innermostCommandResult = new CommandResult( + _innermostCommandResult = new CliCommandResultInternal( command, CurrentToken, _symbolResultTree, @@ -144,10 +162,10 @@ private void ParseCommandArguments(ref int currentArgumentCount, ref int current } if (!(_symbolResultTree.TryGetValue(argument, out var symbolResult) - && symbolResult is ArgumentResult argumentResult)) + && symbolResult is CliArgumentResultInternal argumentResult)) { argumentResult = - new ArgumentResult( + new CliArgumentResultInternal( argument, _symbolResultTree, _innermostCommandResult); @@ -182,10 +200,12 @@ private void ParseCommandArguments(ref int currentArgumentCount, ref int current private void ParseOption() { CliOption option = (CliOption)CurrentToken.Symbol!; - OptionResult optionResult; + CliOptionResultInternal optionResult; - if (!_symbolResultTree.TryGetValue(option, out SymbolResult? symbolResult)) + if (!_symbolResultTree.TryGetValue(option, out CliSymbolResultInternal? symbolResult)) { +// TODO: invocation, directives, help +/* if (option.Action is not null) { // directives have a precedence over --help and --version @@ -206,8 +226,8 @@ private void ParseOption() } } } - - optionResult = new OptionResult( +*/ + optionResult = new CliOptionResultInternal( option, _symbolResultTree, CurrentToken, @@ -217,17 +237,18 @@ private void ParseOption() } else { - optionResult = (OptionResult)symbolResult; + optionResult = (CliOptionResultInternal)symbolResult; } - optionResult.IdentifierTokenCount++; +// TODO: IdentifierTokenCount +// optionResult.IdentifierTokenCount++; Advance(); ParseOptionArguments(optionResult); } - private void ParseOptionArguments(OptionResult optionResult) + private void ParseOptionArguments(CliOptionResultInternal optionResult) { var argument = optionResult.Option.Argument; @@ -254,10 +275,10 @@ private void ParseOptionArguments(OptionResult optionResult) break; } - if (!(_symbolResultTree.TryGetValue(argument, out SymbolResult? symbolResult) - && symbolResult is ArgumentResult argumentResult)) + if (!(_symbolResultTree.TryGetValue(argument, out CliSymbolResultInternal? symbolResult) + && symbolResult is CliArgumentResultInternal argumentResult)) { - argumentResult = new ArgumentResult( + argumentResult = new CliArgumentResultInternal( argument, _symbolResultTree, optionResult); @@ -284,12 +305,13 @@ private void ParseOptionArguments(OptionResult optionResult) { if (!_symbolResultTree.ContainsKey(argument)) { - var argumentResult = new ArgumentResult(argument, _symbolResultTree, optionResult); + var argumentResult = new CliArgumentResultInternal(argument, _symbolResultTree, optionResult); _symbolResultTree.Add(argument, argumentResult); } } } - +// TODO: directives +/* private void ParseDirectives() { while (More(out CliTokenType currentTokenType) && currentTokenType == CliTokenType.Directive) @@ -355,6 +377,7 @@ private void AddPreAction(CliAction action) _preActions.Add(action); } +*/ private void AddCurrentTokenToUnmatched() { @@ -366,19 +389,22 @@ private void AddCurrentTokenToUnmatched() _symbolResultTree.AddUnmatchedToken(CurrentToken, _innermostCommandResult, _rootCommandResult); } + // TODO: Validation + /* private void Validate() { // Only the inner most command goes through complete validation, // for other commands only a subset of options is checked. _innermostCommandResult.Validate(completeValidation: true); - CommandResult? currentResult = _innermostCommandResult.Parent as CommandResult; + CliCommandResultInternal? currentResult = _innermostCommandResult.Parent as CliCommandResultInternal; while (currentResult is not null) { currentResult.Validate(completeValidation: false); - currentResult = currentResult.Parent as CommandResult; + currentResult = currentResult.Parent as CliCommandResultInternal; } } + */ } -} \ No newline at end of file +} diff --git a/src/System.CommandLine/Parsing/StringExtensions.cs b/src/System.CommandLine/Parsing/StringExtensions.cs index 169070c5f7..93821aa57d 100644 --- a/src/System.CommandLine/Parsing/StringExtensions.cs +++ b/src/System.CommandLine/Parsing/StringExtensions.cs @@ -2,6 +2,8 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; @@ -10,6 +12,7 @@ namespace System.CommandLine.Parsing { internal static class StringExtensions { + /* internal static bool ContainsCaseInsensitive( this string source, string value) => @@ -23,9 +26,16 @@ internal static int IndexOfCaseInsensitive( .IndexOf(source, value, CompareOptions.OrdinalIgnoreCase); + */ + } + + internal static class Tokenizer + { + private const string doubleDash = "--"; - internal static (string? Prefix, string Alias) SplitPrefix(this string rawAlias) + internal static (string? Prefix, string Alias) SplitPrefix(string rawAlias) { + // TODO: I believe this code would be faster and easier to understand with collection patterns if (rawAlias[0] == '/') { return ("/", rawAlias.Substring(1)); @@ -34,7 +44,7 @@ internal static (string? Prefix, string Alias) SplitPrefix(this string rawAlias) { if (rawAlias.Length > 1 && rawAlias[1] == '-') { - return ("--", rawAlias.Substring(2)); + return (doubleDash, rawAlias.Substring(2)); } return ("-", rawAlias.Substring(1)); @@ -43,188 +53,171 @@ internal static (string? Prefix, string Alias) SplitPrefix(this string rawAlias) return (null, rawAlias); } + // TODO: What does the following comment do, and do we need it // this method is not returning a Value Tuple or a dedicated type to avoid JITting + + // TODO: When would we ever not infer the rootcommand? This might have been to solve a bug where the first argument could not be the name of the root command. internal static void Tokenize( - this IReadOnlyList args, + IReadOnlyList args, + CliCommand rootCommand, CliConfiguration configuration, bool inferRootCommand, out List tokens, out List? errors) { - const int FirstArgIsNotRootCommand = -1; - - List? errorList = null; + tokens = new List(args.Count); - var currentCommand = configuration.RootCommand; - var foundDoubleDash = false; - var foundEndOfDirectives = false; - - var tokenList = new List(args.Count); - - var knownTokens = configuration.RootCommand.ValidTokens(); - - int i = FirstArgumentIsRootCommand(args, configuration.RootCommand, inferRootCommand) - ? 0 - : FirstArgIsNotRootCommand; - - for (; i < args.Count; i++) + // Handle exe not being in args + var rootIsExplicit = FirstArgIsRootCommand(args, rootCommand, inferRootCommand); + var rootLocation = Location.CreateRoot(rootCommand.Name, rootIsExplicit, rootIsExplicit ? 0 : -1); + if (!rootIsExplicit) // If it is explicit it will be added in the normal handling loop { - var arg = i == FirstArgIsNotRootCommand - ? configuration.RootCommand.Name - : args[i]; - - if (foundDoubleDash) - { - tokenList.Add(CommandArgument(arg, currentCommand!)); + tokens.Add(Command(rootCommand.Name, rootCommand, rootLocation)); + } - continue; - } + var maxSkippedPositions = configuration.PreProcessedLocations is null + || !configuration.PreProcessedLocations.Any() + ? 0 + : configuration.PreProcessedLocations.Max(x => x.Index); + + + var validTokens = GetValidTokens(rootCommand); + var newErrors = MapTokens(args, + rootLocation, + maxSkippedPositions, + rootCommand, + validTokens, + configuration, + false, + tokens); + + errors = newErrors; + + static List? MapTokens(IReadOnlyList args, + Location location, + int maxSkippedPositions, + CliCommand currentCommand, + Dictionary validTokens, + CliConfiguration configuration, + bool foundDoubleDash, + List tokens) + { + List? errors = null; + var previousOptionWasClosed = false; - if (!foundDoubleDash && - arg == "--") + for (var i = 0; i < args.Count; i++) { - tokenList.Add(DoubleDash()); - foundDoubleDash = true; - continue; - } + var arg = args[i]; - if (!foundEndOfDirectives) - { - if (arg.Length > 2 && - arg[0] == '[' && - arg[1] != ']' && - arg[1] != ':' && - arg[arg.Length - 1] == ']') + if (i <= maxSkippedPositions + && configuration.PreProcessedLocations is not null + && configuration.PreProcessedLocations.Any(x => x.Index == i)) { - int colonIndex = arg.AsSpan().IndexOf(':'); - string directiveName = colonIndex > 0 - ? arg.Substring(1, colonIndex - 1) // [name:value] - : arg.Substring(1, arg.Length - 2); // [name] is a legal directive - - CliDirective? directive; - if (knownTokens.TryGetValue($"[{directiveName}]", out var directiveToken)) - { - directive = (CliDirective)directiveToken.Symbol!; - } - else - { - directive = null; - } - - tokenList.Add(Directive(arg, directive)); continue; } - if (!configuration.RootCommand.EqualsNameOrAlias(arg)) + if (foundDoubleDash) { - foundEndOfDirectives = true; + // everything after the double dash is added as an argument + tokens.Add(CommandArgument(arg, currentCommand!, Location.FromOuterLocation(arg, i, location))); + continue; } - } - if (configuration.ResponseFileTokenReplacer is { } replacer && - arg.GetReplaceableTokenValue() is { } value) - { - if (replacer( - value, - out var newTokens, - out var error)) + if (arg == doubleDash) { - if (newTokens is not null && newTokens.Count > 0) - { - List listWithReplacedTokens = args.ToList(); - listWithReplacedTokens.InsertRange(i + 1, newTokens); - args = listWithReplacedTokens; - } + tokens.Add(DoubleDash(i, Location.FromOuterLocation(arg, i, location))); + foundDoubleDash = true; continue; } - else if (!string.IsNullOrWhiteSpace(error)) + + // TODO: Figure out a place to put this test, or at least the prefix, somewhere not hard-coded + if (configuration.ResponseFileTokenReplacer is not null && + arg.StartsWith("@")) { - (errorList ??= new()).Add(error!); + var responseName = arg.Substring(1); + var (insertArgs, insertErrors) = configuration.ResponseFileTokenReplacer(responseName); + // TODO: Handle errors + if (insertArgs is not null && insertArgs.Any()) + { + var innerLocation = Location.CreateResponse(responseName, i, location); + var newErrors = MapTokens(insertArgs, innerLocation, 0, currentCommand, + validTokens, configuration, foundDoubleDash, tokens); + } continue; } - } - if (knownTokens.TryGetValue(arg, out var token)) - { - if (PreviousTokenIsAnOptionExpectingAnArgument(out var option)) + if (TryGetSymbolAndTokenType(validTokens,arg, out var symbol, out var tokenType)) { - tokenList.Add(OptionArgument(arg, option!)); + // This test and block is to handle the case `-x -x` where -x takes a string arg and "-x" is the value. Normal + // option argument parsing is handled as all other arguments, because it is not a found token. + if (PreviousTokenIsAnOptionExpectingAnArgument(out var option, tokens, previousOptionWasClosed)) + { + tokens.Add(OptionArgument(arg, option!, Location.FromOuterLocation(arg, i, location))); + continue; + } + else + { + currentCommand = AddToken(currentCommand, tokens, ref validTokens, arg, + Location.FromOuterLocation(arg, i, location), tokenType, symbol); + previousOptionWasClosed = false; + } } else { - switch (token.Type) + if (TrySplitIntoSubtokens(arg, out var first, out var rest) && + TryGetSymbolAndTokenType(validTokens, first, out var subSymbol, out var subTokenType) && + subTokenType == CliTokenType.Option) { - case CliTokenType.Option: - tokenList.Add(Option(arg, (CliOption)token.Symbol!)); - break; + CliOption option = (CliOption)subSymbol!; + tokens.Add(Option(first, option, Location.FromOuterLocation(first, i, location))); - case CliTokenType.Command: - CliCommand cmd = (CliCommand)token.Symbol!; - if (cmd != currentCommand) - { - if (cmd != configuration.RootCommand) - { - knownTokens = cmd.ValidTokens(); // config contains Directives, they are allowed only for RootCommand - } - currentCommand = cmd; - tokenList.Add(Command(arg, cmd)); - } - else - { - tokenList.Add(Argument(arg)); - } - - break; + if (rest is not null) + { + tokens.Add(Argument(rest, Location.FromOuterLocation(rest, i, location, first.Length + 1))); + } + } + else if (!configuration.EnablePosixBundling || + !CanBeUnbundled(arg, tokens) || + !TryUnbundle(arg.AsSpan(1), Location.FromOuterLocation(arg, i, location), validTokens, tokens)) + { + tokens.Add(Argument(arg, Location.FromOuterLocation(arg, i, location))); } } } - else if (arg.TrySplitIntoSubtokens(out var first, out var rest) && - knownTokens.TryGetValue(first, out var subtoken) && - subtoken.Type == CliTokenType.Option) - { - tokenList.Add(Option(first, (CliOption)subtoken.Symbol!)); - if (rest is not null) - { - tokenList.Add(Argument(rest)); - } - } - else if (!configuration.EnablePosixBundling || - !CanBeUnbundled(arg) || - !TryUnbundle(arg.AsSpan(1), i)) + return errors; + } + + static bool TryGetSymbolAndTokenType(Dictionary validTokens, + string arg, + [NotNullWhen(true)] out CliSymbol? symbol, + out CliTokenType tokenType) + { + if (validTokens.TryGetValue(arg, out var t)) { - tokenList.Add(Argument(arg)); + symbol = t.Symbol; + tokenType = t.TokenType; + return true; } - - CliToken Argument(string value) => new(value, CliTokenType.Argument, default, i); - - CliToken CommandArgument(string value, CliCommand command) => new(value, CliTokenType.Argument, command, i); - - CliToken OptionArgument(string value, CliOption option) => new(value, CliTokenType.Argument, option, i); - - CliToken Command(string value, CliCommand cmd) => new(value, CliTokenType.Command, cmd, i); - - CliToken Option(string value, CliOption option) => new(value, CliTokenType.Option, option, i); - - CliToken DoubleDash() => new("--", CliTokenType.DoubleDash, default, i); - - CliToken Directive(string value, CliDirective? directive) => new(value, CliTokenType.Directive, directive, i); + symbol = null; + tokenType = 0; + return false; } - tokens = tokenList; - errors = errorList; - - bool CanBeUnbundled(string arg) + static bool CanBeUnbundled(string arg, List tokenList) => arg.Length > 2 && arg[0] == '-' && arg[1] != '-'// don't check for "--" prefixed args && arg[2] != ':' && arg[2] != '=' // handled by TrySplitIntoSubtokens - && !PreviousTokenIsAnOptionExpectingAnArgument(out _); + && !PreviousTokenIsAnOptionExpectingAnArgument(out _, tokenList, false); - bool TryUnbundle(ReadOnlySpan alias, int argumentIndex) + static bool TryUnbundle(ReadOnlySpan alias, + Location outerLocation, + Dictionary validTokens, + List tokenList) { int tokensBefore = tokenList.Count; - + // TODO: Determine if these pointers are helping us enough for complexity. I do not see how it works, but changing it broke it. string candidate = new('-', 2); // mutable string used to avoid allocations unsafe { @@ -234,32 +227,40 @@ bool TryUnbundle(ReadOnlySpan alias, int argumentIndex) { if (alias[i] == ':' || alias[i] == '=') { - tokenList.Add(new CliToken(alias.Slice(i + 1).ToString(), CliTokenType.Argument, default, argumentIndex)); + string value = alias.Slice(i + 1).ToString(); + tokenList.Add(Argument(value, + Location.FromOuterLocation(value, outerLocation.Index, outerLocation, i + 1))); return true; } pCandidate[1] = alias[i]; - if (!knownTokens.TryGetValue(candidate, out CliToken? found)) + if (!validTokens.TryGetValue(candidate, out var found)) { if (tokensBefore != tokenList.Count && tokenList[tokenList.Count - 1].Type == CliTokenType.Option) { // Invalid_char_in_bundle_causes_rest_to_be_interpreted_as_value - tokenList.Add(new CliToken(alias.Slice(i).ToString(), CliTokenType.Argument, default, argumentIndex)); + string value = alias.Slice(i).ToString(); + tokenList.Add(Argument(value, + Location.FromOuterLocation(value, outerLocation.Index, outerLocation, i))); return true; } return false; } - tokenList.Add(new CliToken(found.Value, found.Type, found.Symbol, argumentIndex)); - if (i != alias.Length - 1 && ((CliOption)found.Symbol!).Greedy) + tokenList.Add(new CliToken(candidate, found.TokenType, found.Symbol, + Location.FromOuterLocation(candidate, outerLocation.Index, outerLocation, i + 1))); + + if (i != alias.Length - 1 && ((CliOption)found.Symbol).Greedy) { int index = i + 1; if (alias[index] == ':' || alias[index] == '=') { index++; // Last_bundled_option_can_accept_argument_with_colon_separator } - tokenList.Add(new CliToken(alias.Slice(index).ToString(), CliTokenType.Argument, default, argumentIndex)); + + string value = alias.Slice(index).ToString(); + tokenList.Add(Argument(value, Location.FromOuterLocation(value, outerLocation.Index, outerLocation, index))); return true; } } @@ -269,13 +270,13 @@ bool TryUnbundle(ReadOnlySpan alias, int argumentIndex) return true; } - bool PreviousTokenIsAnOptionExpectingAnArgument(out CliOption? option) + static bool PreviousTokenIsAnOptionExpectingAnArgument(out CliOption? option, List tokenList, bool previousOptionWasClosed) { if (tokenList.Count > 1) { var token = tokenList[tokenList.Count - 1]; - if (token.Type == CliTokenType.Option) + if (token.Type == CliTokenType.Option)// && !previousOptionWasClosed) { if (token.Symbol is CliOption { Greedy: true } opt) { @@ -288,13 +289,52 @@ bool PreviousTokenIsAnOptionExpectingAnArgument(out CliOption? option) option = null; return false; } + + static CliCommand AddToken(CliCommand currentCommand, + List tokenList, + ref Dictionary validTokens, + string arg, + Location location, + CliTokenType tokenType, + CliSymbol symbol) + { + //var location = Location.FromOuterLocation(outerLocation, argPosition, arg.Length); + switch (tokenType) + { + case CliTokenType.Option: + var option = (CliOption)symbol!; + tokenList.Add(Option(arg, option, location)); + break; + + case CliTokenType.Command: + // All arguments are initially classified as commands because they might be + CliCommand cmd = (CliCommand)symbol!; + if (cmd != currentCommand) + { + currentCommand = cmd; + // TODO: In the following determine how the cmd could be RootCommand AND the cmd not equal currentCmd. This looks like it would always be true.. If it is a massive side case, is it important not to double the ValidTokens call? + if (true) // cmd != rootCommand) + { + validTokens = GetValidTokens(cmd); // config contains Directives, they are allowed only for RootCommand + } + tokenList.Add(Command(arg, cmd, location)); + } + else + { + tokenList.Add(Argument(arg, location)); + } + + break; + } + return currentCommand; + } } - private static bool FirstArgumentIsRootCommand(IReadOnlyList args, CliCommand rootCommand, bool inferRootCommand) + private static bool FirstArgIsRootCommand(IReadOnlyList args, CliCommand rootCommand, bool inferRootCommand) { if (args.Count > 0) { - if (inferRootCommand && args[0] == CliRootCommand.ExecutablePath) + if (inferRootCommand && args[0] == CliExecutable.ExecutablePath) { return true; } @@ -317,13 +357,9 @@ private static bool FirstArgumentIsRootCommand(IReadOnlyList args, CliCo return false; } - private static string? GetReplaceableTokenValue(this string arg) => - arg.Length > 1 && arg[0] == '@' - ? arg.Substring(1) - : null; - - internal static bool TrySplitIntoSubtokens( - this string arg, + // TODO: Naming rules - sub-tokens has a dash and thus should be SubToken + private static bool TrySplitIntoSubtokens( + string arg, out string first, out string? rest) { @@ -346,83 +382,9 @@ internal static bool TrySplitIntoSubtokens( return false; } - internal static bool TryReadResponseFile( - string filePath, - out IReadOnlyList? newTokens, - out string? error) - { - try - { - newTokens = ExpandResponseFile(filePath).ToArray(); - error = null; - return true; - } - catch (FileNotFoundException) - { - error = LocalizationResources.ResponseFileNotFound(filePath); - } - catch (IOException e) - { - error = LocalizationResources.ErrorReadingResponseFile(filePath, e); - } - - newTokens = null; - return false; - - static IEnumerable ExpandResponseFile(string filePath) - { - var lines = File.ReadAllLines(filePath); - - for (var i = 0; i < lines.Length; i++) - { - var line = lines[i]; - - foreach (var p in SplitLine(line)) - { - if (p.GetReplaceableTokenValue() is { } path) - { - foreach (var q in ExpandResponseFile(path)) - { - yield return q; - } - } - else - { - yield return p; - } - } - } - } - - static IEnumerable SplitLine(string line) - { - var arg = line.Trim(); - - if (arg.Length == 0 || arg[0] == '#') - { - yield break; - } - - foreach (var word in CliParser.SplitCommandLine(arg)) - { - yield return word; - } - } - } - - private static Dictionary ValidTokens(this CliCommand command) + private static Dictionary GetValidTokens(CliCommand command) { - Dictionary tokens = new(StringComparer.Ordinal); - - if (command is CliRootCommand { Directives: IList directives }) - { - for (int i = 0; i < directives.Count; i++) - { - var directive = directives[i]; - var tokenString = $"[{directive.Name}]"; - tokens[tokenString] = new CliToken(tokenString, CliTokenType.Directive, directive, CliToken.ImplicitPosition); - } - } + Dictionary tokens = new(StringComparer.Ordinal); AddCommandTokens(tokens, command); @@ -438,61 +400,34 @@ private static Dictionary ValidTokens(this CliCommand command) if (command.HasOptions) { var options = command.Options; - + for (int i = 0; i < options.Count; i++) { AddOptionTokens(tokens, options[i]); } } - CliCommand? current = command; - while (current is not null) - { - CliCommand? parentCommand = null; - SymbolNode? parent = current.FirstParent; - while (parent is not null) - { - if ((parentCommand = parent.Symbol as CliCommand) is not null) - { - if (parentCommand.HasOptions) - { - for (var i = 0; i < parentCommand.Options.Count; i++) - { - CliOption option = parentCommand.Options[i]; - if (option.Recursive) - { - AddOptionTokens(tokens, option); - } - } - } - - break; - } - parent = parent.Next; - } - current = parentCommand; - } - + // TODO: Be sure recursive/global options are handled in the Initialize of Help (add to all) return tokens; - static void AddCommandTokens(Dictionary tokens, CliCommand cmd) + static void AddCommandTokens(Dictionary tokens, CliCommand cmd) { - tokens.Add(cmd.Name, new CliToken(cmd.Name, CliTokenType.Command, cmd, CliToken.ImplicitPosition)); + tokens.Add(cmd.Name, (cmd, CliTokenType.Command)); if (cmd._aliases is not null) { foreach (string childAlias in cmd._aliases) { - tokens.Add(childAlias, new CliToken(childAlias, CliTokenType.Command, cmd, CliToken.ImplicitPosition)); + tokens.Add(childAlias, (cmd, CliTokenType.Command)); } } } - static void AddOptionTokens(Dictionary tokens, CliOption option) + static void AddOptionTokens(Dictionary tokens, CliOption option) { if (!tokens.ContainsKey(option.Name)) { - tokens.Add(option.Name, new CliToken(option.Name, CliTokenType.Option, option, CliToken.ImplicitPosition)); + tokens.Add(option.Name, (option, CliTokenType.Option)); } if (option._aliases is not null) @@ -501,11 +436,36 @@ static void AddOptionTokens(Dictionary tokens, CliOption optio { if (!tokens.ContainsKey(childAlias)) { - tokens.Add(childAlias, new CliToken(childAlias, CliTokenType.Option, option, CliToken.ImplicitPosition)); + tokens.Add(childAlias, (option, CliTokenType.Option)); } } } } } + + private static CliToken GetToken(string? value, CliTokenType tokenType, CliSymbol? symbol, Location location) + => new(value, tokenType, symbol, location); + + private static CliToken Argument(string arg, Location location) + => GetToken(arg, CliTokenType.Argument, default, location); + + private static CliToken CommandArgument(string arg, CliCommand command, Location location) + => GetToken(arg, CliTokenType.Argument, command, location); + + private static CliToken OptionArgument(string arg, CliOption option, Location location) + => GetToken(arg, CliTokenType.Argument, option, location); + + private static CliToken Command(string arg, CliCommand cmd, Location location) + => GetToken(arg, CliTokenType.Command, cmd, location); + + private static CliToken Option(string arg, CliOption option, Location location) + => GetToken(arg, CliTokenType.Option, option, location); + + // TODO: Explore whether double dash should track its command + private static CliToken DoubleDash(int i, Location location) + => GetToken(doubleDash, CliTokenType.DoubleDash, default, location); + } + + } \ No newline at end of file diff --git a/src/System.CommandLine/Parsing/SymbolLookupByName.cs b/src/System.CommandLine/Parsing/SymbolLookupByName.cs new file mode 100644 index 0000000000..d55b83a039 --- /dev/null +++ b/src/System.CommandLine/Parsing/SymbolLookupByName.cs @@ -0,0 +1,190 @@ +// 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.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace System.CommandLine.Parsing; + +// Performance note: Special cases might result in the previous single dictionary being faster, but it did not +// give correct results for many CLIs, and also, it built a dictionary for the full CLI tree, rather than just the +// current commands and its ancestors, so in many cases, this will be faster. +// +// Most importantly, the previous approach fails for options, like the previous global options, that appear on multiple +// commands, since we are now explicitly putting them on all commands. + +/// +/// Provides a mechanism to lookup symbols by their name. This searches the symbols corresponding to the current command and its ancestors. +/// +public class SymbolLookupByName +{ + private readonly struct CommandCache(CliCommand command) + { + public CliCommand Command { get; } = command; + public Dictionary SymbolsByName { get; } = new(); + } + + private List cache; + + /// + /// Creates a new symbol lookup tied to a specific parseResult. + /// + /// + // TODO: If needed, consider a static list/dictionary of ParseResult to make general use easier. + public SymbolLookupByName(ParseResult parseResult) + => cache = BuildCache(parseResult); + + private List BuildCache(ParseResult parseResult) + { + if (cache is not null) + { + return cache; + } + cache = []; + var commandResult = parseResult.CommandResultInternal; + while (commandResult is not null) + { + var command = commandResult.Command; + if (TryGetCommandCache(command, out var _)) + { + throw new InvalidOperationException("Command hierarchy appears to be recursive."); + } + var commandCache = new CommandCache(command); + cache.Add(commandCache); + + AddSymbolsToCache(commandCache, command.Options, command); + AddSymbolsToCache(commandCache, command.Arguments, command); + AddSymbolsToCache(commandCache, command.Subcommands, command); + commandResult = (CliCommandResultInternal?)commandResult.Parent; + } + + return cache; + + static void AddSymbolsToCache(CommandCache commandCache, IEnumerable symbols, CliCommand command) + { + foreach (var symbol in symbols) + { + if (commandCache.SymbolsByName.ContainsKey(symbol.Name)) + { + throw new InvalidOperationException($"Command {command.Name} has more than one child named \"{symbol.Name}\"."); + } + commandCache.SymbolsByName.Add(symbol.Name, symbol); + } + } + } + + private bool TryGetCommandCache(CliCommand command, [NotNullWhen(true)] out CommandCache? commandCache) + { + var candidates = cache.Where(x => x.Command == command); + if (candidates.Any()) + { + commandCache = candidates.Single(); // multiples are a failure in construction + return true; + } + commandCache = null; + return false; + } + + private bool TryGetSymbolAndParentInternal(string name, + [NotNullWhen(true)] out CliSymbol? symbol, + [NotNullWhen(true)] out CliCommand? parent, + [NotNullWhen(false)] out string? errorMessage, + CliCommand? startCommand, + bool skipAncestors, + bool valuesOnly) + { + startCommand ??= cache.First().Command; // The construction of the dictionary makes this the parseResult.CliCommandResultInternal - current command + var commandCaches = GetCommandCachesToUse(startCommand); + if (commandCaches is null || !commandCaches.Any()) + { + errorMessage = $"Requested command {startCommand.Name} is not in the results."; + symbol = null; + parent = null; + return false; + } + + foreach (var commandCache in commandCaches) + { + if (commandCache.SymbolsByName.TryGetValue(name, out symbol)) + { + if (symbol is not null && (!valuesOnly || (symbol is CliValueSymbol))) + { + parent = commandCache.Command; + errorMessage = null; + return true; + } + } + + if (skipAncestors) + { + break; + } + } + + errorMessage = $"Requested symbol {name} was not found."; + symbol = null; + parent = null; + return false; + } + + /// + /// Gets the symbol with the requested name that appears nearest to the starting command, which defaults to the current or leaf command. + /// + /// The name to search for + /// An out parameter to receive the symbol, if found. + /// An out parameter to receive the parent, if found. + /// The command to start searching up from, which defaults to the current command. + /// If true, only the starting command and no ancestors are searched. + /// If true, commands are ignored and only options and arguments are found. + /// A tuple of the found symbol and its parent command. Throws if the name is not found. + /// Thrown if the name is not found. + // TODO: Add tests + public bool TryGetSymbolAndParent(string name, + [NotNullWhen(true)] out CliSymbol? symbol, + [NotNullWhen(true)] out CliCommand? parent, + CliCommand? startCommand = null, + bool skipAncestors = false, + bool valuesOnly = false) + { + if (TryGetSymbolAndParentInternal(name, out var storedSymbol, out var storedParent, out var errorMessage, startCommand, skipAncestors, valuesOnly)) + { + symbol = storedSymbol; + parent = storedParent; + return true; + } + if (errorMessage is not null) + { + throw new InvalidOperationException(errorMessage); + } + symbol = null; + parent = null; + return false; + } + + + /// + /// Returns true if the symbol is found, and provides the symbols as the `out` symbol parameter. + /// + /// The name to search for + /// An out parameter to receive the symbol if it is found. + /// The command to start searching up from, which defaults to the current command. + /// If true, only the starting command and no ancestors are searched. + /// If true, commands are ignored and only options and arguments are found. + /// True if a symbol with the requested name is found + public bool TryGetSymbol(string name, [NotNullWhen(true)] out CliSymbol? symbol, CliCommand? startCommand = null, bool skipAncestors = false, bool valuesOnly = false) + => TryGetSymbolAndParentInternal(name, out symbol, out var _, out var _, startCommand, skipAncestors, valuesOnly); + + + private IEnumerable? GetCommandCachesToUse(CliCommand currentCommand) + { + int index = FindIndex(cache, currentCommand); + return index == -1 + ? null + : cache.Skip(index); + + static int FindIndex(List cache, CliCommand? currentCommand) + => cache.FindIndex(c => c.Command == currentCommand); + } +} diff --git a/src/System.CommandLine/Parsing/SymbolResultExtensions.cs b/src/System.CommandLine/Parsing/SymbolResultExtensions.cs index c81459e8c6..ce69867f8c 100644 --- a/src/System.CommandLine/Parsing/SymbolResultExtensions.cs +++ b/src/System.CommandLine/Parsing/SymbolResultExtensions.cs @@ -5,9 +5,9 @@ namespace System.CommandLine.Parsing { - internal static class SymbolResultExtensions + internal static class SymbolResultInternalExtensions { - internal static IEnumerable AllSymbolResults(this CommandResult commandResult) + internal static IEnumerable AllSymbolResults(this CliCommandResultInternal commandResult) { yield return commandResult; diff --git a/src/System.CommandLine/Parsing/SymbolResultTree.cs b/src/System.CommandLine/Parsing/SymbolResultTree.cs index f0d0fb3d7c..571403bc27 100644 --- a/src/System.CommandLine/Parsing/SymbolResultTree.cs +++ b/src/System.CommandLine/Parsing/SymbolResultTree.cs @@ -2,18 +2,24 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Collections.Generic; +using System.Linq; namespace System.CommandLine.Parsing { - internal sealed class SymbolResultTree : Dictionary + internal sealed class SymbolResultTree : Dictionary { private readonly CliCommand _rootCommand; internal List? Errors; + // TODO: unmatched tokens + /* internal List? UnmatchedTokens; - private Dictionary? _symbolsByName; + */ + // TODO: Looks like this is a SymboNode/linked list because a symbol may appear multiple + // places in the tree and multiple symbols will have the same short name. The question is + // whether creating the multiple node instances is faster than just using lists. Could well be. internal SymbolResultTree( - CliCommand rootCommand, + CliCommand rootCommand, List? tokenizeErrors) { _rootCommand = rootCommand; @@ -31,23 +37,22 @@ internal SymbolResultTree( internal int ErrorCount => Errors?.Count ?? 0; - internal ArgumentResult? GetResult(CliArgument argument) - => TryGetValue(argument, out SymbolResult? result) ? (ArgumentResult)result : default; + internal CliArgumentResultInternal? GetResultInternal(CliArgument argument) + => TryGetValue(argument, out CliSymbolResultInternal? result) ? (CliArgumentResultInternal)result : default; - internal CommandResult? GetResult(CliCommand command) - => TryGetValue(command, out var result) ? (CommandResult)result : default; + internal CliCommandResultInternal? GetResultInternal(CliCommand command) + => TryGetValue(command, out var result) ? (CliCommandResultInternal)result : default; - internal OptionResult? GetResult(CliOption option) - => TryGetValue(option, out SymbolResult? result) ? (OptionResult)result : default; + internal CliOptionResultInternal? GetResultInternal(CliOption option) + => TryGetValue(option, out CliSymbolResultInternal? result) ? (CliOptionResultInternal)result : default; - internal DirectiveResult? GetResult(CliDirective directive) - => TryGetValue(directive, out SymbolResult? result) ? (DirectiveResult)result : default; - - internal IEnumerable GetChildren(SymbolResult parent) + // TODO: Determine how this is used. It appears to be O^n in the size of the tree and so if it is called multiple times, we should reconsider to avoid O^(N*M) + internal IEnumerable GetChildren(CliSymbolResultInternal parent) { - if (parent is not ArgumentResult) + // Argument can't have children + if (parent is not CliArgumentResultInternal) { - foreach (KeyValuePair pair in this) + foreach (KeyValuePair pair in this) { if (ReferenceEquals(parent, pair.Value.Parent)) { @@ -57,100 +62,47 @@ internal IEnumerable GetChildren(SymbolResult parent) } } - internal void AddError(ParseError parseError) => (Errors ??= new()).Add(parseError); - - internal void InsertFirstError(ParseError parseError) => (Errors ??= new()).Insert(0, parseError); - - internal void AddUnmatchedToken(CliToken token, CommandResult commandResult, CommandResult rootCommandResult) + internal IReadOnlyDictionary BuildValueResultDictionary() { - (UnmatchedTokens ??= new()).Add(token); - - if (commandResult.Command.TreatUnmatchedTokensAsErrors) + var dict = new Dictionary(); + foreach (KeyValuePair pair in this) { - if (commandResult != rootCommandResult && !rootCommandResult.Command.TreatUnmatchedTokensAsErrors) + var result = pair.Value; + if (result is CliOptionResultInternal optionResult) { - return; + dict.Add(pair.Key, optionResult.ValueResult); + continue; } - - AddError(new ParseError(LocalizationResources.UnrecognizedCommandOrArgument(token.Value), commandResult)); - } - } - - public SymbolResult? GetResult(string name) - { - if (_symbolsByName is null) - { - _symbolsByName = new(); - PopulateSymbolsByName(_rootCommand); - } - - if (!_symbolsByName.TryGetValue(name, out SymbolNode? node)) - { - throw new ArgumentException($"No symbol result found with name \"{name}\"."); - } - - while (node is not null) - { - if (TryGetValue(node.Symbol, out var result)) + if (result is CliArgumentResultInternal argumentResult) { - return result; + dict.Add(pair.Key, argumentResult.ValueResult); + continue; } - - node = node.Next; } - - return null; + return dict; } - private void PopulateSymbolsByName(CliCommand command) - { - if (command.HasArguments) - { - for (var i = 0; i < command.Arguments.Count; i++) - { - AddToSymbolsByName(command.Arguments[i]); - } - } + internal void AddError(ParseError parseError) => (Errors ??= new()).Add(parseError); + internal void InsertFirstError(ParseError parseError) => (Errors ??= new()).Insert(0, parseError); - if (command.HasOptions) - { - for (var i = 0; i < command.Options.Count; i++) - { - AddToSymbolsByName(command.Options[i]); - } - } + internal void AddUnmatchedToken(CliToken token, CliCommandResultInternal commandResult, CliCommandResultInternal rootCommandResult) + { + /* + // TODO: unmatched tokens + (UnmatchedTokens ??= new()).Add(token); - if (command.HasSubcommands) + if (commandResult.Command.TreatUnmatchedTokensAsErrors) { - for (var i = 0; i < command.Subcommands.Count; i++) + if (commandResult != rootCommandResult && !rootCommandResult.Command.TreatUnmatchedTokensAsErrors) { - var childCommand = command.Subcommands[i]; - AddToSymbolsByName(childCommand); - PopulateSymbolsByName(childCommand); + return; } - } - - void AddToSymbolsByName(CliSymbol symbol) - { - if (_symbolsByName!.TryGetValue(symbol.Name, out var node)) - { - if (symbol.Name == node.Symbol.Name && - symbol.FirstParent?.Symbol is { } parent && - parent == node.Symbol.FirstParent?.Symbol) - { - throw new InvalidOperationException($"Command {parent.Name} has more than one child named \"{symbol.Name}\"."); - } - _symbolsByName[symbol.Name] = new(symbol) - { - Next = node - }; - } - else - { - _symbolsByName[symbol.Name] = new(symbol); - } + */ + AddError(new ParseError(LocalizationResources.UnrecognizedCommandOrArgument(token.Value), commandResult)); + /* } + */ } } -} \ No newline at end of file +} diff --git a/src/System.CommandLine/Parsing/ValueResultOutcome.cs b/src/System.CommandLine/Parsing/ValueResultOutcome.cs new file mode 100644 index 0000000000..ccfc8d70b0 --- /dev/null +++ b/src/System.CommandLine/Parsing/ValueResultOutcome.cs @@ -0,0 +1,11 @@ +// 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.Parsing; + +public enum ValueResultOutcome +{ + NoArgument, // NoArgumentConversionResult + Success, // SuccessfulArgumentConversionResult + HasErrors, // FailedArgumentConversionResult, there are one or more errors +} diff --git a/src/System.CommandLine/System.CommandLine.csproj b/src/System.CommandLine/System.CommandLine.csproj index 17a23bebad..cfb211a276 100644 --- a/src/System.CommandLine/System.CommandLine.csproj +++ b/src/System.CommandLine/System.CommandLine.csproj @@ -1,4 +1,4 @@ - + true @@ -9,6 +9,7 @@ latest Support for parsing command lines, supporting both POSIX and Windows conventions and shell-agnostic command line completions. true + False @@ -22,7 +23,54 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/System.Diagnostics.CodeAnalysis.cs b/src/System.Diagnostics.CodeAnalysis.cs deleted file mode 100644 index 8a5b242094..0000000000 --- a/src/System.Diagnostics.CodeAnalysis.cs +++ /dev/null @@ -1,74 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -#if !NET6_0_OR_GREATER - -#pragma warning disable CA1801, CA1822 - -namespace System.Diagnostics.CodeAnalysis -{ - [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)] - internal sealed class AllowNullAttribute : Attribute - { - } - - [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)] - internal sealed class DisallowNullAttribute : Attribute - { - } - - [AttributeUsage(AttributeTargets.Method, Inherited = false)] - internal sealed class DoesNotReturnAttribute : Attribute - { - } - - [AttributeUsage(AttributeTargets.Parameter)] - internal sealed class DoesNotReturnIfAttribute : Attribute - { - public DoesNotReturnIfAttribute(bool parameterValue) { } - - public bool ParameterValue { get { throw null!; } } - } - - [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Constructor | AttributeTargets.Event | AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Struct, Inherited = false, AllowMultiple = false)] - internal sealed class ExcludeFromCodeCoverageAttribute : Attribute - { - } - - [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)] - internal sealed class MaybeNullAttribute : Attribute - { - } - - [AttributeUsage(AttributeTargets.Parameter)] - internal sealed class MaybeNullWhenAttribute : Attribute - { - public MaybeNullWhenAttribute(bool returnValue) { } - - public bool ReturnValue { get { throw null!; } } - } - - [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)] - internal sealed class NotNullAttribute : Attribute - { - } - - [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, AllowMultiple = true, Inherited = false)] - internal sealed class NotNullIfNotNullAttribute : Attribute - { - public NotNullIfNotNullAttribute(string parameterName) { } - - public string ParameterName { get { throw null!; } } - } - - [AttributeUsage(AttributeTargets.Parameter)] - internal sealed class NotNullWhenAttribute : Attribute - { - public NotNullWhenAttribute(bool returnValue) { } - - public bool ReturnValue { get { throw null!; } } - } -} - -#endif \ No newline at end of file pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy