From e16445299ec1d06968e20d483499c6e75dec18ce Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Mon, 11 May 2026 18:51:13 +0200 Subject: [PATCH] fix(generator): error on unknown command flags with Levenshtein suggestions Unknown long flags in command runners were silently stored and ignored instead of returning an error. Add validation in both --flag=value and --flag value parsing paths for non-DTO commands, with a FailUnknownLongOption helper that fuzzy-matches against known flags and suggests the closest match. Also fix unknown command/namespace 0-match case to include a namespace help hint (regression: only 1-match and multi-match cases had the run hint), and add the parseFailureRunHint to unknown short flag errors in command runners. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../CliParserGenerator.cs | 111 +++++++++++++++++- .../ParseErrors/ParseErrorTests.cs | 83 +++++++++++++ 2 files changed, 190 insertions(+), 4 deletions(-) diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.cs b/src/Nullean.Argh.Generator/CliParserGenerator.cs index b6a9edd..b716278 100644 --- a/src/Nullean.Argh.Generator/CliParserGenerator.cs +++ b/src/Nullean.Argh.Generator/CliParserGenerator.cs @@ -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{"); @@ -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, @@ -6557,9 +6638,11 @@ private static void EmitCommandRunner( sb.AppendLine("\t\t\tvar positionals = new List();"); 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];"); @@ -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);"); @@ -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}.\");"); @@ -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) @@ -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;"); @@ -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}"); diff --git a/tests/Nullean.Argh.IntegrationTests/ParseErrors/ParseErrorTests.cs b/tests/Nullean.Argh.IntegrationTests/ParseErrors/ParseErrorTests.cs index 4bc2948..c9e0210 100644 --- a/tests/Nullean.Argh.IntegrationTests/ParseErrors/ParseErrorTests.cs +++ b/tests/Nullean.Argh.IntegrationTests/ParseErrors/ParseErrorTests.cs @@ -53,6 +53,89 @@ public void Global_flag_typo_suggests_did_you_mean_and_prints_flag_help() err.Should().Contain("--severity "); } + [Fact] + public void Command_flag_typo_returns_exit_2() + { + var result = CliHostRunner.Run( + new Dictionary(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(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 "); + 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(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(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(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(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(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() {