diff --git a/src/System.CommandLine.Tests/CompilationTests.cs b/src/System.CommandLine.Tests/CompilationTests.cs index a5daaeec0a..59bf8d652c 100644 --- a/src/System.CommandLine.Tests/CompilationTests.cs +++ b/src/System.CommandLine.Tests/CompilationTests.cs @@ -6,6 +6,7 @@ using System.CommandLine.Suggest; using System.CommandLine.Tests.Utility; using System.IO; +using System.Runtime.InteropServices; using System.Text; using FluentAssertions; using Microsoft.DotNet.PlatformAbstractions; @@ -42,6 +43,114 @@ public void App_referencing_system_commandline_can_be_compiled_ahead_of_time() } } + [ReleaseBuildOnlyFact] + public void RootCommand_name_falls_back_to_the_AppContext_value_when_hosted_as_a_native_library() + { + // When System.CommandLine is hosted inside a NativeAOT shared library there is no + // managed entry point, so Environment.GetCommandLineArgs() returns an empty array. + // RootCommand must then fall back to the executable name injected into AppContext by + // the build targets (the assembly name) rather than throwing. This exercises that + // path end-to-end through a real native library build. + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + // TODO: Re-enable OSX validation when TFM is upgraded to net8.0. + return; + } + + var workingDirectory = Path.Combine(Directory.GetCurrentDirectory(), "TestApps", "NativeLibrary"); + var publishDirectory = Path.Combine(Path.GetTempPath(), "scl-nativelib-" + Guid.NewGuid().ToString("N")); + string rId = GetPortableRuntimeIdentifier(); + + Process.RunToCompletion( + DotnetMuxer.Path.FullName, + $"clean -c Release -r {rId}", + workingDirectory: workingDirectory); + + var stdOut = new StringBuilder(); + var stdErr = new StringBuilder(); + + try + { + var exitCode = Process.RunToCompletion( + DotnetMuxer.Path.FullName, + string.Format( + "publish -c Release -r {0} --self-contained -o \"{1}\" -p:SystemCommandLineDllPath=\"{2}\" -p:TreatWarningsAsErrors=true", + rId, + publishDirectory, + _systemCommandLineDllPath), + s => + { + _output.WriteLine(s); + stdOut.Append(s); + }, + s => + { + _output.WriteLine(s); + stdErr.Append(s); + }, + workingDirectory); + + string publishOutput = $"{Environment.NewLine}STDOUT:{Environment.NewLine}{stdOut}{Environment.NewLine}STDERR:{Environment.NewLine}{stdErr}"; + + stdOut.ToString().Should().NotContain(": error CS", "the native library should compile cleanly. Publish output:{0}", publishOutput); + exitCode.Should().Be(0, "the native library should publish successfully. Publish output:{0}", publishOutput); + + string nativeLibraryPath = Path.Combine(publishDirectory, NativeLibraryFileName("NativeLibrary")); + File.Exists(nativeLibraryPath).Should().BeTrue($"the published native library should exist at {nativeLibraryPath}"); + + string executableName = InvokeGetExecutableName(nativeLibraryPath); + + // Equality to the assembly name proves the AppContext fallback was taken: had + // GetCommandLineArgs() been non-empty, the name would derive from the host path. + executableName.Should().Be("NativeLibrary"); + } + finally + { + if (Directory.Exists(publishDirectory)) + { + Directory.Delete(publishDirectory, recursive: true); + } + } + } + + private static string NativeLibraryFileName(string assemblyName) => + OperatingSystem.IsWindows() ? $"{assemblyName}.dll" + : OperatingSystem.IsMacOS() ? $"lib{assemblyName}.dylib" + : $"lib{assemblyName}.so"; + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int GetExecutableNameDelegate(IntPtr buffer, int bufferLength); + + private static string InvokeGetExecutableName(string nativeLibraryPath) + { + IntPtr handle = NativeLibrary.Load(nativeLibraryPath); + + try + { + IntPtr export = NativeLibrary.GetExport(handle, "get_executable_name"); + var getExecutableName = Marshal.GetDelegateForFunctionPointer(export); + + int length = getExecutableName(IntPtr.Zero, 0); + byte[] buffer = new byte[length]; + + GCHandle pinned = GCHandle.Alloc(buffer, GCHandleType.Pinned); + try + { + getExecutableName(pinned.AddrOfPinnedObject(), length); + } + finally + { + pinned.Free(); + } + + return Encoding.UTF8.GetString(buffer); + } + finally + { + NativeLibrary.Free(handle); + } + } + private void PublishAndValidate(string appName, string warningText, string additionalArgs = null) { var stdOut = new StringBuilder(); @@ -86,7 +195,7 @@ private void PublishAndValidate(string appName, string warningText, string addit private static string GetPortableRuntimeIdentifier() { string osPart = OperatingSystem.IsWindows() ? "win" : (OperatingSystem.IsMacOS() ? "osx" : "linux"); - return $"{osPart}-{RuntimeEnvironment.RuntimeArchitecture}"; + return $"{osPart}-{Microsoft.DotNet.PlatformAbstractions.RuntimeEnvironment.RuntimeArchitecture}"; } } diff --git a/src/System.CommandLine.Tests/ParserTests.RootCommandAndArg0.cs b/src/System.CommandLine.Tests/ParserTests.RootCommandAndArg0.cs index 7776ff0c32..2b4bece456 100644 --- a/src/System.CommandLine.Tests/ParserTests.RootCommandAndArg0.cs +++ b/src/System.CommandLine.Tests/ParserTests.RootCommandAndArg0.cs @@ -2,8 +2,10 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.CommandLine.Parsing; +using System.IO; using System.Linq; using FluentAssertions; +using FluentAssertions.Execution; using Xunit; namespace System.CommandLine.Tests; @@ -15,18 +17,29 @@ public partial class RootCommandAndArg0 [Fact] public void When_parsing_a_string_array_a_root_command_can_be_omitted_from_the_parsed_args() { + var option = new Option("-x"); var command = new Command("outer") { new Command("inner") { - new Option("-x") + option } }; var result1 = command.Parse(Split("inner -x hello")); var result2 = command.Parse(Split("outer inner -x hello")); + using var _ = new AssertionScope(); + result1.Diagram().Should().Be(result2.Diagram()); + + foreach (var result in new[] { result1, result2 }) + { + result.Errors.Should().BeEmpty(); + result.RootCommandResult.Command.Name.Should().Be("outer"); + result.CommandResult.Command.Name.Should().Be("inner"); + result.GetValue(option).Should().Be("hello"); + } } [Fact] @@ -40,8 +53,6 @@ public void When_parsing_a_string_array_input_then_a_full_path_to_an_executable_ } }; - command.Parse(Split("inner -x hello")).Errors.Should().BeEmpty(); - var parserResult = command.Parse(Split($"\"{RootCommand.ExecutablePath}\" inner -x hello")); parserResult .Errors @@ -52,18 +63,29 @@ public void When_parsing_a_string_array_input_then_a_full_path_to_an_executable_ [Fact] public void When_parsing_an_unsplit_string_a_root_command_can_be_omitted_from_the_parsed_args() { + var option = new Option("-x"); var command = new Command("outer") { new Command("inner") { - new Option("-x") + option } }; var result1 = command.Parse("inner -x hello"); var result2 = command.Parse("outer inner -x hello"); + using var _ = new AssertionScope(); + result1.Diagram().Should().Be(result2.Diagram()); + + foreach (var result in new[] { result1, result2 }) + { + result.Errors.Should().BeEmpty(); + result.RootCommandResult.Command.Name.Should().Be("outer"); + result.CommandResult.Command.Name.Should().Be("inner"); + result.GetValue(option).Should().Be("hello"); + } } [Fact] @@ -82,25 +104,6 @@ public void When_parsing_an_unsplit_string_then_input_a_full_path_to_an_executab result2.RootCommandResult.IdentifierToken.Value.Should().Be(RootCommand.ExecutablePath); } - [Fact] - public void When_parsing_an_unsplit_string_then_a_renamed_RootCommand_can_be_omitted_from_the_parsed_args() - { - var rootCommand = new Command("outer") - { - new Command("inner") - { - new Option("-x") - } - }; - - var result1 = rootCommand.Parse("inner -x hello"); - var result2 = rootCommand.Parse("outer inner -x hello"); - var result3 = rootCommand.Parse($"{RootCommand.ExecutableName} inner -x hello"); - - result2.RootCommandResult.Command.Should().BeSameAs(result1.RootCommandResult.Command); - result3.RootCommandResult.Command.Should().BeSameAs(result1.RootCommandResult.Command); - } - [Fact] public void When_parsing_a_string_array_option_values_containing_command_name_after_slash_are_not_treated_as_root_command() { @@ -112,6 +115,8 @@ public void When_parsing_a_string_array_option_values_containing_command_name_af var result = command.Parse(Split("/p:Key=something/myapp")); + using var _ = new AssertionScope(); + result.Errors.Should().BeEmpty(); result.GetResult(option).Should().NotBeNull(); } @@ -130,10 +135,89 @@ public void When_parsing_a_string_array_option_values_ending_with_slash_command_ var result = command.Parse(Split(arg)); + using var _ = new AssertionScope(); + result.Errors.Should().BeEmpty(); result.GetResult(option).Should().NotBeNull(); } + [Theory] + [MemberData(nameof(PathLikeFirstArgsMatchingTheRootCommand))] + public void When_parsing_a_string_array_a_path_like_first_arg_matching_the_root_command_is_treated_as_the_root_command(string pathLikeRootArg) + { + var option = new Option("-x"); + var command = new Command("outer") + { + new Command("inner") + { + option + } + }; + + var result = command.Parse([pathLikeRootArg, "inner", "-x", "hello"]); + + using var _ = new AssertionScope(); + + result.Errors.Should().BeEmpty(); + result.RootCommandResult.IdentifierToken.Value.Should().Be(pathLikeRootArg); + result.CommandResult.Command.Name.Should().Be("inner"); + result.GetValue(option).Should().Be("hello"); + } + + public static TheoryData PathLikeFirstArgsMatchingTheRootCommand => + [ + "./outer", + "tools/outer", + @".\outer", + @"tools\outer" + ]; + + [Fact] + public void RootCommand_matches_its_executable_name_as_the_first_arg_the_same_way_a_named_Command_matches_its_name() + { + // Guards the assumption that a named Command can stand in for a RootCommand + // in arg0-matching tests: RootCommand.Name is forced to the executable name and + // cannot be changed, so the equivalent scenario uses the executable name as arg0. + var option = new Option("-x"); + var rootCommand = new RootCommand + { + new Command("inner") + { + option + } + }; + + var result = rootCommand.Parse([RootCommand.ExecutableName, "inner", "-x", "hello"]); + + using var _ = new AssertionScope(); + + result.Errors.Should().BeEmpty(); + result.RootCommandResult.IdentifierToken.Value.Should().Be(RootCommand.ExecutableName); + result.CommandResult.Command.Name.Should().Be("inner"); + result.GetValue(option).Should().Be("hello"); + } + +#if NETFRAMEWORK + [Fact] + public void When_parsing_a_string_array_arguments_containing_invalid_path_characters_the_netframework_PathGetFileName_exception_is_ignored() + { + const string invalidPath = "my\0app"; + + // On .NET Framework, this throws ArgumentException for illegal characters in path. + Action getFileName = () => Path.GetFileName(invalidPath); + + var argument = new Argument("value"); + var command = new Command("myapp") { argument }; + var result = command.Parse(invalidPath); + + using var _ = new AssertionScope(); + + getFileName.Should().Throw(); + result.Errors.Should().BeEmpty(); + result.GetValue(argument).Should().Be(invalidPath); + } +#endif + string[] Split(string value) => CommandLineParser.SplitCommandLine(value).ToArray(); } } \ No newline at end of file diff --git a/src/System.CommandLine.Tests/RootCommandTests.cs b/src/System.CommandLine.Tests/RootCommandTests.cs index d5be20a34d..b6d76d1045 100644 --- a/src/System.CommandLine.Tests/RootCommandTests.cs +++ b/src/System.CommandLine.Tests/RootCommandTests.cs @@ -48,5 +48,17 @@ public void Setting_HelpName_does_not_change_Name() rootCommand.Name.Should().Be(RootCommand.ExecutableName); } + + [Fact] + public void ExecutablePath_is_not_null() + { + RootCommand.ExecutablePath.Should().NotBeNull(); + } + + [Fact] + public void ExecutableName_is_not_null_or_empty() + { + RootCommand.ExecutableName.Should().NotBeNullOrEmpty(); + } } } diff --git a/src/System.CommandLine.Tests/TestApps/NativeLibrary/NativeLibrary.csproj b/src/System.CommandLine.Tests/TestApps/NativeLibrary/NativeLibrary.csproj new file mode 100644 index 0000000000..42298fe373 --- /dev/null +++ b/src/System.CommandLine.Tests/TestApps/NativeLibrary/NativeLibrary.csproj @@ -0,0 +1,42 @@ + + + + + + + $(NetMinimum) + + Shared + true + false + Guard + true + + + + ..\..\..\System.CommandLine\bin\Release\$(NetMinimum)\System.CommandLine.dll + + + + + $(SystemCommandLineDllPath) + + + + + + + + + diff --git a/src/System.CommandLine.Tests/TestApps/NativeLibrary/Program.cs b/src/System.CommandLine.Tests/TestApps/NativeLibrary/Program.cs new file mode 100644 index 0000000000..9bffcc34d8 --- /dev/null +++ b/src/System.CommandLine.Tests/TestApps/NativeLibrary/Program.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.CommandLine; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; + +public static class Exports +{ + // Exported native symbol. Builds a RootCommand and returns its Name as UTF-8. + // When hosted as a native library, Environment.GetCommandLineArgs() is empty, + // so RootCommand.ExecutableName falls back to the AppContext value injected by + // the System.CommandLine build targets (which is the assembly name). + // + // Two-call protocol: pass a null buffer to get the required length, then call + // again with a buffer of that size. Returns the number of UTF-8 bytes. + [UnmanagedCallersOnly(EntryPoint = "get_executable_name", CallConvs = new[] { typeof(CallConvCdecl) })] + public static int GetExecutableName(IntPtr buffer, int bufferLength) + { + string name = new RootCommand().Name; + byte[] bytes = Encoding.UTF8.GetBytes(name); + + if (buffer != IntPtr.Zero && bufferLength >= bytes.Length) + { + Marshal.Copy(bytes, 0, buffer, bytes.Length); + } + + return bytes.Length; + } +} diff --git a/src/System.CommandLine/Parsing/StringExtensions.cs b/src/System.CommandLine/Parsing/StringExtensions.cs index ab6be26ba8..f134a44986 100644 --- a/src/System.CommandLine/Parsing/StringExtensions.cs +++ b/src/System.CommandLine/Parsing/StringExtensions.cs @@ -275,9 +275,25 @@ private static bool FirstArgumentIsRootCommand(IReadOnlyList args, Comma return true; } - if (rootCommand.EqualsNameOrAlias(args[0])) + if (args[0].TrySplitIntoSubtokens(out var first, out _) && + rootCommand.ValidTokens().TryGetValue(first, out var token) && + token.Type == TokenType.Option) { - return true; + return false; + } + + try + { + var potentialRootCommand = Path.GetFileName(args[0]); + + if (rootCommand.EqualsNameOrAlias(potentialRootCommand)) + { + return true; + } + } + catch (ArgumentException) + { + // possible exception for illegal characters in path on .NET Framework } } diff --git a/src/System.CommandLine/RootCommand.cs b/src/System.CommandLine/RootCommand.cs index b9172510f1..23b8229a8f 100644 --- a/src/System.CommandLine/RootCommand.cs +++ b/src/System.CommandLine/RootCommand.cs @@ -69,12 +69,12 @@ public string? HelpName /// The name of the currently running executable. /// public static string ExecutableName - => _executableName ??= Path.GetFileNameWithoutExtension(ExecutablePath).Replace(" ", ""); + => _executableName ??= GetExecutableName(); /// /// The path to the currently running executable. /// - public static string ExecutablePath => _executablePath ??= Environment.GetCommandLineArgs()[0]; + public static string ExecutablePath => _executablePath ??= GetExecutablePath(); private static string? ToolCommandName { @@ -91,5 +91,28 @@ private static string? ToolCommandName return _toolCommandName; } } + + private static string GetExecutablePath() + { + var args = Environment.GetCommandLineArgs(); + return args.Length > 0 ? args[0] : string.Empty; + } + + private static string GetExecutableName() + { + var path = ExecutablePath; + + if (path.Length > 0) + { + return Path.GetFileNameWithoutExtension(path).Replace(" ", ""); + } + + if (AppContext.GetData("System.CommandLine.ExecutableName") is string name && name.Length > 0) + { + return name; + } + + return "app"; + } } } diff --git a/src/System.CommandLine/System.CommandLine.csproj b/src/System.CommandLine/System.CommandLine.csproj index e4dfdde7be..cf33b898d8 100644 --- a/src/System.CommandLine/System.CommandLine.csproj +++ b/src/System.CommandLine/System.CommandLine.csproj @@ -20,7 +20,8 @@ - + + diff --git a/src/System.CommandLine/build/System.CommandLine.targets b/src/System.CommandLine/build/System.CommandLine.targets new file mode 100644 index 0000000000..1b719f30d1 --- /dev/null +++ b/src/System.CommandLine/build/System.CommandLine.targets @@ -0,0 +1,74 @@ + + + + <_SystemCommandLineExecutableName Condition="'$(_SystemCommandLineExecutableName)' == ''">$(AssemblyName) + + + + <_SystemCommandLineGeneratedFile>$(IntermediateOutputPath)System.CommandLine.ExecutableName.g.cs + <_SystemCommandLineExecutableNameInputsFile>$(IntermediateOutputPath)System.CommandLine.ExecutableName.inputs.cache + + + + + + + + + + + + + + + + + + + + + <_SystemCommandLineGeneratedCode> +namespace System.CommandLine.Generated +{ + internal static class ExecutableNameInitializer + { + [global::System.Runtime.CompilerServices.ModuleInitializer] + internal static void Initialize() + { + global::System.AppContext.SetData("System.CommandLine.ExecutableName", "$(_SystemCommandLineExecutableName)"); + } + } +} +]]> + + + + + + + +