Skip to content

Commit 0124aa0

Browse files
authored
Merge pull request #164 from Cysharp/double-dash
support double-dash argument escape
2 parents 50644d5 + 9224bb1 commit 0124aa0

File tree

10 files changed

+246
-58
lines changed

10 files changed

+246
-58
lines changed

ReadMe.md

+29-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ ConsoleAppFramework
22
===
33
[![GitHub Actions](https://github.com/Cysharp/ConsoleAppFramework/workflows/Build-Debug/badge.svg)](https://github.com/Cysharp/ConsoleAppFramework/actions) [![Releases](https://img.shields.io/github/release/Cysharp/ConsoleAppFramework.svg)](https://github.com/Cysharp/ConsoleAppFramework/releases)
44

5-
ConsoleAppFramework v5 is Zero Dependency, Zero Overhead, Zero Reflection, Zero Allocation, AOT Safe CLI Framework powered by C# Source Generator; achieves exceptionally high performance, fastest start-up time(with NativeAOT) and minimal binary size. Leveraging the latest features of .NET 8 and C# 12 ([IncrementalGenerator](https://github.com/dotnet/roslyn/blob/main/docs/features/incremental-generators.md), [managed function pointer](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-9.0/function-pointers#function-pointers-1), [params arrays and default values lambda expression](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/lambda-expressions#input-parameters-of-a-lambda-expression), [`ISpanParsable<T>`](https://learn.microsoft.com/en-us/dotnet/api/system.ispanparsable-1), [`PosixSignalRegistration`](https://learn.microsoft.com/en-us/dotnet/api/system.runtime.interopservices.posixsignalregistration), etc.), this library ensures maximum performance while maintaining flexibility and extensibility.
5+
ConsoleAppFramework v5 is Zero Dependency, Zero Overhead, Zero Reflection, Zero Allocation, AOT Safe CLI Framework powered by C# Source Generator; achieves exceptionally high performance, fastest start-up time(with NativeAOT) and minimal binary size. Leveraging the latest features of .NET 8 and C# 13 ([IncrementalGenerator](https://github.com/dotnet/roslyn/blob/main/docs/features/incremental-generators.md), [managed function pointer](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-9.0/function-pointers#function-pointers-1), [params arrays and default values lambda expression](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/lambda-expressions#input-parameters-of-a-lambda-expression), [`ISpanParsable<T>`](https://learn.microsoft.com/en-us/dotnet/api/system.ispanparsable-1), [`PosixSignalRegistration`](https://learn.microsoft.com/en-us/dotnet/api/system.runtime.interopservices.posixsignalregistration), etc.), this library ensures maximum performance while maintaining flexibility and extensibility.
66

77
![image](https://github.com/Cysharp/ConsoleAppFramework/assets/46207/db4bf599-9fe0-4ce4-801f-0003f44d5628)
88
> Set `RunStrategy=ColdStart WarmupCount=0` to calculate the cold start benchmark, which is suitable for CLI application.
@@ -147,14 +147,15 @@ ConsoleAppFramework offers a rich set of features as a framework. The Source Gen
147147
* High performance value parsing via `ISpanParsable<T>`
148148
* Parsing of params arrays
149149
* Parsing of JSON arguments
150+
* Double-dash escape arguments
150151
* Help(`-h|--help`) option builder
151152
* Default show version(`--version`) option
152153

153154
As you can see from the generated output, the help display is also fast. In typical frameworks, the help string is constructed after the help invocation. However, in ConsoleAppFramework, the help is embedded as string constants, achieving the absolute maximum performance that cannot be surpassed!
154155

155156
Getting Started
156157
--
157-
This library is distributed via NuGet, minimal requirement is .NET 8 and C# 12.
158+
This library is distributed via NuGet, minimal requirement is .NET 8 and C# 13.
158159

159160
> dotnet add package [ConsoleAppFramework](https://www.nuget.org/packages/ConsoleAppFramework)
160161
@@ -168,6 +169,13 @@ using ConsoleAppFramework;
168169
ConsoleApp.Run(args, (string name) => Console.WriteLine($"Hello {name}"));
169170
```
170171

172+
> When using .NET 8, you need to explicitly set LangVersion to 13 or above.
173+
> ```xml
174+
> <PropertyGroup>
175+
> <TargetFramework>net8.0</TargetFramework>
176+
> <LangVersion>13</LangVersion>
177+
> </PropertyGroup>
178+
171179
> The latest Visual Studio changed the execution timing of Source Generators to either during save or at compile time. If you encounter unexpected behavior, try compiling once or change the option to "Automatic" under TextEditor -> C# -> Advanced -> Source Generators.
172180
173181
You can execute command like `sampletool --name "foo"`.
@@ -605,6 +613,25 @@ By setting this attribute on a parameter, the custom parser will be called when
605613
ConsoleApp.Run(args, ([Vector3Parser] Vector3 position) => Console.WriteLine(position));
606614
```
607615

616+
### Double-dash escaping
617+
618+
Arguments after double-dash (`--`) can be received as escaped arguments without being parsed. This is useful when creating commands like `dotnet run`.
619+
```csharp
620+
// dotnet run --project foo.csproj -- --foo 100 --bar bazbaz
621+
var app = ConsoleApp.Create();
622+
app.Add("run", (string project, ConsoleAppContext context) =>
623+
{
624+
// run --project foo.csproj -- --foo 100 --bar bazbaz
625+
Console.WriteLine(string.Join(" ", context.Arguments));
626+
// --project foo.csproj
627+
Console.WriteLine(string.Join(" ", context.CommandArguments!));
628+
// --foo 100 --bar bazbaz
629+
Console.WriteLine(string.Join(" ", context.EscapedArguments!));
630+
});
631+
app.Run(args);
632+
```
633+
You can get the escaped arguments using `ConsoleAppContext.EscapedArguments`. From `ConsoleAppContext`, you can also get `Arguments` which contains all arguments passed to `Run/RunAsync`, and `CommandArguments` which contains the arguments used for command execution.
634+
608635
### Syntax Parsing Policy and Performance
609636

610637
While there are some standards for command-line arguments, such as UNIX tools and POSIX, there is no absolute specification. The [Command-line syntax overview for System.CommandLine](https://learn.microsoft.com/en-us/dotnet/standard/commandline/syntax) provides an explanation of the specifications adopted by System.CommandLine. However, ConsoleAppFramework, while referring to these specifications to some extent, does not necessarily aim to fully comply with them.

sandbox/GeneratorSandbox/GeneratorSandbox.csproj

+3-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
<PropertyGroup>
44
<OutputType>Exe</OutputType>
5-
<TargetFramework>net8.0</TargetFramework>
5+
<TargetFramework>net9.0</TargetFramework>
6+
<LangVersion>13</LangVersion>
7+
68
<ImplicitUsings>enable</ImplicitUsings>
79
<Nullable>disable</Nullable>
810
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>

sandbox/GeneratorSandbox/Program.cs

+28-21
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
#nullable enable
22

33
using ConsoleAppFramework;
4+
using GeneratorSandbox;
45
using Microsoft.Extensions.DependencyInjection;
6+
using System.Linq;
57
using System.Text.Json;
68
//using Microsoft.Extensions.Configuration;
79
//using Microsoft.Extensions.DependencyInjection;
@@ -29,14 +31,30 @@
2931
// services.Configure<PositionOptions>(configuration.GetSection("Position"));
3032
// });
3133

32-
//app.Add<MyCommand>();
33-
//app.Run(args);
34-
// sc.BuildServiceProvider()
3534

36-
//IServiceProvider ser;
37-
//ser.CreateScope()
35+
args = ["run", "--project", "foo.csproj", "--", "--foo", "100", "--bar", "bazbaz"];
3836

39-
ConsoleApp.Run(args, () => { });
37+
// dotnet run --project foo.csproj -- --foo 100 --bar bazbaz
38+
39+
var app = ConsoleApp.Create();
40+
41+
app.Add("run", (string project, ConsoleAppContext context) =>
42+
{
43+
// run --project foo.csproj -- --foo 100 --bar bazbaz
44+
Console.WriteLine(string.Join(" ", context.Arguments));
45+
46+
// --project foo.csproj
47+
Console.WriteLine(string.Join(" ", context.CommandArguments!));
48+
49+
// --foo 100 --bar bazbaz
50+
Console.WriteLine(string.Join(" ", context.EscapedArguments!));
51+
});
52+
53+
app.Run(args);
54+
55+
56+
57+
//ConsoleApp.Run(args, (ConsoleAppContext ctx) => { });
4058

4159
// inject options
4260
//public class MyCommand(IOptions<PositionOptions> options)
@@ -115,24 +133,13 @@ public class MyService
115133

116134
public class MyCommands
117135
{
118-
/// <summary>
119-
///
120-
/// </summary>
121-
/// <param name="msg">foobarbaz!</param>
122-
[Command("Error1")]
123-
public void Error1(string msg = @"\")
136+
public void Cmd1(int x, int y, ConsoleAppContext ctx)
124137
{
125-
Console.WriteLine(msg);
126138
}
127-
[Command("Error2")]
128-
public void Error2(string msg = "\\")
129-
{
130-
Console.WriteLine(msg);
131-
}
132-
[Command("Output")]
133-
public void Output(string msg = @"\\")
139+
140+
public Task Cmd2(int x, int y)
134141
{
135-
Console.WriteLine(msg); // 「\」
142+
return Task.CompletedTask;
136143
}
137144
}
138145

src/ConsoleAppFramework.Abstractions/ConsoleApp.Abstractions.cs

+43-2
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,52 @@
1-
namespace ConsoleAppFramework;
1+
using System.ComponentModel;
2+
3+
namespace ConsoleAppFramework;
24

35
public interface IArgumentParser<T>
46
{
57
static abstract bool TryParse(ReadOnlySpan<char> s, out T result);
68
}
79

8-
public record class ConsoleAppContext(string CommandName, string[] Arguments, object? State);
10+
public record ConsoleAppContext
11+
{
12+
public string CommandName { get; init; }
13+
public string[] Arguments { get; init; }
14+
public object? State { get; init; }
15+
16+
[EditorBrowsable(EditorBrowsableState.Never)]
17+
public int CommandDepth { get; }
18+
19+
[EditorBrowsable(EditorBrowsableState.Never)]
20+
public int EscapeIndex { get; }
21+
22+
public ReadOnlySpan<string> CommandArguments
23+
{
24+
get => (EscapeIndex == -1)
25+
? Arguments.AsSpan(CommandDepth)
26+
: Arguments.AsSpan(CommandDepth, EscapeIndex - CommandDepth);
27+
}
28+
29+
public ReadOnlySpan<string> EscapedArguments
30+
{
31+
get => (EscapeIndex == -1)
32+
? Array.Empty<string>()
33+
: Arguments.AsSpan(EscapeIndex + 1);
34+
}
35+
36+
public ConsoleAppContext(string commandName, string[] arguments, object? state, int commandDepth, int escapeIndex)
37+
{
38+
this.CommandName = commandName;
39+
this.Arguments = arguments;
40+
this.State = state;
41+
this.CommandDepth = commandDepth;
42+
this.EscapeIndex = escapeIndex;
43+
}
44+
45+
public override string ToString()
46+
{
47+
return string.Join(" ", Arguments);
48+
}
49+
}
950

1051
public abstract class ConsoleAppFilter(ConsoleAppFilter next)
1152
{

src/ConsoleAppFramework/Command.cs

+10-10
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ public record class CommandParameter
173173
// increment = false when passed from [Argument]
174174
public string BuildParseMethod(int argCount, string argumentName, bool increment)
175175
{
176-
var incrementIndex = increment ? "!TryIncrementIndex(ref i, args.Length) || " : "";
176+
var incrementIndex = increment ? "!TryIncrementIndex(ref i, commandArgs.Length) || " : "";
177177
return Core(Type.TypeSymbol, false);
178178

179179
string Core(ITypeSymbol type, bool nullable)
@@ -193,7 +193,7 @@ string Core(ITypeSymbol type, bool nullable)
193193

194194
if (CustomParserType != null)
195195
{
196-
return $"if ({incrementIndex}!{CustomParserType.ToFullyQualifiedFormatDisplayString()}.TryParse(args[i], {outArgVar})) {{ ThrowArgumentParseFailed(\"{argumentName}\", args[i]); }}{elseExpr}";
196+
return $"if ({incrementIndex}!{CustomParserType.ToFullyQualifiedFormatDisplayString()}.TryParse(commandArgs[i], {outArgVar})) {{ ThrowArgumentParseFailed(\"{argumentName}\", commandArgs[i]); }}{elseExpr}";
197197
}
198198

199199
switch (type.SpecialType)
@@ -202,11 +202,11 @@ string Core(ITypeSymbol type, bool nullable)
202202
// no parse
203203
if (increment)
204204
{
205-
return $"if (!TryIncrementIndex(ref i, args.Length)) {{ ThrowArgumentParseFailed(\"{argumentName}\", args[i]); }} else {{ arg{argCount} = args[i]; }}";
205+
return $"if (!TryIncrementIndex(ref i, commandArgs.Length)) {{ ThrowArgumentParseFailed(\"{argumentName}\", commandArgs[i]); }} else {{ arg{argCount} = commandArgs[i]; }}";
206206
}
207207
else
208208
{
209-
return $"arg{argCount} = args[i];";
209+
return $"arg{argCount} = commandArgs[i];";
210210
}
211211

212212
case SpecialType.System_Boolean:
@@ -230,13 +230,13 @@ string Core(ITypeSymbol type, bool nullable)
230230
// Enum
231231
if (type.TypeKind == TypeKind.Enum)
232232
{
233-
return $"if ({incrementIndex}!Enum.TryParse<{type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}>(args[i], true, {outArgVar})) {{ ThrowArgumentParseFailed(\"{argumentName}\", args[i]); }}{elseExpr}";
233+
return $"if ({incrementIndex}!Enum.TryParse<{type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}>(commandArgs[i], true, {outArgVar})) {{ ThrowArgumentParseFailed(\"{argumentName}\", commandArgs[i]); }}{elseExpr}";
234234
}
235235

236236
// ParamsArray
237237
if (IsParams)
238238
{
239-
return $"{(increment ? "i++; " : "")}if (!TryParseParamsArray(args, ref arg{argCount}, ref i)) {{ ThrowArgumentParseFailed(\"{argumentName}\", args[i]); }}{elseExpr}";
239+
return $"{(increment ? "i++; " : "")}if (!TryParseParamsArray(commandArgs, ref arg{argCount}, ref i)) {{ ThrowArgumentParseFailed(\"{argumentName}\", commandArgs[i]); }}{elseExpr}";
240240
}
241241

242242
// Array
@@ -248,7 +248,7 @@ string Core(ITypeSymbol type, bool nullable)
248248
{
249249
if (elementType.AllInterfaces.Any(x => x.EqualsUnconstructedGenericType(parsable)))
250250
{
251-
return $"if ({incrementIndex}!TrySplitParse(args[i], {outArgVar})) {{ ThrowArgumentParseFailed(\"{argumentName}\", args[i]); }}{elseExpr}";
251+
return $"if ({incrementIndex}!TrySplitParse(commandArgs[i], {outArgVar})) {{ ThrowArgumentParseFailed(\"{argumentName}\", commandArgs[i]); }}{elseExpr}";
252252
}
253253
}
254254
break;
@@ -272,15 +272,15 @@ string Core(ITypeSymbol type, bool nullable)
272272

273273
if (tryParseKnownPrimitive)
274274
{
275-
return $"if ({incrementIndex}!{type.ToFullyQualifiedFormatDisplayString()}.TryParse(args[i], {outArgVar})) {{ ThrowArgumentParseFailed(\"{argumentName}\", args[i]); }}{elseExpr}";
275+
return $"if ({incrementIndex}!{type.ToFullyQualifiedFormatDisplayString()}.TryParse(commandArgs[i], {outArgVar})) {{ ThrowArgumentParseFailed(\"{argumentName}\", commandArgs[i]); }}{elseExpr}";
276276
}
277277
else if (tryParseIParsable)
278278
{
279-
return $"if ({incrementIndex}!{type.ToFullyQualifiedFormatDisplayString()}.TryParse(args[i], null, {outArgVar})) {{ ThrowArgumentParseFailed(\"{argumentName}\", args[i]); }}{elseExpr}";
279+
return $"if ({incrementIndex}!{type.ToFullyQualifiedFormatDisplayString()}.TryParse(commandArgs[i], null, {outArgVar})) {{ ThrowArgumentParseFailed(\"{argumentName}\", commandArgs[i]); }}{elseExpr}";
280280
}
281281
else
282282
{
283-
return $"try {{ arg{argCount} = System.Text.Json.JsonSerializer.Deserialize<{type.ToFullyQualifiedFormatDisplayString()}>(args[{(increment ? "++i" : "i")}], JsonSerializerOptions); }} catch {{ ThrowArgumentParseFailed(\"{argumentName}\", args[i]); }}";
283+
return $"try {{ arg{argCount} = System.Text.Json.JsonSerializer.Deserialize<{type.ToFullyQualifiedFormatDisplayString()}>(commandArgs[{(increment ? "++i" : "i")}], JsonSerializerOptions); }} catch {{ ThrowArgumentParseFailed(\"{argumentName}\", commandArgs[i]); }}";
284284
}
285285
}
286286
}

src/ConsoleAppFramework/ConsoleAppBaseCode.cs

+38-3
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,42 @@ internal interface IArgumentParser<T>
4545
static abstract bool TryParse(ReadOnlySpan<char> s, out T result);
4646
}
4747
48-
internal record class ConsoleAppContext(string CommandName, string[] Arguments, object? State);
48+
internal record ConsoleAppContext
49+
{
50+
public string CommandName { get; init; }
51+
public string[] Arguments { get; init; }
52+
public object? State { get; init; }
53+
internal int CommandDepth { get; }
54+
internal int EscapeIndex { get; }
55+
56+
public ReadOnlySpan<string> CommandArguments
57+
{
58+
get => (EscapeIndex == -1)
59+
? Arguments.AsSpan(CommandDepth)
60+
: Arguments.AsSpan(CommandDepth, EscapeIndex - CommandDepth);
61+
}
62+
63+
public ReadOnlySpan<string> EscapedArguments
64+
{
65+
get => (EscapeIndex == -1)
66+
? Array.Empty<string>()
67+
: Arguments.AsSpan(EscapeIndex + 1);
68+
}
69+
70+
public ConsoleAppContext(string commandName, string[] arguments, object? state, int commandDepth, int escapeIndex)
71+
{
72+
this.CommandName = commandName;
73+
this.Arguments = arguments;
74+
this.State = state;
75+
this.CommandDepth = commandDepth;
76+
this.EscapeIndex = escapeIndex;
77+
}
78+
79+
public override string ToString()
80+
{
81+
return string.Join(" ", Arguments);
82+
}
83+
}
4984
5085
internal abstract class ConsoleAppFilter(ConsoleAppFilter next)
5186
{
@@ -329,12 +364,12 @@ static void ShowVersion()
329364
330365
static partial void ShowHelp(int helpId);
331366
332-
static async Task RunWithFilterAsync(string commandName, string[] args, ConsoleAppFilter invoker)
367+
static async Task RunWithFilterAsync(string commandName, string[] args, int commandDepth, int escapeIndex, ConsoleAppFilter invoker)
333368
{
334369
using var posixSignalHandler = PosixSignalHandler.Register(Timeout);
335370
try
336371
{
337-
await Task.Run(() => invoker.InvokeAsync(new ConsoleAppContext(commandName, args, null), posixSignalHandler.Token)).WaitAsync(posixSignalHandler.TimeoutToken);
372+
await Task.Run(() => invoker.InvokeAsync(new ConsoleAppContext(commandName, args, null, commandDepth, escapeIndex), posixSignalHandler.Token)).WaitAsync(posixSignalHandler.TimeoutToken);
338373
}
339374
catch (Exception ex)
340375
{

0 commit comments

Comments
 (0)