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
111 changes: 107 additions & 4 deletions src/Nullean.Argh.Generator/CliParserGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3823,6 +3823,8 @@ private static void EmitFuzzyDispatchDefault(
sb.AppendLine("\t\t\t\tif (__matches.Count == 0)");
sb.AppendLine("\t\t\t\t{");
sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown {kind} '{{__tok}}'.\");");
sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine();");
sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine($\"Run '{{__app}} {nsHelp}' for usage.\");");
sb.AppendLine("\t\t\t\t}");
sb.AppendLine("\t\t\t\telse if (__matches.Count == 1)");
sb.AppendLine("\t\t\t\t{");
Expand Down Expand Up @@ -6484,6 +6486,85 @@ private static void EmitValidationChecks(

private static string EscapeVerbatimString(string s) => s.Replace("\"", "\"\"");

private static void EmitCommandRunnerFuzzyFailHelper(
StringBuilder sb,
CommandModel cmd,
string? flagHelpStdErrMethodName,
string? parseFailureRunHint)
{
var flagParams = cmd.Parameters
.Where(static p => IsEmittedFlagLike(p.Kind))
.ToList();

if (flagParams.Count > 0)
{
sb.Append("\t\t\tvar __flagFuzzyCands = new string[] { ");
var sortedNames = flagParams
.Select(static p => p.CliLongName)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
for (var i = 0; i < sortedNames.Count; i++)
{
if (i > 0)
sb.Append(", ");
sb.Append('"').Append(Escape(sortedNames[i])).Append('"');
}
sb.AppendLine(" };");
}

sb.AppendLine("\t\t\tint FailUnknownLongOption(string flagName)");
sb.AppendLine("\t\t\t{");
if (flagParams.Count > 0)
{
sb.AppendLine($"\t\t\t\tvar __matches = FuzzyMatch.FindClosest(flagName, __flagFuzzyCands, {FuzzyMaxDistance});");
sb.AppendLine("\t\t\t\tif (__matches.Count == 0)");
sb.AppendLine("\t\t\t\t{");
}
sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown option '--{flagName}'.\");");
if (parseFailureRunHint is not null)
{
sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine();");
sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine(\"{Escape(parseFailureRunHint)}\");");
}
sb.AppendLine("\t\t\t\t\treturn 2;");
if (flagParams.Count > 0)
{
sb.AppendLine("\t\t\t\t}");
sb.AppendLine("\t\t\t\tif (__matches.Count == 1)");
sb.AppendLine("\t\t\t\t{");
sb.AppendLine("\t\t\t\t\tvar __m = __matches[0];");
sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown option '--{flagName}'. Did you mean '--{__m}'?\");");
if (flagHelpStdErrMethodName is not null)
{
sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine();");
sb.AppendLine($"\t\t\t\t\t{flagHelpStdErrMethodName}(__m);");
}
if (parseFailureRunHint is not null)
{
sb.AppendLine("\t\t\t\t\tConsole.Error.WriteLine();");
sb.AppendLine($"\t\t\t\t\tConsole.Error.WriteLine(\"{Escape(parseFailureRunHint)}\");");
}
sb.AppendLine("\t\t\t\t\treturn 2;");
sb.AppendLine("\t\t\t\t}");
sb.AppendLine("\t\t\t\tConsole.Error.WriteLine($\"Error: unknown option '--{flagName}'. Did you mean one of these?\");");
sb.AppendLine("\t\t\t\tConsole.Error.WriteLine();");
sb.AppendLine("\t\t\t\tforeach (var __m in __matches)");
sb.AppendLine("\t\t\t\t{");
if (flagHelpStdErrMethodName is not null)
sb.AppendLine($"\t\t\t\t\t{flagHelpStdErrMethodName}(__m);");
sb.AppendLine("\t\t\t\t}");
if (parseFailureRunHint is not null)
{
sb.AppendLine("\t\t\t\tConsole.Error.WriteLine();");
sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"{Escape(parseFailureRunHint)}\");");
}
sb.AppendLine("\t\t\t\treturn 2;");
}
sb.AppendLine("\t\t\t}");
sb.AppendLine();
}

