using CommandLine; using CommandLine.Text; using Dinah.Core; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; namespace LibationCli; file static class GlobalCliHelp { internal static bool IsGlobalHelpToken(string token) { if (string.IsNullOrWhiteSpace(token)) return false; return token.Equals("--help", StringComparison.OrdinalIgnoreCase) || token.Equals("-h", StringComparison.OrdinalIgnoreCase) || token.Equals("/?", StringComparison.OrdinalIgnoreCase) || token.Equals("/h", StringComparison.OrdinalIgnoreCase) || token.Equals("/help", StringComparison.OrdinalIgnoreCase); } } public enum ExitCode { ProcessCompletedSuccessfully = 0, NonRunNonError = 1, ParseError = 2, RunTimeError = 3 } internal sealed record CliParseOutcome(ParserResult? Result, ExitCode? ExitCode); class Program { public static readonly Type[] VerbTypes = Setup.LoadVerbs() .Where(type => type != typeof(AbsUploadOptions)) .ToArray(); static async Task Main(string[] args) { Console.OutputEncoding = Console.InputEncoding = System.Text.Encoding.UTF8; #if DEBUG string input = ""; //input = " set-status -p --force B017V4IM1G"; //input = " liberate B017V4IM1G"; //input = " convert B017V4IM1G"; //input = " search \"-liberated\""; //input = " export --help"; //input = " version --check"; //input = " scan rmcrackan"; //input = " help set-status"; //input = " liberate "; //input = "get-setting -o Replace_OpenQuote=[ "; //input = "get-setting "; //input = "liberate B017V4NOZ0 --force -o Books=\"./Books\""; // note: this hack will fail for quoted file paths with spaces because it will break on those spaces if (!string.IsNullOrWhiteSpace(input)) args = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); var setBreakPointHere = args; #endif var outcome = ParseInvocation(args, Console.Error); if (outcome.ExitCode is { } exitCode) { Environment.ExitCode = (int)exitCode; return; } //Everything parsed correctly, so execute the command // async: run parsed options await outcome.Result!.WithParsedAsync(opt => opt.Run()); } internal static CliParseOutcome ParseInvocation( string[] args, TextWriter error, IReadOnlyList? groups = null) { var route = CliCommandRouter.Route(args, groups ?? CliCommandGroups.All); if (route.Kind == CliRouteKind.GroupHelp) { error.WriteLine(HelpVerb.GetGroupHelpText(route.Group!)); return new(null, ExitCode.ProcessCompletedSuccessfully); } args = route.ParserArgs; if (route.Kind == CliRouteKind.UnknownSubcommand) { error.WriteLine($"Unknown {route.Group!.Name.ToUpperInvariant()} command '{args[1]}'."); error.WriteLine(HelpVerb.GetGroupHelpText(route.Group!)); return new(null, ExitCode.ParseError); } if (TryPrintGlobalHelpOnly(args, error)) return new(null, ExitCode.ProcessCompletedSuccessfully); args = NormalizeVerbShortHelpAliases(args); Type[] parserVerbTypes = route.Subcommand is { } subcommand ? [subcommand.OptionsType] : VerbTypes; var result = new Parser(ConfigureParser).ParseArguments(args, parserVerbTypes); if (result.Value is HelpVerb helper) { // AutoHelp normally intercepts bare `help` as HelpVerbRequestedError. Keep this path // consistent if CommandLineParser ever hands us a parsed HelpVerb instead. if (string.IsNullOrWhiteSpace(helper.HelpType)) WriteGlobalVerbListHelp(error); else error.WriteLine(helper.GetHelpText()); return new(result, ExitCode.ProcessCompletedSuccessfully); } if (result.TypeInfo.Current == typeof(HelpVerb)) { //Error parsing the command, but the verb type was identified as HelpVerb WriteGlobalVerbListHelp(error); return new(result, ExitCode.ProcessCompletedSuccessfully); } if (result.Errors.Any()) return new(result, HandleErrors(result, error, route)); return new(result, null); } private static ExitCode HandleErrors(ParserResult result, TextWriter error, CliRoute route) { var errorsList = result.Errors.ToList(); if (errorsList.Any(e => e.Tag == ErrorType.HelpRequestedError)) { WriteVerbOptionsHelp(result, error, route); return ExitCode.ProcessCompletedSuccessfully; } if (errorsList.OfType().FirstOrDefault() is { } helpVerbErr) { WriteHelpForVerbRequestedError(helpVerbErr, error); return ExitCode.ProcessCompletedSuccessfully; } if (errorsList.Any(e => e.Tag == ErrorType.VersionRequestedError)) { error.WriteLine(HelpVerb.CreateHelpText().Heading); return ExitCode.ProcessCompletedSuccessfully; } if (errorsList.OfType().Any()) { WriteGlobalVerbListHelp(error, "No verb selected"); return ExitCode.ParseError; } //print the specified verb's usage var helpText = HelpVerb.CreateHelpText(); helpText.AddDashesToOption = true; helpText.AutoHelp = true; if (!errorsList.OfType().Any(o => o.Token.ToLower() == "help")) { //verb was not executed with the "--help" option, //so print verb option parsing error info. helpText = HelpText.DefaultParsingErrorsHandler(result, helpText); } AddNestedCommandHeading(helpText, route); helpText.AddOptions(result); error.WriteLine(helpText); return ExitCode.ParseError; } /// /// Multi-verb parsing treats the first token as a verb name, so bare --help / -h must be handled here. /// private static bool TryPrintGlobalHelpOnly(string[] args, TextWriter error) { if (args is not { Length: 1 } || !GlobalCliHelp.IsGlobalHelpToken(args[0])) return false; WriteGlobalVerbListHelp(error); return true; } /// /// CommandLineParser's implicit help is --help only; map the first -h after the verb (case-insensitive, so -H too) to --help. /// private static string[] NormalizeVerbShortHelpAliases(string[] args) { if (args.Length < 2) return args; var copy = (string[])args.Clone(); for (var i = 1; i < copy.Length; i++) { if (copy[i].Equals("-h", StringComparison.OrdinalIgnoreCase)) { copy[i] = "--help"; break; } } return copy; } private static void WriteGlobalVerbListHelp(TextWriter error, string? preOptionsLine = null) => HelpVerb.WriteGlobalVerbList(error, VerbTypes, CliCommandGroups.All, preOptionsLine); private static void WriteVerbOptionsHelp(ParserResult result, TextWriter error, CliRoute? route = null) { var helpText = HelpVerb.CreateHelpText(); AddNestedCommandHeading(helpText, route); helpText.AddDashesToOption = true; helpText.AutoHelp = true; helpText.AddOptions(result); error.WriteLine(helpText); } /// /// Nested commands are rewritten to an internal parser verb (e.g. upload). /// Brand help with the public path (abs upload) so the usage screen matches what users type. /// private static void AddNestedCommandHeading(HelpText helpText, CliRoute? route) { if (route?.Group is not { } group || route.Subcommand is not { } subcommand) return; helpText.AddPreOptionsLine($"{group.Name} {subcommand.Name}"); helpText.AddPreOptionsLine(subcommand.HelpText); } private static void WriteHelpForVerbRequestedError(HelpVerbRequestedError helpVerbErr, TextWriter error) { if (!helpVerbErr.Matched || helpVerbErr.Type is null || string.IsNullOrWhiteSpace(helpVerbErr.Verb)) { WriteGlobalVerbListHelp(error); return; } var subResult = new Parser(ConfigureParser).ParseArguments(new[] { helpVerbErr.Verb }, VerbTypes); if (subResult.TypeInfo.Current != typeof(NullInstance)) WriteVerbOptionsHelp(subResult, error); else WriteGlobalVerbListHelp(error); } private static void ConfigureParser(ParserSettings settings) { settings.AllowMultiInstance = true; settings.AutoVersion = false; settings.AutoHelp = true; } }