diff --git a/src/System.CommandLine.Subsystems.Tests/AlternateSubsystems.cs b/src/System.CommandLine.Subsystems.Tests/AlternateSubsystems.cs index 9ae61750ac..b0dc5b5dbd 100644 --- a/src/System.CommandLine.Subsystems.Tests/AlternateSubsystems.cs +++ b/src/System.CommandLine.Subsystems.Tests/AlternateSubsystems.cs @@ -1,7 +1,9 @@ // 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; +using System.CommandLine.Subsystems.Annotations; namespace System.CommandLine.Subsystems.Tests { @@ -45,11 +47,11 @@ internal class VersionWithInitializeAndTeardown : VersionSubsystem internal bool ExecutionWasRun; internal bool TeardownWasRun; - protected override CliConfiguration Initialize(CliConfiguration configuration) + protected override CliConfiguration Initialize(InitializationContext context) { // marker hack needed because ConsoleHack not available in initialization InitializationWasRun = true; - return base.Initialize(configuration); + return base.Initialize(context); } protected override CliExit Execute(PipelineContext pipelineContext) @@ -65,5 +67,13 @@ protected override CliExit TearDown(CliExit cliExit) } } + internal class StringDirectiveSubsystem(IAnnotationProvider? annotationProvider = null) + : DirectiveSubsystem("other",SubsystemKind.Other, annotationProvider) + { } + + internal class BooleanDirectiveSubsystem(IAnnotationProvider? annotationProvider = null) + : DirectiveSubsystem("diagram", SubsystemKind.Other, annotationProvider) + { } + } } 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.Subsystems.Tests/PipelineTests.cs b/src/System.CommandLine.Subsystems.Tests/PipelineTests.cs index 297590108e..198376544e 100644 --- a/src/System.CommandLine.Subsystems.Tests/PipelineTests.cs +++ b/src/System.CommandLine.Subsystems.Tests/PipelineTests.cs @@ -3,87 +3,78 @@ using FluentAssertions; using System.CommandLine.Parsing; -using System.Reflection; using Xunit; namespace System.CommandLine.Subsystems.Tests { public class PipelineTests { - - private static readonly string? version = (Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly()) - ?.GetCustomAttribute() - ?.InformationalVersion; - + private static Pipeline GetTestPipeline(VersionSubsystem versionSubsystem) + => new() + { + Version = versionSubsystem + }; + 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] - [InlineData("-v", true)] - [InlineData("--version", true)] - [InlineData("-x", false)] - [InlineData("", false)] - [InlineData(null, false)] + [ClassData(typeof(TestData.Version))] public void Subsystem_runs_in_pipeline_only_when_requested(string input, bool shouldRun) { - var configuration = new CliConfiguration(new CliRootCommand { }); - var pipeline = new Pipeline - { - Version = new VersionSubsystem() - }; - var consoleHack = new ConsoleHack().RedirectToBuffer(true); + var pipeline = GetTestPipeline(new VersionSubsystem()); + var console = GetNewTestConsole(); - var exit = pipeline.Execute(configuration, input, consoleHack); + var exit = pipeline.Execute(GetNewTestConfiguration(), input, console); exit.ExitCode.Should().Be(0); exit.Handled.Should().Be(shouldRun); if (shouldRun) { - consoleHack.GetBuffer().Trim().Should().Be(version); + console.GetBuffer().Trim().Should().Be(TestData.AssemblyVersionString); } } [Theory] - [InlineData("-v", true)] - [InlineData("--version", true)] - [InlineData("-x", false)] - [InlineData("", false)] - [InlineData(null, false)] + [ClassData(typeof(TestData.Version))] public void Subsystem_runs_with_explicit_parse_only_when_requested(string input, bool shouldRun) { - var configuration = new CliConfiguration(new CliRootCommand { }); - var pipeline = new Pipeline - { - Version = new VersionSubsystem() - }; - var consoleHack = new ConsoleHack().RedirectToBuffer(true); + var pipeline = GetTestPipeline(new VersionSubsystem()); + var console = GetNewTestConsole(); - var result = pipeline.Parse(configuration, input); - var exit = pipeline.Execute(result, input, consoleHack); + var result = pipeline.Parse(GetNewTestConfiguration(), input); + var exit = pipeline.Execute(result, input, console); exit.ExitCode.Should().Be(0); exit.Handled.Should().Be(shouldRun); if (shouldRun) { - consoleHack.GetBuffer().Trim().Should().Be(version); + console.GetBuffer().Trim().Should().Be(TestData.AssemblyVersionString); } } [Theory] - [InlineData("-v", true)] - [InlineData("--version", true)] - [InlineData("-x", false)] - [InlineData("", false)] - [InlineData(null, false)] + [ClassData(typeof(TestData.Version))] public void Subsystem_runs_initialize_and_teardown_when_requested(string input, bool shouldRun) { - var configuration = new CliConfiguration(new CliRootCommand { }); - AlternateSubsystems.VersionWithInitializeAndTeardown versionSubsystem = new AlternateSubsystems.VersionWithInitializeAndTeardown(); - var pipeline = new Pipeline - { - Version = versionSubsystem - }; - var consoleHack = new ConsoleHack().RedirectToBuffer(true); + var versionSubsystem = new AlternateSubsystems.VersionWithInitializeAndTeardown(); + var pipeline = GetTestPipeline(versionSubsystem); + var console = GetNewTestConsole(); - var exit = pipeline.Execute(configuration, input, consoleHack); + var exit = pipeline.Execute(GetNewTestConfiguration(), input, console); exit.ExitCode.Should().Be(0); exit.Handled.Should().Be(shouldRun); @@ -94,56 +85,72 @@ public void Subsystem_runs_initialize_and_teardown_when_requested(string input, [Theory] - [InlineData("-v", true)] - [InlineData("--version", true)] - [InlineData("-x", false)] - [InlineData("", false)] - [InlineData(null, false)] - public void Subsystem_can_be_used_without_runner(string input, bool shouldRun) + [ClassData(typeof(TestData.Version))] + public void Subsystem_works_without_pipeline(string input, bool shouldRun) { - var configuration = new CliConfiguration(new CliRootCommand { }); var versionSubsystem = new VersionSubsystem(); - var consoleHack = new ConsoleHack().RedirectToBuffer(true); - - Subsystem.Initialize(versionSubsystem, configuration); - // TODO: I do not know why anyone would do this, but I do not see a reason to work to block it. See style2 below - var parseResult = CliParser.Parse(configuration.RootCommand, input, configuration); + // 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) + if (shouldRun) { // TODO: Add an execute overload to avoid checking activated twice - var exit = Subsystem.Execute(versionSubsystem, parseResult, input, consoleHack); + var exit = Subsystem.Execute(versionSubsystem, parseResult, input, console); exit.Should().NotBeNull(); exit.ExitCode.Should().Be(0); exit.Handled.Should().BeTrue(); - consoleHack.GetBuffer().Trim().Should().Be(version); + console.GetBuffer().Trim().Should().Be(TestData.AssemblyVersionString); } } [Theory] - [InlineData("-v", true)] - [InlineData("--version", true)] - [InlineData("-x", false)] - [InlineData("", false)] - [InlineData(null, false)] - public void Subsystem_can_be_used_without_runner_style2(string input, bool shouldRun) + [ClassData(typeof(TestData.Version))] + public void Subsystem_works_without_pipeline_style2(string input, bool shouldRun) { - var configuration = new CliConfiguration(new CliRootCommand { }); var versionSubsystem = new VersionSubsystem(); - var consoleHack = new ConsoleHack().RedirectToBuffer(true); + var args = CliParser.SplitCommandLine(input).ToList().AsReadOnly(); + var console = GetNewTestConsole(); + var configuration = GetNewTestConfiguration(); var expectedVersion = shouldRun - ? version + ? TestData.AssemblyVersionString : ""; - Subsystem.Initialize(versionSubsystem, configuration); - var parseResult = CliParser.Parse(configuration.RootCommand, input, configuration); - var exit = Subsystem.ExecuteIfNeeded(versionSubsystem, parseResult, input, consoleHack); + // 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.Handled.Should().Be(shouldRun); - consoleHack.GetBuffer().Trim().Should().Be(expectedVersion); + 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] @@ -171,9 +178,8 @@ public void Normal_pipeline_contains_no_subsystems() 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 consoleHack = new ConsoleHack().RedirectToBuffer(true); var symbol = new CliOption("-x"); - + var console = GetNewTestConsole(); var pipeline = new StandardPipeline { Version = new AlternateSubsystems.VersionThatUsesHelpData(symbol) @@ -183,9 +189,10 @@ public void Subsystems_can_access_each_others_data() { symbol.With(pipeline.Help.Description, "Testing") }; - pipeline.Execute(new CliConfiguration(rootCommand), "-v", consoleHack); - consoleHack.GetBuffer().Trim().Should().Be($"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..363a576e71 --- /dev/null +++ b/src/System.CommandLine.Subsystems.Tests/ResponseSubsystemTests.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. + +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(); + 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 index f93b8e94b9..7af1fa8222 100644 --- a/src/System.CommandLine.Subsystems.Tests/System.CommandLine.Subsystems.Tests.csproj +++ b/src/System.CommandLine.Subsystems.Tests/System.CommandLine.Subsystems.Tests.csproj @@ -16,6 +16,7 @@ + @@ -31,7 +32,11 @@ --> + + + + diff --git a/src/System.CommandLine.Subsystems.Tests/TestData.cs b/src/System.CommandLine.Subsystems.Tests/TestData.cs new file mode 100644 index 0000000000..0460bb1d33 --- /dev/null +++ b/src/System.CommandLine.Subsystems.Tests/TestData.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. + +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(); + } +} diff --git a/src/System.CommandLine.Subsystems.Tests/VersionSubsystemTests.cs b/src/System.CommandLine.Subsystems.Tests/VersionSubsystemTests.cs index 6cecb7beb9..304c12b6da 100644 --- a/src/System.CommandLine.Subsystems.Tests/VersionSubsystemTests.cs +++ b/src/System.CommandLine.Subsystems.Tests/VersionSubsystemTests.cs @@ -1,23 +1,21 @@ // 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 VersionSubsystemTests { [Fact] public void When_version_subsystem_is_used_the_version_option_is_added_to_the_root() { var rootCommand = new CliRootCommand - { - new CliOption("-x") - }; + { + new CliOption("-x") // add option that is expected for the test data used here + }; var configuration = new CliConfiguration(rootCommand); var pipeline = new Pipeline { @@ -32,20 +30,18 @@ public void When_version_subsystem_is_used_the_version_option_is_added_to_the_ro .Count(x => x.Name == "--version") .Should() .Be(1); - } [Theory] - [InlineData("--version", true)] - [InlineData("-v", true)] - [InlineData("-x", false)] - [InlineData("", false)] + [ClassData(typeof(TestData.Version))] public void Version_is_activated_only_when_requested(string input, bool result) { - CliRootCommand rootCommand = new(); + CliRootCommand rootCommand = [new CliOption("-x")]; // add random option as empty CLIs are rare var configuration = new CliConfiguration(rootCommand); var versionSubsystem = new VersionSubsystem(); - Subsystem.Initialize(versionSubsystem, configuration); + 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); diff --git a/src/System.CommandLine.Subsystems/CompletionSubsystem.cs b/src/System.CommandLine.Subsystems/CompletionSubsystem.cs index bc024ca886..adae591c3a 100644 --- a/src/System.CommandLine.Subsystems/CompletionSubsystem.cs +++ b/src/System.CommandLine.Subsystems/CompletionSubsystem.cs @@ -9,7 +9,7 @@ namespace System.CommandLine; public class CompletionSubsystem : CliSubsystem { public CompletionSubsystem(IAnnotationProvider? annotationProvider = null) - : base(CompletionAnnotations.Prefix, annotationProvider, SubsystemKind.Completion) + : base(CompletionAnnotations.Prefix, SubsystemKind.Completion, annotationProvider) { } // TODO: Figure out trigger for completions diff --git a/src/System.CommandLine.Subsystems/Directives/DiagramSubsystem.cs b/src/System.CommandLine.Subsystems/Directives/DiagramSubsystem.cs new file mode 100644 index 0000000000..e9051cc6c1 --- /dev/null +++ b/src/System.CommandLine.Subsystems/Directives/DiagramSubsystem.cs @@ -0,0 +1,178 @@ +// 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; +using System.CommandLine.Parsing; + +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); + + protected internal override CliExit Execute(PipelineContext pipelineContext) + { + // Gather locations + //var locations = pipelineContext.ParseResult.LocationMap + // .Concat(Map(pipelineContext.ParseResult.Configuration.PreProcessedLocations)); + + pipelineContext.ConsoleHack.WriteLine("Output diagram"); + return CliExit.SuccessfullyHandled(pipelineContext.ParseResult); + } + + + // 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); + + + 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, + SymbolResult symbolResult, + ParseResult parseResult) + { + if (parseResult.Errors.Any(e => e.SymbolResult == symbolResult)) + { + builder.Append('!'); + } + +/* + switch (symbolResult) + { + // TODO: Directives + 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(((CommandResult)symbolResult).IdentifierToken.Value); + } + + foreach (SymbolResult 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..6b4b7d9670 --- /dev/null +++ b/src/System.CommandLine.Subsystems/Directives/DirectiveSubsystem.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.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 CliConfiguration 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; + } + } + + return context.Configuration; + } + + 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..ed43c8d626 --- /dev/null +++ b/src/System.CommandLine.Subsystems/Directives/ResponseSubsystem.cs @@ -0,0 +1,103 @@ +// 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) +{ + protected internal override CliConfiguration Initialize(InitializationContext context) + { + context.Configuration.ResponseFileTokenReplacer = Replacer; + return context.Configuration; + } + + public static (List? tokens, List? errors) Replacer(string responseSourceName) + { + 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 index 9ded0c298e..bb14fddb1d 100644 --- a/src/System.CommandLine.Subsystems/ErrorReportingSubsystem.cs +++ b/src/System.CommandLine.Subsystems/ErrorReportingSubsystem.cs @@ -9,7 +9,7 @@ namespace System.CommandLine; public class ErrorReportingSubsystem : CliSubsystem { public ErrorReportingSubsystem(IAnnotationProvider? annotationProvider = null) - : base(ErrorReportingAnnotations.Prefix, annotationProvider, SubsystemKind.ErrorReporting) + : base(ErrorReportingAnnotations.Prefix, SubsystemKind.ErrorReporting, annotationProvider) { } // TODO: Stash option rather than using string diff --git a/src/System.CommandLine.Subsystems/HelpSubsystem.cs b/src/System.CommandLine.Subsystems/HelpSubsystem.cs index 9fc8c15997..f332e9a0ae 100644 --- a/src/System.CommandLine.Subsystems/HelpSubsystem.cs +++ b/src/System.CommandLine.Subsystems/HelpSubsystem.cs @@ -16,7 +16,7 @@ namespace System.CommandLine; // .With(help.Description, "Greet the user"); // public class HelpSubsystem(IAnnotationProvider? annotationProvider = null) - : CliSubsystem(HelpAnnotations.Prefix, annotationProvider: annotationProvider, SubsystemKind.Help) + : CliSubsystem(HelpAnnotations.Prefix, SubsystemKind.Help, annotationProvider) { public void SetDescription(CliSymbol symbol, string description) => SetAnnotation(symbol, HelpAnnotations.Description, description); @@ -29,15 +29,16 @@ public string GetDescription(CliSymbol symbol) public AnnotationAccessor Description => new(this, HelpAnnotations.Description); - protected internal override CliConfiguration Initialize(CliConfiguration configuration) + protected internal override CliConfiguration Initialize(InitializationContext context) { var option = new CliOption("--help", ["-h"]) { + // TODO: Why don't we accept bool like any other bool option? Arity = ArgumentArity.Zero }; - configuration.RootCommand.Add(option); + context.Configuration.RootCommand.Add(option); - return configuration; + return context.Configuration; } protected internal override bool GetIsActivated(ParseResult? parseResult) diff --git a/src/System.CommandLine.Subsystems/Pipeline.cs b/src/System.CommandLine.Subsystems/Pipeline.cs index b8250cdb5a..ec7aea776a 100644 --- a/src/System.CommandLine.Subsystems/Pipeline.cs +++ b/src/System.CommandLine.Subsystems/Pipeline.cs @@ -1,6 +1,7 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. +// 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; @@ -10,15 +11,16 @@ public class Pipeline { public HelpSubsystem? Help { get; set; } public VersionSubsystem? Version { get; set; } - public ErrorReportingSubsystem? ErrorReporting { get; set; } public CompletionSubsystem? Completion { get; set; } + public DiagramSubsystem? Diagram { get; set; } + public ErrorReportingSubsystem? ErrorReporting { get; set; } public ParseResult Parse(CliConfiguration configuration, string rawInput) => Parse(configuration, CliParser.SplitCommandLine(rawInput).ToArray()); - public ParseResult Parse(CliConfiguration configuration, string[] args) + public ParseResult Parse(CliConfiguration configuration, IReadOnlyList args) { - InitializeSubsystems(configuration); + InitializeSubsystems(new InitializationContext(configuration, args)); var parseResult = CliParser.Parse(configuration.RootCommand, args, configuration); return parseResult; } @@ -39,17 +41,20 @@ public CliExit Execute(ParseResult parseResult, string rawInput, ConsoleHack? co return new CliExit(pipelineContext); } - protected virtual void InitializeHelp(CliConfiguration configuration) - => Help?.Initialize(configuration); + protected virtual void InitializeHelp(InitializationContext context) + => Help?.Initialize(context); + + protected virtual void InitializeVersion(InitializationContext context) + => Version?.Initialize(context); - protected virtual void InitializeVersion(CliConfiguration configuration) - => Version?.Initialize(configuration); + protected virtual void InitializeCompletion(InitializationContext context) + => Completion?.Initialize(context); - protected virtual void InitializeErrorReporting(CliConfiguration configuration) - => ErrorReporting?.Initialize(configuration); + protected virtual void InitializeDiagram(InitializationContext context) + => Diagram?.Initialize(context); - protected virtual void InitializeCompletion(CliConfiguration configuration) - => Completion?.Initialize(configuration); + protected virtual void InitializeErrorReporting(InitializationContext context) + => ErrorReporting?.Initialize(context); protected virtual CliExit TearDownHelp(CliExit cliExit) => Help is null @@ -61,28 +66,36 @@ protected virtual CliExit TearDownHelp(CliExit cliExit) ? cliExit : Version.TearDown(cliExit); + protected virtual CliExit TearDownCompletion(CliExit cliExit) + => Completion is null + ? cliExit + : Completion.TearDown(cliExit); + + protected virtual CliExit TearDownDiagram(CliExit cliExit) + => Diagram is null + ? cliExit + : Diagram.TearDown(cliExit); + protected virtual CliExit TearDownErrorReporting(CliExit cliExit) => ErrorReporting is null ? cliExit : ErrorReporting.TearDown(cliExit); - protected virtual CliExit TearDownCompletions(CliExit cliExit) - => Completion is null - ? cliExit - : Completion.TearDown(cliExit); - protected virtual void ExecuteHelp(PipelineContext context) => ExecuteIfNeeded(Help, context); protected virtual void ExecuteVersion(PipelineContext context) => ExecuteIfNeeded(Version, context); + protected virtual void ExecuteCompletion(PipelineContext context) + => ExecuteIfNeeded(Completion, context); + + protected virtual void ExecuteDiagram(PipelineContext context) + => ExecuteIfNeeded(Diagram, context); + protected virtual void ExecuteErrorReporting(PipelineContext context) => ExecuteIfNeeded(ErrorReporting, context); - protected virtual void ExecuteCompletions(PipelineContext context) - => ExecuteIfNeeded(Completion, context); - // 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 /// @@ -94,12 +107,13 @@ protected virtual void ExecuteCompletions(PipelineContext context) /// /// Note to inheritors: The ordering of initializing should normally be in the reverse order than tear down /// - protected virtual void InitializeSubsystems(CliConfiguration configuration) + protected virtual void InitializeSubsystems(InitializationContext context) { - InitializeHelp(configuration); - InitializeVersion(configuration); - InitializeErrorReporting(configuration); - InitializeCompletion(configuration); + InitializeHelp(context); + InitializeVersion(context); + InitializeCompletion(context); + InitializeDiagram(context); + InitializeErrorReporting(context); } // TODO: Consider whether this should be public @@ -113,8 +127,9 @@ protected virtual void InitializeSubsystems(CliConfiguration configuration) /// protected virtual CliExit TearDownSubsystems(CliExit cliExit) { - TearDownCompletions(cliExit); TearDownErrorReporting(cliExit); + TearDownDiagram(cliExit); + TearDownCompletion(cliExit); TearDownVersion(cliExit); TearDownHelp(cliExit); return cliExit; @@ -124,8 +139,9 @@ protected virtual void ExecuteSubsystems(PipelineContext pipelineContext) { ExecuteHelp(pipelineContext); ExecuteVersion(pipelineContext); + ExecuteCompletion(pipelineContext); + ExecuteDiagram(pipelineContext); ExecuteErrorReporting(pipelineContext); - ExecuteCompletions(pipelineContext); } protected static void ExecuteIfNeeded(CliSubsystem? subsystem, PipelineContext pipelineContext) diff --git a/src/System.CommandLine.Subsystems/StandardPipeline.cs b/src/System.CommandLine.Subsystems/StandardPipeline.cs index d1ab7a65cd..6e5792ebfe 100644 --- a/src/System.CommandLine.Subsystems/StandardPipeline.cs +++ b/src/System.CommandLine.Subsystems/StandardPipeline.cs @@ -1,14 +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. +using System.CommandLine.Directives; + namespace System.CommandLine; public class StandardPipeline : Pipeline -{ +{ public StandardPipeline() { Help = new HelpSubsystem(); Version = new VersionSubsystem(); - ErrorReporting = new ErrorReportingSubsystem(); Completion = new CompletionSubsystem(); + Diagram = new DiagramSubsystem(); + ErrorReporting = new ErrorReportingSubsystem(); } } 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/CliSubsystem.cs b/src/System.CommandLine.Subsystems/Subsystems/CliSubsystem.cs index 794eff661d..ec1ede69a4 100644 --- a/src/System.CommandLine.Subsystems/Subsystems/CliSubsystem.cs +++ b/src/System.CommandLine.Subsystems/Subsystems/CliSubsystem.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.Subsystems.Annotations; using System.Diagnostics.CodeAnalysis; namespace System.CommandLine.Subsystems; @@ -12,7 +11,7 @@ namespace System.CommandLine.Subsystems; /// public abstract class CliSubsystem { - protected CliSubsystem(string name, IAnnotationProvider? annotationProvider, SubsystemKind subsystemKind) + protected CliSubsystem(string name, SubsystemKind subsystemKind, IAnnotationProvider? annotationProvider) { Name = name; _annotationProvider = annotationProvider; @@ -115,7 +114,8 @@ internal PipelineContext ExecuteIfNeeded(ParseResult? parseResult, PipelineConte /// 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 - protected internal virtual CliConfiguration Initialize(CliConfiguration configuration) => configuration; + protected internal virtual CliConfiguration Initialize(InitializationContext context) + => context.Configuration; // TODO: Determine if this is needed. protected internal virtual CliExit TearDown(CliExit cliExit) 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/Subsystem.cs b/src/System.CommandLine.Subsystems/Subsystems/Subsystem.cs index a61ab23ef2..f67f5ecdc4 100644 --- a/src/System.CommandLine.Subsystems/Subsystems/Subsystem.cs +++ b/src/System.CommandLine.Subsystems/Subsystems/Subsystem.cs @@ -5,8 +5,8 @@ namespace System.CommandLine.Subsystems; public class Subsystem { - public static void Initialize(CliSubsystem subsystem, CliConfiguration configuration) - => subsystem.Initialize(configuration); + public static void Initialize(CliSubsystem subsystem, CliConfiguration configuration, IReadOnlyList args) + => subsystem.Initialize(new InitializationContext(configuration, args)); public static CliExit Execute(CliSubsystem subsystem, PipelineContext pipelineContext) => subsystem.Execute(pipelineContext); @@ -14,15 +14,15 @@ public static CliExit Execute(CliSubsystem subsystem, PipelineContext pipelineCo public static bool GetIsActivated(CliSubsystem subsystem, ParseResult parseResult) => subsystem.GetIsActivated(parseResult); - public static CliExit ExecuteIfNeeded(CliSubsystem subsystem, ParseResult parseResult, string rawInput, ConsoleHack? consoleHack = null) - => new(subsystem.ExecuteIfNeeded(new PipelineContext(parseResult, rawInput, null,consoleHack))); + public static CliExit ExecuteIfNeeded(CliSubsystem subsystem, ParseResult parseResult, string rawInput, ConsoleHack? consoleHack = null) + => new(subsystem.ExecuteIfNeeded(new PipelineContext(parseResult, rawInput, null, consoleHack))); public static CliExit Execute(CliSubsystem subsystem, ParseResult parseResult, string rawInput, ConsoleHack? consoleHack = null) => subsystem.Execute(new PipelineContext(parseResult, rawInput, null, consoleHack)); - internal static PipelineContext ExecuteIfNeeded(CliSubsystem subsystem, ParseResult parseResult, string rawInput, ConsoleHack? consoleHack, PipelineContext? pipelineContext = null) - => subsystem.ExecuteIfNeeded(pipelineContext ?? new PipelineContext(parseResult, rawInput, null,consoleHack)); + internal static PipelineContext ExecuteIfNeeded(CliSubsystem subsystem, ParseResult parseResult, string rawInput, ConsoleHack? consoleHack, PipelineContext? pipelineContext = null) + => subsystem.ExecuteIfNeeded(pipelineContext ?? new PipelineContext(parseResult, rawInput, null, consoleHack)); internal static PipelineContext ExecuteIfNeeded(CliSubsystem subsystem, PipelineContext pipelineContext) => subsystem.ExecuteIfNeeded(pipelineContext); diff --git a/src/System.CommandLine.Subsystems/Subsystems/SubsystemKind.cs b/src/System.CommandLine.Subsystems/Subsystems/SubsystemKind.cs index 8e6b80f3b2..5ad2bfbb66 100644 --- a/src/System.CommandLine.Subsystems/Subsystems/SubsystemKind.cs +++ b/src/System.CommandLine.Subsystems/Subsystems/SubsystemKind.cs @@ -10,4 +10,6 @@ public enum SubsystemKind Version, ErrorReporting, Completion, + Diagram, + Response, } diff --git a/src/System.CommandLine.Subsystems/VersionSubsystem.cs b/src/System.CommandLine.Subsystems/VersionSubsystem.cs index 0740e3bc6e..ee763fa55a 100644 --- a/src/System.CommandLine.Subsystems/VersionSubsystem.cs +++ b/src/System.CommandLine.Subsystems/VersionSubsystem.cs @@ -12,7 +12,7 @@ public class VersionSubsystem : CliSubsystem private string? specificVersion = null; public VersionSubsystem(IAnnotationProvider? annotationProvider = null) - : base(VersionAnnotations.Prefix, annotationProvider, SubsystemKind.Version) + : base(VersionAnnotations.Prefix, SubsystemKind.Version, annotationProvider) { } @@ -34,16 +34,15 @@ public string? SpecificVersion ?.GetCustomAttribute() ?.InformationalVersion; - - protected internal override CliConfiguration Initialize(CliConfiguration configuration) + protected internal override CliConfiguration Initialize(InitializationContext context) { var option = new CliOption("--version", ["-v"]) { Arity = ArgumentArity.Zero }; - configuration.RootCommand.Add(option); + context.Configuration.RootCommand.Add(option); - return configuration; + return context.Configuration; } // TODO: Stash option rather than using string 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/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/ParserTests.cs b/src/System.CommandLine.Tests/ParserTests.cs index 8d37106b40..3da60b50a6 100644 --- a/src/System.CommandLine.Tests/ParserTests.cs +++ b/src/System.CommandLine.Tests/ParserTests.cs @@ -16,12 +16,21 @@ 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() { @@ -1447,10 +1456,10 @@ public void Command_argument_arity_can_be_a_fixed_value_greater_than_1() .Tokens .Should() .BeEquivalentTo( - new CliToken("1", CliTokenType.Argument, argument), - new CliToken("2", CliTokenType.Argument, argument), - new CliToken("3", CliTokenType.Argument, argument)); - } + new CliToken("1", CliTokenType.Argument, argument,dummyLocation), + new CliToken("2", CliTokenType.Argument, argument, dummyLocation), + new CliToken("3", CliTokenType.Argument, argument, dummyLocation)); + } [Fact] public void Command_argument_arity_can_be_a_range_with_a_lower_bound_greater_than_1() @@ -1469,19 +1478,19 @@ public void Command_argument_arity_can_be_a_range_with_a_lower_bound_greater_tha .Tokens .Should() .BeEquivalentTo( - new CliToken("1", CliTokenType.Argument, argument), - new CliToken("2", CliTokenType.Argument, argument), - new CliToken("3", CliTokenType.Argument, argument)); + 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") .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)); + 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)); } // TODO: Validation? @@ -1541,9 +1550,9 @@ public void Option_argument_arity_can_be_a_fixed_value_greater_than_1() .Tokens .Should() .BeEquivalentTo( - new CliToken("1", CliTokenType.Argument, default), - new CliToken("2", CliTokenType.Argument, default), - new CliToken("3", CliTokenType.Argument, default)); + new CliToken("1", CliTokenType.Argument, default, dummyLocation), + new CliToken("2", CliTokenType.Argument, default, dummyLocation), + new CliToken("3", CliTokenType.Argument, default, dummyLocation)); } [Fact] @@ -1561,19 +1570,19 @@ public void Option_argument_arity_can_be_a_range_with_a_lower_bound_greater_than .Tokens .Should() .BeEquivalentTo( - new CliToken("1", CliTokenType.Argument, default), - new CliToken("2", CliTokenType.Argument, default), - new CliToken("3", CliTokenType.Argument, default)); + 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), - new CliToken("2", CliTokenType.Argument, default), - new CliToken("3", CliTokenType.Argument, default), - new CliToken("4", CliTokenType.Argument, default), - new CliToken("5", CliTokenType.Argument, default)); + 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)); } // TODO: Validation? diff --git a/src/System.CommandLine.Tests/TokenizerTests.cs b/src/System.CommandLine.Tests/TokenizerTests.cs index 8f162059a6..1b810e5e43 100644 --- a/src/System.CommandLine.Tests/TokenizerTests.cs +++ b/src/System.CommandLine.Tests/TokenizerTests.cs @@ -15,14 +15,14 @@ public partial class TokenizerTests { [Fact] - public void The_tokenizer_is_accessible() + 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; - CliTokenizer.Tokenize(args,command,false, true, out tokens, out errors); + Tokenizer.Tokenize(args, command, new CliConfiguration(command), true, out tokens, out errors); tokens .Skip(1) @@ -32,5 +32,60 @@ public void The_tokenizer_is_accessible() errors.Should().BeNull(); } + + [Fact] + public void Location_stack_is_correct() + { + var option = new CliOption("--hello"); + var command = new CliRootCommand { option }; + IReadOnlyList args = ["--hello", "world"]; + List tokens = null; + List errors = null; + + int rootCommandNameLength = CliExecutable.ExecutableName.Length; + + 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($"User [-1, {rootCommandNameLength}, 0]; User [0, 7, 0]"); + locations[1].Should().Be($"User [-1, {rootCommandNameLength}, 0]; 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/CliConfiguration.cs b/src/System.CommandLine/CliConfiguration.cs index b5a4e09e26..8b19f68f99 100644 --- a/src/System.CommandLine/CliConfiguration.cs +++ b/src/System.CommandLine/CliConfiguration.cs @@ -55,6 +55,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,8 +115,8 @@ 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. /// diff --git a/src/System.CommandLine/ParseResult.cs b/src/System.CommandLine/ParseResult.cs index 35f940e6c1..9c8e96cea6 100644 --- a/src/System.CommandLine/ParseResult.cs +++ b/src/System.CommandLine/ParseResult.cs @@ -25,6 +25,7 @@ public sealed class ParseResult internal ParseResult( CliConfiguration configuration, +// TODO: determine how rootCommandResult and commandResult differ CommandResult rootCommandResult, CommandResult commandResult, List tokens, diff --git a/src/System.CommandLine/Parsing/CliParser.cs b/src/System.CommandLine/Parsing/CliParser.cs index 0b5fd199c0..1ab9ddd41e 100644 --- a/src/System.CommandLine/Parsing/CliParser.cs +++ b/src/System.CommandLine/Parsing/CliParser.cs @@ -138,6 +138,7 @@ 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 rootCommand, IReadOnlyList arguments, @@ -151,11 +152,11 @@ private static ParseResult Parse( configuration ??= new CliConfiguration(rootCommand); - CliTokenizer.Tokenize( + Tokenizer.Tokenize( arguments, rootCommand, + configuration, inferRootCommand: rawInput is not null, - configuration.EnablePosixBundling, out List tokens, out List? tokenizationErrors); diff --git a/src/System.CommandLine/Parsing/CliToken.cs b/src/System.CommandLine/Parsing/CliToken.cs index 76e7ca2d76..d692285332 100644 --- a/src/System.CommandLine/Parsing/CliToken.cs +++ b/src/System.CommandLine/Parsing/CliToken.cs @@ -3,6 +3,8 @@ 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? /// @@ -10,35 +12,39 @@ namespace System.CommandLine.Parsing /// 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 + /// The location of the token + /* 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) + */ + + 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/Location.cs b/src/System.CommandLine/Parsing/Location.cs new file mode 100644 index 0000000000..bfaa877cda --- /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 start, Location? outerLocation, int offset = 0) + { + Text = text; + Source = source; + Start = start; + Length = text.Length; + Offset = offset; + OuterLocation = outerLocation; + } + + public string Text { get; } + public string Source { get; } + public int Start { 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() + "; ")}{Source} [{Start}, {Length}, {Offset}]"; + + } +} \ No newline at end of file diff --git a/src/System.CommandLine/Parsing/StringExtensions.cs b/src/System.CommandLine/Parsing/StringExtensions.cs index bee1ab5ad1..9927aa4dd9 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; @@ -27,10 +29,13 @@ internal static int IndexOfCaseInsensitive( */ } - internal static class CliTokenizer + internal static class Tokenizer { + private const string doubleDash = "--"; + 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)); @@ -39,7 +44,7 @@ internal static (string? Prefix, string Alias) SplitPrefix(string rawAlias) { if (rawAlias.Length > 1 && rawAlias[1] == '-') { - return ("--", rawAlias.Substring(2)); + return (doubleDash, rawAlias.Substring(2)); } return ("-", rawAlias.Substring(1)); @@ -48,199 +53,171 @@ internal static (string? Prefix, string Alias) SplitPrefix(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( IReadOnlyList args, CliCommand rootCommand, + CliConfiguration configuration, bool inferRootCommand, - bool enablePosixBundling, out List tokens, out List? errors) { - const int FirstArgIsNotRootCommand = -1; - - List? errorList = null; - - var currentCommand = rootCommand; - var foundDoubleDash = false; - // TODO: Directives - /* - var foundEndOfDirectives = false; - */ - - var tokenList = new List(args.Count); + tokens = new List(args.Count); - var knownTokens = GetValidTokens(rootCommand); - - int i = FirstArgumentIsRootCommand(args, 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 - ? 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.Start); + + + 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]; - // TODO: Directives - /* - 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.Start == 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; } - } - /* - // TODO: ResponseFileTokenReplacer - /* - 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 != rootCommand) - { - knownTokens = GetValidTokens(cmd); // 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 (TrySplitIntoSubtokens(arg, 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 (!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); - - // TODO: Directives - // 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 { @@ -250,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.Start, 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.Start, 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.Start, 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.Start, outerLocation, index))); return true; } } @@ -285,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) { @@ -304,9 +289,48 @@ 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) { @@ -333,11 +357,7 @@ private static bool FirstArgumentIsRootCommand(IReadOnlyList args, CliCo return false; } - private static string? GetReplaceableTokenValue(string arg) => - arg.Length > 1 && arg[0] == '@' - ? arg.Substring(1) - : null; - + // TODO: Naming rules - sub-tokens has a dash and thus should be SubToken private static bool TrySplitIntoSubtokens( string arg, out string first, @@ -362,87 +382,9 @@ private static bool TrySplitIntoSubtokens( return false; } - // TODO: rename to TryTokenizeResponseFile - internal static bool TryReadResponseFile( - string filePath, - out IReadOnlyList? newTokens, - out string? error) + private static Dictionary GetValidTokens(CliCommand command) { - 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; - } - } - } - - private static Dictionary GetValidTokens(CliCommand command) - { - Dictionary tokens = new(StringComparer.Ordinal); - - // TODO: Directives - /* - 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); @@ -458,64 +400,34 @@ private static Dictionary GetValidTokens(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]; - // TODO: recursive options - /* - 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) @@ -524,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/System.CommandLine.csproj b/src/System.CommandLine/System.CommandLine.csproj index 4595ebb603..fa596533f6 100644 --- a/src/System.CommandLine/System.CommandLine.csproj +++ b/src/System.CommandLine/System.CommandLine.csproj @@ -51,6 +51,7 @@ +