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); + } + } +}