Skip to content

Commit

Permalink
Remove built-in help/error handling in CmdLine (#154)
Browse files Browse the repository at this point in the history
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
agocke authored Aug 4, 2024
1 parent 73d2c7d commit d1f6dd2
Show file tree
Hide file tree
Showing 8 changed files with 244 additions and 281 deletions.
18 changes: 18 additions & 0 deletions src/Serde.CmdLine/ArgumentSyntaxException.cs
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)
{
}
}
272 changes: 185 additions & 87 deletions src/Serde.CmdLine/CmdLine.cs
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}
""";
}


}
Loading

0 comments on commit d1f6dd2

Please sign in to comment.