From 2bcafeb8a2d7c9aebd1d6bd223a8611c82f9e8e8 Mon Sep 17 00:00:00 2001 From: Tig Date: Fri, 12 Jun 2026 07:57:21 -0700 Subject: [PATCH 1/2] fix: preserve dash-prefixed tokens in DefaultCommand fallback (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the default-command fallback re-parsed [DefaultCommand, ..args], dash-prefixed tokens that weren't declared options still failed with 'Unknown option' — e.g. 'app --literal' or 'app Alice --suffix' — contradicting the DefaultCommand contract that unrecognized options are retried as args. Add ArgParser.Parse(args, command, unknownOptionsAsArguments) which passes unknown dash-prefixed tokens through verbatim as positional arguments while recognized framework/global/command options still parse normally. The fallback path in CliHost.RunWithDefaultCommandAsync opts in; the default parse behavior is unchanged. Commands that reject positional args still produce the usual usage error. Test-first: added failing integration tests (greet host: '--literal', 'Alice --suffix', and 'Alice --formal' for the recognized-option case) and ArgParser unit tests covering the new mode, default-mode rejection, and the no-positionals guard, then implemented the fix. Spec updated per constitution C2. Fixes #30 Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/library-spec.md | 9 +++- src/Terminal.Gui.Cli/ArgParser.cs | 16 ++++++- src/Terminal.Gui.Cli/CliHost.cs | 6 ++- .../GreetExampleTests.cs | 48 +++++++++++++++++++ .../Terminal.Gui.Cli.Tests/ArgParserTests.cs | 46 ++++++++++++++++++ 5 files changed, 121 insertions(+), 4 deletions(-) diff --git a/specs/library-spec.md b/specs/library-spec.md index 255c794..3c2d67b 100644 --- a/specs/library-spec.md +++ b/specs/library-spec.md @@ -38,8 +38,13 @@ resolve to a registered command. When set, `CliHost` routes to that command in t - The leading token is not a recognized command alias. In each case the host re-parses `[DefaultCommand, ..args]` against the resolved default -command, so bare positional args and unrecognized options are retried as args to it. If -`DefaultCommand` names an alias that is not registered, the host emits +command, so bare positional args and unrecognized options are retried as args to it. The +reparse uses `ArgParser.Parse(args, command, unknownOptionsAsArguments: true)`: dash-prefixed +tokens that match no framework, global, or default-command option pass through verbatim as +positional arguments (e.g. `app --literal` and `app Alice --suffix` reach the default command +as positionals), while recognized options still parse as options. If the default command does +not accept positional args, leftover tokens still produce the usual positional-args usage +error. If `DefaultCommand` names an alias that is not registered, the host emits `Default command '' is not registered.` and returns a usage error. When `DefaultCommand` is null, the original parse/usage-error behavior is preserved. diff --git a/src/Terminal.Gui.Cli/ArgParser.cs b/src/Terminal.Gui.Cli/ArgParser.cs index da84d7e..255fb63 100644 --- a/src/Terminal.Gui.Cli/ArgParser.cs +++ b/src/Terminal.Gui.Cli/ArgParser.cs @@ -29,7 +29,14 @@ public ArgParser (List globalOptions, int maxInitialChar } /// Parses command-line arguments, optionally validating against a resolved command. - public ParseResult Parse (string[] args, ICliCommand? command = null) + /// The raw command-line arguments. + /// The resolved command to validate options against, when known. + /// + /// When true, dash-prefixed tokens that match no framework, global, or command option are passed + /// through verbatim as positional arguments instead of failing the parse. Used by the + /// default-command fallback so original tokens reach the default command (issue #30). + /// + public ParseResult Parse (string[] args, ICliCommand? command = null, bool unknownOptionsAsArguments = false) { ArgumentNullException.ThrowIfNull (args); @@ -130,6 +137,13 @@ public ParseResult Parse (string[] args, ICliCommand? command = null) continue; } + if (unknownOptionsAsArguments) + { + arguments.Add (token); + index++; + continue; + } + return ParseResult.Fail ($"Unknown option '{token}'."); } diff --git a/src/Terminal.Gui.Cli/CliHost.cs b/src/Terminal.Gui.Cli/CliHost.cs index 73194e1..4fd5986 100644 --- a/src/Terminal.Gui.Cli/CliHost.cs +++ b/src/Terminal.Gui.Cli/CliHost.cs @@ -90,7 +90,11 @@ private async Task RunWithDefaultCommandAsync ( } string[] adjusted = [_options.DefaultCommand!, .. args]; - ArgParser.ParseResult parse = _parser.Parse (adjusted, defaultCmd); + + // The fallback fires precisely because the original tokens didn't resolve to a known + // command/options, so unknown dash-prefixed tokens must pass through verbatim as + // positional arguments rather than failing the reparse (issue #30). + ArgParser.ParseResult parse = _parser.Parse (adjusted, defaultCmd, true); if (!parse.Success || parse.Options is null) { diff --git a/tests/Terminal.Gui.Cli.IntegrationTests/GreetExampleTests.cs b/tests/Terminal.Gui.Cli.IntegrationTests/GreetExampleTests.cs index 0092172..26287b8 100644 --- a/tests/Terminal.Gui.Cli.IntegrationTests/GreetExampleTests.cs +++ b/tests/Terminal.Gui.Cli.IntegrationTests/GreetExampleTests.cs @@ -94,6 +94,54 @@ public async Task DefaultCommand_NoArgs_GreetsWorld () Assert.Equal (ExitCodes.Ok, exitCode); } + [Fact] + public async Task DefaultCommand_DashPrefixedToken_PassesThroughAsPositionalArg () + { + CliHost host = CreateGreetHost (); + using StringWriter stdout = new (); + using StringWriter stderr = new (); + + // "--literal" is not a recognized option anywhere; the default-command fallback + // must pass it through verbatim as a positional argument (issue #30). + var exitCode = await host.RunAsync (["--literal"], TestContext.Current.CancellationToken, stdout, stderr); + + Assert.Equal (ExitCodes.Ok, exitCode); + Assert.Contains ("Hello, --literal!", stdout.ToString ()); + Assert.Equal (string.Empty, stderr.ToString ()); + } + + [Fact] + public async Task DefaultCommand_PositionalThenUnknownOption_PassesBothThroughAsArgs () + { + CliHost host = CreateGreetHost (); + using StringWriter stdout = new (); + using StringWriter stderr = new (); + + var exitCode = await host.RunAsync (["Alice", "--suffix"], TestContext.Current.CancellationToken, stdout, + stderr); + + Assert.Equal (ExitCodes.Ok, exitCode); + Assert.Contains ("Hello, Alice --suffix!", stdout.ToString ()); + Assert.Equal (string.Empty, stderr.ToString ()); + } + + [Fact] + public async Task DefaultCommand_RecognizedOptionInFallback_StillParsesAsOption () + { + CliHost host = CreateGreetHost (); + using StringWriter stdout = new (); + using StringWriter stderr = new (); + + // "--formal" is a declared option of the default command; the fallback must + // still parse it as an option, not a positional argument. + var exitCode = await host.RunAsync (["Alice", "--formal"], TestContext.Current.CancellationToken, stdout, + stderr); + + Assert.Equal (ExitCodes.Ok, exitCode); + Assert.Contains ("Good day, Alice.", stdout.ToString ()); + Assert.Equal (string.Empty, stderr.ToString ()); + } + [Fact] public async Task HelpCat_RendersAnsiForRootHelp () { diff --git a/tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs b/tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs index 05a8750..58b1dd8 100644 --- a/tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs +++ b/tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs @@ -38,6 +38,52 @@ public void TryParseTimeout_WithOverflowValue_ReturnsFalse () Assert.False (ArgParser.TryParseTimeout ("1e999999h", out _)); } + [Fact] + public void Parse_UnknownDashToken_FailsByDefault () + { + ArgParser parser = new ([]); + + ArgParser.ParseResult result = + parser.Parse (["pick", "--name", "value", "--literal"], new TestCommand (true)); + + Assert.False (result.Success); + Assert.Contains ("--literal", result.Error); + } + + [Fact] + public void Parse_UnknownOptionsAsArguments_TreatsUnknownDashTokensAsPositionals () + { + ArgParser parser = new ([]); + + ArgParser.ParseResult result = parser.Parse ( + ["pick", "--literal", "Alice", "--name", "value", "--json"], + new TestCommand (true), + true); + + Assert.True (result.Success, result.Error); + Assert.NotNull (result.Options); + + // Unknown dash tokens pass through verbatim as positionals; recognized + // command and framework options still parse normally. + Assert.Equal (["--literal", "Alice"], result.Options.Arguments); + Assert.Equal ("value", result.Options.CommandOptions["name"]); + Assert.True (result.Options.JsonOutput); + } + + [Fact] + public void Parse_UnknownOptionsAsArguments_StillRejectedWhenCommandForbidsPositionals () + { + ArgParser parser = new ([]); + + ArgParser.ParseResult result = parser.Parse ( + ["pick", "--name", "value", "--literal"], + new TestCommand (false), + true); + + Assert.False (result.Success); + Assert.Contains ("positional", result.Error); + } + [Fact] public void Parse_RejectsMissingRequiredCommandOption () { From 27d38abb7dd70e16002b815a7a16512d342e3d58 Mon Sep 17 00:00:00 2001 From: Tig Date: Fri, 12 Jun 2026 08:04:14 -0700 Subject: [PATCH 2/2] fix: restore two-argument ArgParser.Parse overload for binary compatibility Optional parameters bind at compile time, so widening the existing public Parse(string[], ICliCommand?) to three parameters removed the old CLR method and would throw MissingMethodException for already-compiled consumers. Keep the original overload delegating to the new three-argument implementation. Addresses Codex P2 review feedback on #31. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Terminal.Gui.Cli/ArgParser.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Terminal.Gui.Cli/ArgParser.cs b/src/Terminal.Gui.Cli/ArgParser.cs index 255fb63..1014bc5 100644 --- a/src/Terminal.Gui.Cli/ArgParser.cs +++ b/src/Terminal.Gui.Cli/ArgParser.cs @@ -28,6 +28,14 @@ public ArgParser (List globalOptions, int maxInitialChar _maxInitialChars = maxInitialChars; } + /// Parses command-line arguments, optionally validating against a resolved command. + /// The raw command-line arguments. + /// The resolved command to validate options against, when known. + public ParseResult Parse (string[] args, ICliCommand? command = null) + { + return Parse (args, command, false); + } + /// Parses command-line arguments, optionally validating against a resolved command. /// The raw command-line arguments. /// The resolved command to validate options against, when known. @@ -36,7 +44,7 @@ public ArgParser (List globalOptions, int maxInitialChar /// through verbatim as positional arguments instead of failing the parse. Used by the /// default-command fallback so original tokens reach the default command (issue #30). /// - public ParseResult Parse (string[] args, ICliCommand? command = null, bool unknownOptionsAsArguments = false) + public ParseResult Parse (string[] args, ICliCommand? command, bool unknownOptionsAsArguments) { ArgumentNullException.ThrowIfNull (args);