diff --git a/src/BizHawk.Client.Common/ArgParser.cs b/src/BizHawk.Client.Common/ArgParser.cs
index c7e8393ac56..b2910494d17 100644
--- a/src/BizHawk.Client.Common/ArgParser.cs
+++ b/src/BizHawk.Client.Common/ArgParser.cs
@@ -2,7 +2,10 @@
using System.Collections.Generic;
using System.CommandLine;
+using System.CommandLine.Help;
+using System.CommandLine.Invocation;
using System.CommandLine.Parsing;
+using System.IO;
using System.Linq;
using System.Net.Sockets;
@@ -15,10 +18,30 @@ namespace BizHawk.Client.Common
/// Parses command-line flags into a struct.
public static class ArgParser
{
- private static readonly Argument ArgumentRomFilePath = new("rom")
+ private sealed class HelpSedAction(HelpAction stockAction) : SynchronousCommandLineAction
{
- DefaultValueFactory = _ => null,
- Description = "path; if specified, the file will be loaded the same way as it would be from `File` > `Open...`; this argument can and should be given LAST despite what it says at the top of --help",
+ public override int Invoke(ParseResult parseResult)
+ {
+ var outerOutput = parseResult.InvocationConfiguration.Output;
+ using StringWriter innerOutput = new();
+ parseResult.InvocationConfiguration.Output = innerOutput;
+ var result = stockAction.Invoke(parseResult);
+ var dollarZero = OSTailoredCode.IsUnixHost
+#if true
+ ? "./EmuHawkMono.sh"
+#else //TODO for .NET Core: see https://github.com/dotnet/runtime/issues/101837
+ ? Environment.GetCommandLineArgs()[0].SubstringAfterLast('/')
+#endif
+ : Environment.GetCommandLineArgs()[0].SubstringAfterLast('\\');
+ outerOutput.Write(innerOutput.ToString().Replace("EmuHawk", dollarZero)
+ .Replace("[...] [options]", "[option...] [rom]"));
+ return result;
+ }
+ }
+
+ private static readonly Argument ArgumentRomFilePath = new("rom")
+ {
+ Description = "path; if specified, the file will be loaded the same way as it would be from `File` > `Open...`",
};
private static readonly Option OptionAVDumpAudioSync = new("--audiosync")
@@ -134,8 +157,9 @@ private static RootCommand GetRootCommand()
RootCommand root = new($"{
(string.IsNullOrEmpty(VersionInfo.CustomBuildString) ? "EmuHawk" : VersionInfo.CustomBuildString)
}, a multi-system emulator frontend\n{VersionInfo.GetEmuVersion()}");
- root.Add(ArgumentRomFilePath);
root.Options.RemoveAll(option => option is VersionOption); // we have our own version command
+ var helpOption = root.Options.OfType().First();
+ helpOption.Action = new HelpSedAction((HelpAction) helpOption.Action!);
// `--help` uses this order, so keep alphabetised by flag
root.Add(/* --audiosync */ OptionAVDumpAudioSync);
@@ -163,6 +187,7 @@ private static RootCommand GetRootCommand()
root.Add(/* --userdata */ OptionUserdataUnparsedPairs);
root.Add(/* --version */ OptionQueryAppVersion);
+ root.Add(ArgumentRomFilePath);
return root;
}
@@ -177,17 +202,17 @@ private static void EnsureConsole()
/// exit code, or if should not exit
/// parsing failure, or invariant broken
- public static int? ParseArguments(out ParsedCLIFlags parsed, string[] args)
+ public static int? ParseArguments(out ParsedCLIFlags parsed, string[] args, bool fromUnitTest = false)
{
parsed = default;
- if (args.Length is not 0) Console.Error.WriteLine($"parsing command-line flags: {string.Join(" ", args)}");
+ if (!fromUnitTest && args.Length is not 0) Console.Error.WriteLine($"parsing command-line flags: {string.Join(" ", args)}");
var rootCommand = GetRootCommand();
var result = CommandLineParser.Parse(rootCommand, args);
if (result.Errors.Count is not 0)
{
// generate useful commandline error output
EnsureConsole();
- result.Invoke();
+ if (!fromUnitTest) result.Invoke();
// show first error in modal dialog (done in `catch` block in `Program`)
throw new ArgParserException($"failed to parse command-line arguments: {result.Errors[0].Message}");
}
@@ -195,16 +220,25 @@ private static void EnsureConsole()
{
// means e.g. `./EmuHawkMono.sh --help` was passed, run whatever behaviour it normally has
EnsureConsole();
- return result.Invoke();
+ return fromUnitTest ? 0 : result.Invoke();
}
if (result.GetValue(OptionQueryAppVersion))
{
// means e.g. `./EmuHawkMono.sh --version` was passed, so print that and exit immediately
EnsureConsole();
- Console.WriteLine(VersionInfo.GetEmuVersion());
+ if (!fromUnitTest) Console.WriteLine(VersionInfo.GetEmuVersion());
return 0;
}
+ var unmatchedArguments = result.GetValue(ArgumentRomFilePath) ?? [ ];
+ if (unmatchedArguments.Length >= 2)
+ {
+ var foundFlagLike = unmatchedArguments.FirstOrDefault(static s => s.StartsWith("--"));
+ throw new ArgParserException(foundFlagLike is null
+ ? "Multiple rom paths provided. (Did you delete half of a flag? Or forget the \"--\" of one?)"
+ : $"Unrecognised flag(s): \"{foundFlagLike}\"");
+ }
+
var autoDumpLength = result.GetValue(OptionAVDumpEndAtFrame);
HashSet? currAviWriterFrameList = null;
if (result.GetValue(OptionAVDumpFrameList) is string list)
@@ -268,11 +302,17 @@ private static void EnsureConsole()
openExtToolDll: result.GetValue(OptionOpenExternalTool),
socketProtocol: result.GetValue(OptionSocketServerUseUDP) ? ProtocolType.Udp : ProtocolType.Tcp,
userdataUnparsedPairs: userdataUnparsedPairs,
- cmdRom: result.GetValue(ArgumentRomFilePath)
- );
+ cmdRom: unmatchedArguments.LastOrDefault()); // `unmatchedArguments.Length` must be 0 or 1 at this point, but in case we change how that's handled, 'last' here preserves the behaviour from the old hand-rolled parser
return null;
}
+ internal static void RunHelpActionForUnitTest(TextWriter output)
+ {
+ var result = CommandLineParser.Parse(GetRootCommand(), [ "--help" ]);
+ result.InvocationConfiguration.Output = output;
+ result.Invoke();
+ }
+
public sealed class ArgParserException : Exception
{
public ArgParserException(string message) : base(message) {}
diff --git a/src/BizHawk.Tests.Client.Common/ArgParserTests.cs b/src/BizHawk.Tests.Client.Common/ArgParserTests.cs
new file mode 100644
index 00000000000..fcd20737cea
--- /dev/null
+++ b/src/BizHawk.Tests.Client.Common/ArgParserTests.cs
@@ -0,0 +1,40 @@
+using System.IO;
+using System.Linq;
+
+using BizHawk.Client.Common;
+
+namespace BizHawk.Tests.Client.Common
+{
+ [TestClass]
+ public sealed class ArgParserTests
+ {
+ [DataRow("--config=config.json", "--help")]
+ [DataRow("--config=config.json", "--version")]
+ [TestMethod]
+ public void TestConfigWithHelpOrVersion(params string[] args)
+ => Assert.AreEqual(0, ArgParser.ParseArguments(out _, args, fromUnitTest: true));
+
+ [TestMethod]
+ public void TestHelpSaysPassFlagsFirst()
+ {
+ using StringWriter output = new();
+ ArgParser.RunHelpActionForUnitTest(output);
+ var outputLines = output.ToString().Split('\n');
+ var usageLine = outputLines[outputLines.Index().First(tuple => tuple.Item.Contains("Usage:")).Index + 1].ToUpperInvariant();
+ Assert.IsTrue(usageLine.IndexOf("OPTION") < usageLine.IndexOf("ROM"));
+ }
+
+ [DataRow("rom.nes", "--nonexistent")]
+ [DataRow("--nonexistent", "rom.nes")]
+ [DataRow("--nonexistent", "--", "rom.nes")]
+ [TestMethod]
+ public void TestWithNonexistent(params string[] args)
+ {
+ int? exitCode = null;
+ var e = Assert.ThrowsExactly(() => exitCode = ArgParser.ParseArguments(out _, args, fromUnitTest: true));
+ Assert.AreNotEqual(0, exitCode ?? 1);
+ Assert.Contains(substring: "Unrecog", e.Message);
+ Assert.Contains(substring: "--nonexistent", e.Message);
+ }
+ }
+}