Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions specs/library-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<name>' is not registered.` and returns a usage error. When
`DefaultCommand` is null, the original parse/usage-error behavior is preserved.

Expand Down
22 changes: 22 additions & 0 deletions src/Terminal.Gui.Cli/ArgParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,22 @@ public ArgParser (List<GlobalOptionDescriptor> globalOptions, int maxInitialChar
}

/// <summary>Parses command-line arguments, optionally validating against a resolved command.</summary>
/// <param name="args">The raw command-line arguments.</param>
/// <param name="command">The resolved command to validate options against, when known.</param>
public ParseResult Parse (string[] args, ICliCommand? command = null)
{
return Parse (args, command, false);
}

/// <summary>Parses command-line arguments, optionally validating against a resolved command.</summary>
/// <param name="args">The raw command-line arguments.</param>
/// <param name="command">The resolved command to validate options against, when known.</param>
/// <param name="unknownOptionsAsArguments">
/// 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).
/// </param>
public ParseResult Parse (string[] args, ICliCommand? command, bool unknownOptionsAsArguments)
{
ArgumentNullException.ThrowIfNull (args);

Expand Down Expand Up @@ -130,6 +145,13 @@ public ParseResult Parse (string[] args, ICliCommand? command = null)
continue;
}

if (unknownOptionsAsArguments)
{
arguments.Add (token);
index++;
continue;
}

return ParseResult.Fail ($"Unknown option '{token}'.");
}

Expand Down
6 changes: 5 additions & 1 deletion src/Terminal.Gui.Cli/CliHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,11 @@ private async Task<int> 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)
{
Expand Down
48 changes: 48 additions & 0 deletions tests/Terminal.Gui.Cli.IntegrationTests/GreetExampleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ()
{
Expand Down
46 changes: 46 additions & 0 deletions tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ()
{
Expand Down
Loading