Replace implementation of `ArgParser` using `System.CommandLine`

This commit is contained in:
YoshiRulz 2024-09-06 00:02:23 +10:00 committed by James Groom
parent bf5deafdab
commit d908d77186
4 changed files with 178 additions and 202 deletions

View File

@ -31,6 +31,7 @@
<PackageVersion Include="SQLitePCLRaw.provider.e_sqlite3" Version="2.1.8" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" /><!-- don't forget to update .stylecop.json at the same time -->
<PackageVersion Include="System.Collections.Immutable" Version="8.0.0" />
<PackageVersion Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<PackageVersion Include="System.ComponentModel.Annotations" Version="5.0.0" />
<PackageVersion Include="System.Drawing.Common" Version="6.0.0" />
<PackageVersion Include="System.Drawing.Primitives" Version="4.3.0" />

View File

@ -76,6 +76,7 @@
(fetchNuGet { pname = "System.Buffers"; version = "4.4.0"; sha256 = "183f8063w8zqn99pv0ni0nnwh7fgx46qzxamwnans55hhs2l0g19"; })
(fetchNuGet { pname = "System.Buffers"; version = "4.5.1"; sha256 = "04kb1mdrlcixj9zh1xdi5as0k0qi8byr5mi3p3jcxx72qz93s2y3"; })
(fetchNuGet { pname = "System.Collections.Immutable"; version = "8.0.0"; sha256 = "0z53a42zjd59zdkszcm7pvij4ri5xbb8jly9hzaad9khlf69bcqp"; })
(fetchNuGet { pname = "System.CommandLine"; version = "2.0.0-beta4.22272.1"; sha256 = "1iy5hwwgvx911g3yq65p4zsgpy08w4qz9j3h0igcf7yci44vw8yd"; })
(fetchNuGet { pname = "System.ComponentModel.Annotations"; version = "5.0.0"; sha256 = "021h7x98lblq9avm1bgpa4i31c2kgsa7zn4sqhxf39g087ar756j"; })
(fetchNuGet { pname = "System.Diagnostics.DiagnosticSource"; version = "5.0.0"; sha256 = "0phd2qizshjvglhzws1jd0cq4m54gscz4ychzr3x6wbgl4vvfrga"; })
(fetchNuGet { pname = "System.Drawing.Common"; version = "6.0.0"; sha256 = "02n8rzm58dac2np8b3xw8ychbvylja4nh6938l5k2fhyn40imlgz"; })

View File