private static void EmitCommandRunner(
StringBuilder sb,
CommandModel cmd,
Expand Down Expand Up @@ -6557,9 +6638,11 @@ private static void EmitCommandRunner(
sb.AppendLine("\t\t\tvar positionals = new List<string>();");
EmitBoolSwitchNames(sb, cmd);
EmitCanonFlagNameMethod(sb, cmd);
EmitShortFlagMethods(sb, cmd, multiFlagsAvailable: anyRepeatedCollection);
if (emitDtoTryParse)
EmitKnownNonBoolFlagNames(sb, cmd);
EmitShortFlagMethods(sb, cmd, multiFlagsAvailable: anyRepeatedCollection,
parseFailureRunHint: emitDtoTryParse ? null : parseFailureRunHint);
EmitKnownNonBoolFlagNames(sb, cmd);
if (!emitDtoTryParse)
EmitCommandRunnerFuzzyFailHelper(sb, cmd, flagHelpStdErrMethodName, parseFailureRunHint);
sb.AppendLine("\t\t\tfor (var i = 0; i < args.Length;)");
sb.AppendLine("\t\t\t{");
sb.AppendLine("\t\t\t\tvar a = args[i];");
Expand All @@ -6578,6 +6661,11 @@ private static void EmitCommandRunner(
sb.AppendLine($"\t\t\t\t\t\t\t{failureExit};");
sb.AppendLine("\t\t\t\t\t\t}");
}
else if (!emitDtoTryParse)
{
sb.AppendLine("\t\t\t\t\t\tif (!IsBoolSwitchName(flagName) && !IsKnownNonBoolFlagName(flagName))");
sb.AppendLine("\t\t\t\t\t\t\treturn FailUnknownLongOption(flagName);");
}
if (anyRepeatedCollection)
{
sb.AppendLine("\t\t\t\t\t\tSetFlag(flagName, flagValue);");
Expand Down Expand Up @@ -6621,6 +6709,11 @@ private static void EmitCommandRunner(
sb.AppendLine("\t\t\t\t\t\t\t}");
}
}
else
{
sb.AppendLine("\t\t\t\t\t\t\tif (!IsKnownNonBoolFlagName(flagName))");
sb.AppendLine("\t\t\t\t\t\t\t\treturn FailUnknownLongOption(flagName);");
}
sb.AppendLine("\t\t\t\t\t\t\tif (i + 1 >= args.Length)");
sb.AppendLine("\t\t\t\t\t\t\t{");
sb.AppendLine("\t\t\t\t\t\t\t\tConsole.Error.WriteLine($\"Error: missing value for flag --{flagName}.\");");
Expand Down Expand Up @@ -7313,7 +7406,7 @@ private static void EmitCanonFlagNameMethod(StringBuilder sb, CommandModel cmd)
sb.AppendLine("\t\t\t};");
}

private static void EmitShortFlagMethods(StringBuilder sb, CommandModel cmd, bool multiFlagsAvailable = true)
private static void EmitShortFlagMethods(StringBuilder sb, CommandModel cmd, bool multiFlagsAvailable = true, string? parseFailureRunHint = null)
{
var shortCases = new List<(char c, string Primary, bool IsBool, bool IsRepeatableCollection)>();
foreach (var p in cmd.Parameters)
Expand All @@ -7332,6 +7425,11 @@ private static void EmitShortFlagMethods(StringBuilder sb, CommandModel cmd, boo
sb.AppendLine("\t\t\tbool TryApplyShortFlag(char c, string val)");
sb.AppendLine("\t\t\t{");
sb.AppendLine("\t\t\t\tConsole.Error.WriteLine($\"Error: unknown short option '-{c}'.\");");
if (parseFailureRunHint is not null)
{
sb.AppendLine("\t\t\t\tConsole.Error.WriteLine();");
sb.AppendLine($"\t\t\t\tConsole.Error.WriteLine(\"{Escape(parseFailureRunHint)}\");");
}
sb.AppendLine("\t\t\t\treturn false;");
sb.AppendLine("\t\t\t}");
sb.AppendLine("\t\t\tbool IsShortBoolChar(char c) => false;");
Expand Down Expand Up @@ -7361,6 +7459,11 @@ private static void EmitShortFlagMethods(StringBuilder sb, CommandModel cmd, boo

sb.AppendLine("\t\t\t\t\tdefault:");
sb.AppendLine("\t\t\t\t\t\tConsole.Error.WriteLine($\"Error: unknown short option '-{c}'.\");");
if (parseFailureRunHint is not null)
{
sb.AppendLine("\t\t\t\t\t\tConsole.Error.WriteLine();");
sb.AppendLine($"\t\t\t\t\t\tConsole.Error.WriteLine(\"{Escape(parseFailureRunHint)}\");");
}
sb.AppendLine("\t\t\t\t\t\treturn false;");
sb.AppendLine("\t\t\t\t}");
sb.AppendLine("\t\t\t}");
Expand Down
83 changes: 83 additions & 0 deletions tests/Nullean.Argh.IntegrationTests/ParseErrors/ParseErrorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,89 @@ public void Global_flag_typo_suggests_did_you_mean_and_prints_flag_help()
err.Should().Contain("--severity <enum>");
}

[Fact]
public void Command_flag_typo_returns_exit_2()
{
var result = CliHostRunner.Run(
new Dictionary<string, string>(StringComparer.Ordinal) { ["NO_COLOR"] = "1" },
"hello", "--nme", "world");
result.ExitCode.Should().Be(2);
}

[Fact]
public void Command_flag_typo_suggests_did_you_mean_and_prints_flag_help()
{
var result = CliHostRunner.Run(
new Dictionary<string, string>(StringComparer.Ordinal) { ["NO_COLOR"] = "1" },
"hello", "--nme", "world");
var err = ConsoleOutput.Normalize(CliHostRunner.StderrText(result));
err.Should().Contain("Error: unknown option '--nme'. Did you mean '--name'?");
err.Should().Contain("--name <string>");
err.Should().Contain($"Run '{CliHostPaths.CliHostAssemblyName} hello --help' for usage.");
ConsoleOutput.Normalize(CliHostRunner.StdoutText(result)).Should().BeEmpty();
}

[Fact]
public void Command_flag_equals_syntax_typo_returns_exit_2()
{
var result = CliHostRunner.Run(
new Dictionary<string, string>(StringComparer.Ordinal) { ["NO_COLOR"] = "1" },
"hello", "--nme=world");
result.ExitCode.Should().Be(2);
var err = ConsoleOutput.Normalize(CliHostRunner.StderrText(result));
err.Should().Contain("Error: unknown option '--nme'. Did you mean '--name'?");
}

[Fact]
public void Command_completely_unknown_flag_returns_exit_2_with_run_hint()
{
var result = CliHostRunner.Run(
new Dictionary<string, string>(StringComparer.Ordinal) { ["NO_COLOR"] = "1" },
"hello", "--zzz", "x");
result.ExitCode.Should().Be(2);
var err = ConsoleOutput.Normalize(CliHostRunner.StderrText(result));
err.Should().Contain("Error: unknown option '--zzz'.");
err.Should().Contain($"Run '{CliHostPaths.CliHostAssemblyName} hello --help' for usage.");
}

[Fact]
public void Command_unknown_short_flag_returns_exit_2_with_run_hint()
{
// enum-cmd has -n and -c; passing -z is unknown
var result = CliHostRunner.Run(
new Dictionary<string, string>(StringComparer.Ordinal) { ["NO_COLOR"] = "1" },
"enum-cmd", "-z", "val");
result.ExitCode.Should().Be(2);
var err = ConsoleOutput.Normalize(CliHostRunner.StderrText(result));
err.Should().Contain("Error: unknown short option '-z'.");
err.Should().Contain($"Run '{CliHostPaths.CliHostAssemblyName} enum-cmd --help' for usage.");
}

[Fact]
public void Unknown_namespace_command_no_match_prints_ns_help_hint()
{
// 'storage' namespace has 'list' and 'blob' — 'zzz' has no close match
var result = CliHostRunner.Run(
new Dictionary<string, string>(StringComparer.Ordinal) { ["NO_COLOR"] = "1" },
"storage", "zzz");
result.ExitCode.Should().Be(2);
var err = ConsoleOutput.Normalize(CliHostRunner.StderrText(result));
err.Should().Contain("Error: unknown command or namespace 'zzz'.");
err.Should().Contain($"Run '{CliHostPaths.CliHostAssemblyName} storage --help' for usage.");
}

[Fact]
public void Unknown_root_command_no_match_prints_root_help_hint()
{
var result = CliHostRunner.Run(
new Dictionary<string, string>(StringComparer.Ordinal) { ["NO_COLOR"] = "1" },
"zzz-completely-unknown");
result.ExitCode.Should().Be(2);
var err = ConsoleOutput.Normalize(CliHostRunner.StderrText(result));
err.Should().Contain("Error: unknown command or namespace 'zzz-completely-unknown'.");
err.Should().Contain($"Run '{CliHostPaths.CliHostAssemblyName} --help' for usage.");
}

[Fact]
public void Non_nullable_global_option_property_with_default_is_not_required()
{
Expand Down
Loading