-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Remove built-in help/error handling in CmdLine (#154)
Serde.CmdLine may eventually grow this functionality, but it looks like this is putting the cart before the horse. After the functionality as been implemented and working we can grow the API.
- Loading branch information
Showing
8 changed files
with
244 additions
and
281 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
// Licensed to the .NET Foundation under one or more agreements. | ||
// The .NET Foundation licenses this file to you under the MIT license. | ||
|
||
using System; | ||
|
||
namespace Serde.CmdLine; | ||
public sealed class ArgumentSyntaxException : Exception | ||
{ | ||
public ArgumentSyntaxException(string message) | ||
: base(message) | ||
{ | ||
} | ||
|
||
public ArgumentSyntaxException(string message, Exception innerException) | ||
: base(message, innerException) | ||
{ | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,88 +1,186 @@ | ||
using Spectre.Console; | ||
|
||
namespace Serde.CmdLine; | ||
|
||
public static class CmdLine | ||
{ | ||
/// <summary> | ||
/// Try to parse the command line arguments directly into a command object. | ||
/// No errors are handled, so exceptions will be thrown if the arguments are invalid. | ||
/// </summary> | ||
public static T ParseRaw<T>(string[] args, bool throwOnHelpRequested = true) | ||
where T : IDeserialize<T> | ||
{ | ||
var deserializer = new Deserializer(args); | ||
var cmd = T.Deserialize(deserializer); | ||
if (throwOnHelpRequested && deserializer.HelpRequested) | ||
{ | ||
var helpText = Deserializer.GetHelpText(SerdeInfoProvider.GetInfo<T>()); | ||
throw new HelpRequestedException(helpText); | ||
} | ||
return cmd; | ||
} | ||
|
||
/// <summary> | ||
/// Try to parse the command line arguments directly into a command object. | ||
/// If an error occurs, the error message will be printed to the console, followed by the generated help text. | ||
/// </summary> | ||
public static bool TryParse<T>(string[] args, IAnsiConsole console, out T cmd) | ||
where T : IDeserialize<T> | ||
{ | ||
return TryParse(args, console, CliParseOptions.Default, out cmd); | ||
} | ||
|
||
/// <summary> | ||
/// Try to parse the command line arguments directly into a command object. | ||
/// Returns true if the command was successfully parsed, false otherwise. If help is | ||
/// requested and <see cref="CliParseOptions.ThrowOnHelpRequested"/> is true, the command is not | ||
/// parsed and false is returned. | ||
/// </summary> | ||
public static bool TryParse<T>(string[] args, IAnsiConsole console, CliParseOptions options, out T cmd) | ||
where T : IDeserialize<T> | ||
{ | ||
var deserializer = new Deserializer(args); | ||
try | ||
{ | ||
cmd = T.Deserialize(deserializer); | ||
if (options.ThrowOnHelpRequested && deserializer.HelpRequested) | ||
{ | ||
var helpText = Deserializer.GetHelpText(SerdeInfoProvider.GetInfo<T>()); | ||
console.Write(helpText); | ||
} | ||
return true; | ||
} | ||
catch (InvalidDeserializeValueException e) when (options.HandleErrors) | ||
{ | ||
console.WriteLine("error: " + e.Message); | ||
var helpText = Deserializer.GetHelpText(SerdeInfoProvider.GetInfo<T>()); | ||
console.Write(helpText); | ||
} | ||
cmd = default!; | ||
return false; | ||
} | ||
} | ||
|
||
public sealed record CliParseOptions | ||
{ | ||
public static readonly CliParseOptions Default = new(); | ||
|
||
/// <summary> | ||
/// If true, exceptions will be caught and handled by printing the error message to the console, | ||
/// followed by the generated help text. | ||
/// </summary> | ||
public bool HandleErrors { get; init; } = true; | ||
|
||
/// <summary> | ||
/// If true, the "-h" and "--help" flags will be parsed and ignored, and help text will be | ||
/// automatically generated. If <see cref="ThrowOnHelpRequested"/> is true, the help text | ||
/// will be passed into the <see cref="HelpRequestedException"/>. If false, and a console | ||
/// output handle is passed to parsing, the help text will be printed to the console. | ||
/// </summary> | ||
public bool HandleHelp { get; init; } = true; | ||
|
||
/// <summary> | ||
/// If true, a <see cref="HelpRequestedException"/> will be thrown when "-h" or "--help" is | ||
/// passed as an argument. | ||
/// </summary> | ||
public bool ThrowOnHelpRequested { get; init; } = true; | ||
using System; | ||
using System.Collections.Generic; | ||
using System.Linq; | ||
using Spectre.Console; | ||
|
||
namespace Serde.CmdLine; | ||
|
||
public static class CmdLine | ||
{ | ||
/// <summary> | ||
/// Try to parse the command line arguments directly into a command object. | ||
/// No errors are handled, so exceptions will be thrown if the arguments are invalid. | ||
/// </summary> | ||
public static T ParseRaw<T>(string[] args) | ||
where T : IDeserialize<T> | ||
{ | ||
var deserializer = new Deserializer(args); | ||
var cmd = T.Deserialize(deserializer); | ||
return cmd; | ||
} | ||
|
||
/// <summary> | ||
/// Try to parse the command line arguments directly into a command object. | ||
/// If an error occurs, the error message will be printed to the console, followed by the generated help text | ||
/// for the top-level command. | ||
/// </summary> | ||
public static bool TryParse<T>(string[] args, IAnsiConsole console, out T cmd) | ||
where T : IDeserialize<T> | ||
{ | ||
try | ||
{ | ||
cmd = ParseRaw<T>(args); | ||
return true; | ||
} | ||
catch (ArgumentSyntaxException ex) | ||
{ | ||
console.WriteLine("error: " + ex.Message); | ||
console.WriteLine(GetHelpText(SerdeInfoProvider.GetInfo<T>())); | ||
cmd = default!; | ||
return false; | ||
} | ||
} | ||
|
||
public static string GetHelpText(ISerdeInfo serdeInfo) | ||
{ | ||
var args = new List<(string Name, string? Description)>(); | ||
var options = new List<(string[] Patterns, string? Name)>(); | ||
string? commandsName = null; | ||
var commands = new List<(string Name, string? Description)>(); | ||
for (int fieldIndex = 0; fieldIndex < serdeInfo.FieldCount; fieldIndex++) | ||
{ | ||
var attrs = serdeInfo.GetFieldAttributes(fieldIndex); | ||
foreach (var attr in attrs) | ||
{ | ||
if (attr is { AttributeType: { Name: nameof(CommandOptionAttribute) }, | ||
ConstructorArguments: [ { Value: string flagNames } ] }) | ||
{ | ||
// Consider nullable boolean fields as flag options. | ||
#pragma warning disable SerdeExperimentalFieldInfo | ||
var optionName = serdeInfo.GetFieldInfo(fieldIndex).Name == "bool?" | ||
#pragma warning restore SerdeExperimentalFieldInfo | ||
? null | ||
: $"<{serdeInfo.GetFieldStringName(fieldIndex)}>"; | ||
options.Add((flagNames.Split('|'), optionName)); | ||
} | ||
else if (attr is { AttributeType: { Name: nameof(CommandParameterAttribute) }, | ||
ConstructorArguments: [ { Value: int paramIndex }, { Value: string paramName } ], | ||
NamedArguments: var namedArgs }) | ||
{ | ||
string? desc = null; | ||
if (namedArgs is [ { MemberName: nameof(CommandParameterAttribute.Description), | ||
TypedValue: { Value: string attrDesc } } ]) | ||
{ | ||
desc = attrDesc; | ||
} | ||
args.Add(($"<{paramName}>", desc)); | ||
} | ||
else if (attr is { AttributeType: { Name: nameof(CommandAttribute) }, | ||
ConstructorArguments: [ { Value: string commandName }] | ||
}) | ||
{ | ||
commandsName ??= commandName; | ||
#pragma warning disable SerdeExperimentalFieldInfo | ||
var info = serdeInfo.GetFieldInfo(fieldIndex); | ||
#pragma warning restore SerdeExperimentalFieldInfo | ||
// The info should be either a nullable wrapper or a union. If it's a | ||
// nullable wrapper, unwrap it first. | ||
if (info.Kind != InfoKind.Union) | ||
{ | ||
#pragma warning disable SerdeExperimentalFieldInfo | ||
info = info.GetFieldInfo(0); | ||
#pragma warning restore SerdeExperimentalFieldInfo | ||
} | ||
var unionInfo = (IUnionSerdeInfo)info; | ||
foreach (var unionField in unionInfo.CaseInfos) | ||
{ | ||
var cmdName = unionField.Name; | ||
string? desc = null; | ||
foreach (var caseAttr in unionField.Attributes) | ||
{ | ||
if (caseAttr is { AttributeType: { Name: nameof(CommandAttribute) }, | ||
ConstructorArguments: [ { Value: string caseCmdName } ], | ||
NamedArguments: var namedCaseArgs }) | ||
{ | ||
cmdName = caseCmdName; | ||
if (namedCaseArgs is [ { MemberName: nameof(CommandAttribute.Description), | ||
TypedValue: { Value: string caseDesc } } ]) | ||
{ | ||
desc = caseDesc; | ||
} | ||
break; | ||
} | ||
} | ||
commands.Add((cmdName, desc)); | ||
} | ||
} | ||
} | ||
} | ||
|
||
const string Indent = " "; | ||
|
||
var commandsString = commands.Count == 0 | ||
? "" | ||
: $""" | ||
|
||
Commands: | ||
{Indent + string.Join(Environment.NewLine + Indent, | ||
commands.Select(c => $"{c.Name}{c.Description?.Prepend(" ") ?? ""}"))} | ||
|
||
"""; | ||
|
||
var argsString = args.Count > 0 | ||
? $""" | ||
|
||
Arguments: | ||
{Indent + string.Join(Environment.NewLine + Indent, | ||
args.Select(a => $"{a.Name}{a.Description?.Prepend(" ") ?? ""}"))} | ||
|
||
""" | ||
: ""; | ||
|
||
var optionsString = options.Count > 0 | ||
? $""" | ||
|
||
Options: | ||
{Indent + string.Join(Environment.NewLine + Indent, | ||
options.Select(o => $"{string.Join(", ", o.Patterns)}{o.Name?.Map(n => " " + n) ?? "" }"))} | ||
|
||
""" | ||
: ""; | ||
|
||
var optionsUsageShortString = options.Count > 0 | ||
? " " + string.Join(" ", | ||
options.Select(o => $"[{string.Join(" | ", o.Patterns)}{o.Name?.Map(n => " " + n) ?? "" }]")) | ||
: ""; | ||
|
||
var topLevelName = serdeInfo.Name; | ||
string topLevelDesc = ""; | ||
foreach (var attr in serdeInfo.Attributes) | ||
{ | ||
if (attr is { AttributeType: { Name: nameof(CommandAttribute) }, | ||
ConstructorArguments: [ { Value: string name } ], | ||
NamedArguments: var namedArgs }) | ||
{ | ||
topLevelName = name; | ||
if (namedArgs is [ { MemberName: nameof(CommandAttribute.Description), | ||
TypedValue: { Value: string desc } } ]) | ||
{ | ||
topLevelDesc = Environment.NewLine + desc + Environment.NewLine; | ||
} | ||
break; | ||
} | ||
} | ||
|
||
var argsShortString = args.Count > 0 | ||
? " " + string.Join(" ", args.Select(a => a.Name)) | ||
: ""; | ||
|
||
return $""" | ||
Usage: {topLevelName}{optionsUsageShortString}{commandsName?.Map(n => $" <{n}>") ?? ""}{argsShortString} | ||
{topLevelDesc}{argsString}{optionsString}{commandsString} | ||
"""; | ||
} | ||
|
||
|
||
} |
Oops, something went wrong.