@ -1,233 +1,206 @@
#nullable enable
using System.Collections.Generic;
using System.CommandLine;
using System.CommandLine.Builder;
using System.CommandLine.Parsing;
using System.Linq;
using System.IO;
using System.Net.Sockets;
using BizHawk.Common.CollectionExtensions;
using BizHawk.Common.StringExtensions;
namespace BizHawk.Client.Common
{
/// <summary>
/// Parses command line flags from a string array into various instance fields.
/// </summary>
/// <remarks>
/// If a flag is given multiple times, the last is taken.<br/>
/// If a flag that isn't recognised is given, it is parsed as a filename. As noted above, the last filename is taken.
/// </remarks>
/// <summary>Parses command-line flags into a <see cref="ParsedCLIFlags"/> struct.</summary>
public static class ArgParser
{
/// <exception cref="ArgParserException"><c>--socket_ip</c> passed without specifying <c>--socket_port</c> or vice-versa</exception>
private sealed class BespokeOption<T> : Option<T>
{
public BespokeOption(string name)
: base(name) {}
public BespokeOption(string name, string description)
: base(name: name, description: description) {}
}
private static readonly Argument<string?> ArgumentRomFilePath = new("rom", () => null);
private static readonly BespokeOption<string?> OptionAVDumpAudioSync = new(name: "--audiosync", description: "bool; `true` is the only truthy value, all else falsey; if not set, uses remembered state from config");
private static readonly BespokeOption<int?> OptionAVDumpEndAtFrame = new("--dump-length");
private static readonly BespokeOption<string?> OptionAVDumpFrameList = new("--dump-frames"); // desc added in static ctor
private static readonly BespokeOption<string?> OptionAVDumpName = new("--dump-name"); // desc added in static ctor
private static readonly BespokeOption<bool> OptionAVDumpQuitWhenDone = new("--dump-close");
private static readonly BespokeOption<string?> OptionAVDumpType = new("--dump-type"); // desc added in static ctor
private static readonly BespokeOption<string?> OptionConfigFilePath = new("--config");
private static readonly BespokeOption<string?> OptionHTTPClientURIGET = new("--url_get");
private static readonly BespokeOption<string?> OptionHTTPClientURIPOST = new("--url_post");
private static readonly BespokeOption<bool> OptionLaunchChromeless = new(name: "--chromeless", description: "chrome is never shown, even in windowed mode");
private static readonly BespokeOption<bool> OptionLaunchFullscreen = new("--fullscreen");
private static readonly BespokeOption<int?> OptionLoadQuicksaveSlot = new("--load-slot");
private static readonly BespokeOption<string?> OptionLoadSavestateFilePath = new("--load-state");
private static readonly BespokeOption<string?> OptionLuaFilePath = new("--lua"); // desc added in static ctor
private static readonly BespokeOption<string?> OptionMMFPath = new("--mmf");
private static readonly BespokeOption<string?> OptionMovieFilePath = new("--movie");
private static readonly BespokeOption<string?> OptionOpenExternalTool = new(name: "--open-ext-tool-dll", description: "the first ext. tool from ExternalToolManager.ToolStripMenu which satisfies both of these will be opened: 1) available (no load errors, correct system/rom, etc.) and 2) dll path matches given string; or dll filename matches given string with or without `.dll`");
private static readonly BespokeOption<bool> OptionOpenLuaConsole = new("--luaconsole");
private static readonly BespokeOption<bool> OptionQueryAppVersion = new("--version");
private static readonly BespokeOption<string?> OptionSocketServerIP = new("--socket_ip"); // desc added in static ctor
private static readonly BespokeOption<ushort?> OptionSocketServerPort = new("--socket_port"); // desc added in static ctor
private static readonly BespokeOption<bool> OptionSocketServerUseUDP = new("--socket_udp"); // desc added in static ctor
private static readonly BespokeOption<string?> OptionUserdataUnparsedPairs = new(name: "--userdata", description: "pairs in the format `k1:v1;k2:v2` (mind your shell escape sequences); if the value is `true`/`false` it's interpreted as a boolean, if it's a valid 32-bit signed integer e.g. `-1234` it's interpreted as such, if it's a valid 32-bit float e.g. `12.34` it's interpreted as such, else it's interpreted as a string");
private static readonly Parser Parser;
static ArgParser()
{
OptionAVDumpFrameList.Description = $"comma-separated list of integers, indices of frames which should be included in the A/V dump (encoding); implies `--{OptionAVDumpEndAtFrame.Name}=<end>` where `<end>` is the highest frame listed";
OptionAVDumpName.Description = $"ignored unless `--{OptionAVDumpType.Name}` also passed";
OptionAVDumpType.Description = $"ignored unless `--{OptionAVDumpName.Name}` also passed";
OptionLuaFilePath.Description = $"implies `--{OptionOpenLuaConsole.Name}`";
OptionSocketServerIP.Description = $"must be paired with `--{OptionSocketServerPort.Name}`";
OptionSocketServerPort.Description = $"must be paired with `--{OptionSocketServerIP.Name}`";
OptionSocketServerUseUDP.Description = $"ignored unless `--{OptionSocketServerIP.Name} --{OptionSocketServerPort.Name}` also passed";
RootCommand root = new();
root.Add(ArgumentRomFilePath);
// `--help` uses this order, so keep alphabetised by flag
root.Add(/* --audiosync */ OptionAVDumpAudioSync);
root.Add(/* --chromeless */ OptionLaunchChromeless);
root.Add(/* --config */ OptionConfigFilePath);
root.Add(/* --dump-close */ OptionAVDumpQuitWhenDone);
root.Add(/* --dump-frames */ OptionAVDumpFrameList);
root.Add(/* --dump-length */ OptionAVDumpEndAtFrame);
root.Add(/* --dump-name */ OptionAVDumpName);
root.Add(/* --dump-type */ OptionAVDumpType);
root.Add(/* --fullscreen */ OptionLaunchFullscreen);
root.Add(/* --load-slot */ OptionLoadQuicksaveSlot);
root.Add(/* --load-state */ OptionLoadSavestateFilePath);
root.Add(/* --lua */ OptionLuaFilePath);
root.Add(/* --luaconsole */ OptionOpenLuaConsole);
root.Add(/* --mmf */ OptionMMFPath);
root.Add(/* --movie */ OptionMovieFilePath);
root.Add(/* --open-ext-tool-dll */ OptionOpenExternalTool);
root.Add(/* --socket_ip */ OptionSocketServerIP);
root.Add(/* --socket_port */ OptionSocketServerPort);
root.Add(/* --socket_udp */ OptionSocketServerUseUDP);
root.Add(/* --url_get */ OptionHTTPClientURIGET);
root.Add(/* --url_post */ OptionHTTPClientURIPOST);
root.Add(/* --userdata */ OptionUserdataUnparsedPairs);
root.Add(/* --version */ OptionQueryAppVersion);
Parser = new CommandLineBuilder(root)
// .UseVersionOption() // "cannot be combined with other arguments" which is fair enough but `--config` is crucial on NixOS
// .UseHelp() //TODO
// .UseEnvironmentVariableDirective() // useless
.UseParseDirective()
.UseSuggestDirective()
// .RegisterWithDotnetSuggest() // intended for dotnet tools
// .UseTypoCorrections() // we're only using the parser, and I guess this only works with the full buy-in
// .UseParseErrorReporting() // we're only using the parser, and I guess this only works with the full buy-in
// .UseExceptionHandler() // we're only using the parser, so nothing should be throwing
// .CancelOnProcessTermination() // we're only using the parser, so there's not really anything to cancel
.Build();
}
/// <exception cref="ArgParserException">parsing failure, or invariant broken</exception>
public static void ParseArguments(out ParsedCLIFlags parsed, string[] args)
{
string? cmdLoadSlot = null;
string? cmdLoadState = null;
string? cmdConfigFile = null;
string? cmdMovie = null;
string? cmdDumpType = null;
parsed = default;
var result = Parser.Parse(args);
if (result.Errors.Count is not 0)
{
// write all to stdout and show first in modal dialog (done in `catch` block in `Program`)
Console.WriteLine("failed to parse command-line arguments:");
foreach (var error in result.Errors) Console.WriteLine(error.Message);
throw new ArgParserException($"failed to parse command-line arguments: {result.Errors[0].Message}");
}
var autoDumpLength = result.GetValueForOption(OptionAVDumpEndAtFrame);
HashSet<int>? currAviWriterFrameList = null;
int? autoDumpLength = null;
bool? printVersion = null;
string? cmdDumpName = null;
bool? autoCloseOnDump = null;
bool? chromeless = null;
bool? startFullscreen = null;
string? luaScript = null;
bool? luaConsole = null;
ushort? socketPort = null;
string? socketIP = null;
string? mmfFilename = null;
string? urlGet = null;
string? urlPost = null;
bool? audiosync = null;
string? openExtToolDll = null;
var socketProtocol = ProtocolType.Tcp;
List<(string Key, string Value)>? userdataUnparsedPairs = null;
string? cmdRom = null;
for (var i = 0; i < args.Length; i++)
if (result.GetValueForOption(OptionAVDumpFrameList) is string list)
{
var arg = args[i];
if (arg == ">")
{
// For some reason sometimes visual studio will pass this to us on the commandline. it makes no sense.
var stdout = args[++i];
Console.SetOut(new StreamWriter(stdout));
continue;
}
var argDowncased = arg.ToLowerInvariant();
if (argDowncased.StartsWithOrdinal("--load-slot="))
{
cmdLoadSlot = argDowncased.Substring(argDowncased.IndexOf('=') + 1);
}
else if (argDowncased.StartsWithOrdinal("--load-state="))
{
cmdLoadState = arg.Substring(arg.IndexOf('=') + 1);
}
else if (argDowncased.StartsWithOrdinal("--config="))
{
cmdConfigFile = arg.Substring(arg.IndexOf('=') + 1);
}
else if (argDowncased.StartsWithOrdinal("--movie="))
{
cmdMovie = arg.Substring(arg.IndexOf('=') + 1);
}
else if (argDowncased.StartsWithOrdinal("--dump-type="))
{
// ignored unless `--dump-name` also passed
cmdDumpType = argDowncased.Substring(argDowncased.IndexOf('=') + 1);
}
else if (argDowncased.StartsWithOrdinal("--dump-frames="))
{
// comma-separated list of integers, indices of frames which should be included in the A/V dump (encoding)
string list = argDowncased.Substring(argDowncased.IndexOf('=') + 1);
currAviWriterFrameList = new();
currAviWriterFrameList.AddRange(list.Split(',').Select(int.Parse));
// automatically set dump length to maximum frame
autoDumpLength = currAviWriterFrameList.Max();
autoDumpLength ??= currAviWriterFrameList.Max();
}
else if (argDowncased.StartsWithOrdinal("--version"))
var luaScript = result.GetValueForOption(OptionLuaFilePath);
var luaConsole = luaScript is not null || result.GetValueForOption(OptionOpenLuaConsole);
var socketIP = result.GetValueForOption(OptionSocketServerIP);
var socketPort = result.GetValueForOption(OptionSocketServerPort);
var socketAddress = socketIP is null && socketPort is null
? ((string, ushort)?) null // don't bother
: socketIP is not null && socketPort is not null
? (socketIP, socketPort.Value)
: throw new ArgParserException("Socket server needs both --socket_ip and --socket_port. Socket server was not started");
var httpClientURIGET = result.GetValueForOption(OptionHTTPClientURIGET);
var httpClientURIPOST = result.GetValueForOption(OptionHTTPClientURIPOST);
var httpAddresses = httpClientURIGET is null && httpClientURIPOST is null
? ((string?, string?)?) null // don't bother
: (httpClientURIGET, httpClientURIPOST);
var audiosync = result.GetValueForOption(OptionAVDumpAudioSync)?.Equals("true", StringComparison.OrdinalIgnoreCase);
List<(string Key, string Value)>? userdataUnparsedPairs = null;
if (result.GetValueForOption(OptionUserdataUnparsedPairs) is string list1)
{
printVersion = true;
}
else if (argDowncased.StartsWithOrdinal("--dump-name="))
{
// ignored unless `--dump-type` also passed
cmdDumpName = arg.Substring(arg.IndexOf('=') + 1);
}
else if (argDowncased.StartsWithOrdinal("--dump-length="))
{
var len = int.TryParse(argDowncased.Substring(argDowncased.IndexOf('=') + 1), out var i1) ? i1 : default;
autoDumpLength = len;
}
else if (argDowncased.StartsWithOrdinal("--dump-close"))
{
autoCloseOnDump = true;
}
else if (argDowncased.StartsWithOrdinal("--chromeless"))
{
// chrome is never shown, even in windowed mode
chromeless = true;
}
else if (argDowncased.StartsWithOrdinal("--fullscreen"))
{
startFullscreen = true;
}
else if (argDowncased.StartsWithOrdinal("--lua="))
{
luaScript = arg.Substring(arg.IndexOf('=') + 1);
// implies `--luaconsole`
luaConsole = true;
}
else if (argDowncased.StartsWithOrdinal("--luaconsole"))
{
luaConsole = true;
}
else if (argDowncased.StartsWithOrdinal("--socket_port="))
{
// must be paired with `--socket_ip`
var port = ushort.TryParse(arg.Substring(14), out var i1) ? i1 : (ushort) 0;
if (port > 0) socketPort = port;
}
else if (argDowncased.StartsWithOrdinal("--socket_ip="))
{
// must be paired with `--socket_port`
socketIP = argDowncased.Substring(argDowncased.IndexOf('=') + 1);
}
else if (argDowncased.StartsWithOrdinal("--socket_udp"))
{
// ignored unless `--socket_ip --socket_port` also passed
socketProtocol = ProtocolType.Udp;
}
else if (argDowncased.StartsWithOrdinal("--mmf="))
{
mmfFilename = arg.Substring(arg.IndexOf('=') + 1);
}
else if (argDowncased.StartsWithOrdinal("--url_get="))
{
urlGet = arg.Substring(arg.IndexOf('=') + 1);
}
else if (argDowncased.StartsWithOrdinal("--url_post="))
{
urlPost = arg.Substring(arg.IndexOf('=') + 1);
}
else if (argDowncased.StartsWithOrdinal("--audiosync="))
{
// `true` is the only truthy value, all else falsey
// if not set, uses remembered state from config
audiosync = argDowncased.Substring(argDowncased.IndexOf('=') + 1) == "true";
}
else if (argDowncased.StartsWithOrdinal("--open-ext-tool-dll="))
{
// the first ext. tool from ExternalToolManager.ToolStripMenu which satisfies both of these will be opened:
// - available (no load errors, correct system/rom, etc.)
// - dll path matches given string; or dll filename matches given string with or without `.dll`
openExtToolDll = arg.Substring(20);
}
else if (argDowncased.StartsWithOrdinal("--userdata="))
{
// pairs in the format `k1:v1;k2:v2` (mind your shell escape sequences)
// if the value is `true`/`false` it's interpreted as a boolean,
// if it's a valid 32-bit signed integer e.g. `-1234` it's interpreted as such, if it's a valid 32-bit float e.g. `12.34` it's interpreted as such,
// else it's interpreted as a string
userdataUnparsedPairs = new();
foreach (var s in arg.Substring(11).Split(';'))
foreach (var s in list1.Split(';'))
{
var iColon = s.IndexOf(':');
if (iColon is -1) throw new ArgParserException("malformed userdata (';' without ':')");
userdataUnparsedPairs.Add((s.Substring(startIndex: 0, length: iColon), s.Substring(iColon + 1)));
}
}
else
{
cmdRom = arg;
}
}
var httpAddresses = urlGet == null && urlPost == null
? ((string?, string?)?) null // don't bother
: (urlGet, urlPost);
(string, ushort)? socketAddress;
if (socketIP == null && socketPort == null)
{
socketAddress = null; // don't bother
}
else if (socketIP == null || socketPort == null)
{
throw new ArgParserException("Socket server needs both --socket_ip and --socket_port. Socket server was not started");
}
else
{
socketAddress = (socketIP, socketPort.Value);
}
parsed = new ParsedCLIFlags(
cmdLoadSlot: cmdLoadSlot is null ? null : int.Parse(cmdLoadSlot),
cmdLoadState: cmdLoadState,
cmdConfigFile: cmdConfigFile,
cmdMovie: cmdMovie,
cmdDumpType: cmdDumpType,
parsed = new(
cmdLoadSlot: result.GetValueForOption(OptionLoadQuicksaveSlot),
cmdLoadState: result.GetValueForOption(OptionLoadSavestateFilePath),
cmdConfigFile: result.GetValueForOption(OptionConfigFilePath),
cmdMovie: result.GetValueForOption(OptionMovieFilePath),
cmdDumpType: result.GetValueForOption(OptionAVDumpType),
currAviWriterFrameList: currAviWriterFrameList,
autoDumpLength: autoDumpLength ?? 0,
printVersion: printVersion ?? false,
cmdDumpName: cmdDumpName,
autoCloseOnDump: autoCloseOnDump ?? false,
chromeless: chromeless ?? false,
startFullscreen: startFullscreen ?? false,
printVersion: result.GetValueForOption(OptionQueryAppVersion),
cmdDumpName: result.GetValueForOption(OptionAVDumpName),
autoCloseOnDump: result.GetValueForOption(OptionAVDumpQuitWhenDone),
chromeless: result.GetValueForOption(OptionLaunchChromeless),
startFullscreen: result.GetValueForOption(OptionLaunchFullscreen),
luaScript: luaScript,
luaConsole: luaConsole ?? false,
luaConsole: luaConsole,
socketAddress: socketAddress,
mmfFilename: mmfFilename,
mmfFilename: result.GetValueForOption(OptionMMFPath),
httpAddresses: httpAddresses,
audiosync: audiosync,
openExtToolDll: openExtToolDll,
socketProtocol: socketProtocol,
openExtToolDll: result.GetValueForOption(OptionOpenExternalTool),
socketProtocol: result.GetValueForOption(OptionSocketServerUseUDP) ? ProtocolType.Udp : ProtocolType.Tcp,
userdataUnparsedPairs: userdataUnparsedPairs,
cmdRom: cmdRom
cmdRom: result.GetValueForArgument(ArgumentRomFilePath)
);
}

View File

@ -13,6 +13,7 @@
<PackageReference Include="SharpCompress" />
<PackageReference Include="SQLitePCLRaw.provider.e_sqlite3" />
<PackageReference Include="System.Drawing.Common" PrivateAssets="all" />
<PackageReference Include="System.CommandLine" PrivateAssets="all" />
<ProjectReference Include="$(ProjectDir)../BizHawk.Emulation.Cores/BizHawk.Emulation.Cores.csproj" />
<ProjectReference Include="$(ProjectDir)../BizHawk.Bizware.Graphics/BizHawk.Bizware.Graphics.csproj" />
<EmbeddedResource Include="Resources/**/*" />