Skip to content

Commit a15bb6d

Browse files
committed
done ReadMe
1 parent e279ae6 commit a15bb6d

File tree

5 files changed

+265
-30
lines changed

5 files changed

+265
-30
lines changed

ConsoleAppFramework.sln

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFrameworkBenchmark", "sa
2626
EndProject
2727
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsoleAppFramework.GeneratorTests", "tests\ConsoleAppFramework.GeneratorTests\ConsoleAppFramework.GeneratorTests.csproj", "{C54F7FE8-650A-4DC7-877F-0DE929351800}"
2828
EndProject
29+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NativeAot", "sandbox\NativeAot\NativeAot.csproj", "{EC1A3299-6597-4AD2-92DE-EDF309875A97}"
30+
EndProject
2931
Global
3032
GlobalSection(SolutionConfigurationPlatforms) = preSolution
3133
Debug|Any CPU = Debug|Any CPU
@@ -48,6 +50,10 @@ Global
4850
{C54F7FE8-650A-4DC7-877F-0DE929351800}.Debug|Any CPU.Build.0 = Debug|Any CPU
4951
{C54F7FE8-650A-4DC7-877F-0DE929351800}.Release|Any CPU.ActiveCfg = Release|Any CPU
5052
{C54F7FE8-650A-4DC7-877F-0DE929351800}.Release|Any CPU.Build.0 = Release|Any CPU
53+
{EC1A3299-6597-4AD2-92DE-EDF309875A97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
54+
{EC1A3299-6597-4AD2-92DE-EDF309875A97}.Debug|Any CPU.Build.0 = Debug|Any CPU
55+
{EC1A3299-6597-4AD2-92DE-EDF309875A97}.Release|Any CPU.ActiveCfg = Release|Any CPU
56+
{EC1A3299-6597-4AD2-92DE-EDF309875A97}.Release|Any CPU.Build.0 = Release|Any CPU
5157
EndGlobalSection
5258
GlobalSection(SolutionProperties) = preSolution
5359
HideSolutionNode = FALSE
@@ -57,6 +63,7 @@ Global
5763
{ACDA48BA-0BFE-4917-B335-7836DAA5929A} = {A2CF2984-E8E2-48FC-B5A1-58D74A2467E6}
5864
{F558E4F2-1AB0-4634-B613-69DFE79894AF} = {A2CF2984-E8E2-48FC-B5A1-58D74A2467E6}
5965
{C54F7FE8-650A-4DC7-877F-0DE929351800} = {AAD2D900-C305-4449-A9FC-6C7696FFEDFA}
66+
{EC1A3299-6597-4AD2-92DE-EDF309875A97} = {A2CF2984-E8E2-48FC-B5A1-58D74A2467E6}
6067
EndGlobalSection
6168
GlobalSection(ExtensibilityGlobals) = postSolution
6269
SolutionGuid = {7F3E353A-C125-4020-8481-11DC6496358C}

ReadMe.md

Lines changed: 181 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -332,40 +332,151 @@ app.Run(args);
332332

333333
### Performance of Commands
334334

335-
TODO:NANIKA KAKU
335+
In `ConsoleAppFramework`, the number and types of registered commands are statically determined at compile time. For example, let's register the following four commands:
336+
337+
```csharp
338+
app.Add("foo", () => { });
339+
app.Add("foo bar", (int x, int y) => { });
340+
app.Add("foo bar barbaz", (DateTime dateTime) => { });
341+
app.Add("foo baz", async (string foo = "test", CancellationToken cancellationToken = default) => { });
342+
```
343+
344+
The Source Generator generates four fields and holds them with specific types.
345+
346+
```csharp
347+
partial struct ConsoleAppBuilder
348+
{
349+
Action command0 = default!;
350+
Action<int, int> command1 = default!;
351+
Action<global::System.DateTime> command2 = default!;
352+
Func<string, global::System.Threading.CancellationToken, Task> command3 = default!;
353+
354+
partial void AddCore(string commandName, Delegate command)
355+
{
356+
switch (commandName)
357+
{
358+
case "foo":
359+
this.command0 = Unsafe.As<Action>(command);
360+
break;
361+
case "foo bar":
362+
this.command1 = Unsafe.As<Action<int, int>>(command);
363+
break;
364+
case "foo bar barbaz":
365+
this.command2 = Unsafe.As<Action<global::System.DateTime>>(command);
366+
break;
367+
case "foo baz":
368+
this.command3 = Unsafe.As<Func<string, global::System.Threading.CancellationToken, Task>>(command);
369+
break;
370+
default:
371+
break;
372+
}
373+
}
374+
}
375+
```
376+
377+
This ensures the fastest execution speed without any additional unnecessary allocations such as arrays and without any boxing since it holds static delegate types.
378+
379+
Command routing also generates a switch of nested string constants.
380+
381+
```csharp
382+
partial void RunCore(string[] args)
383+
{
384+
if (args.Length == 0)
385+
{
386+
ShowHelp(-1);
387+
return;
388+
}
389+
switch (args[0])
390+
{
391+
case "foo":
392+
if (args.Length == 1)
393+
{
394+
RunCommand0(args, args.AsSpan(1), command0);
395+
return;
396+
}
397+
switch (args[1])
398+
{
399+
case "bar":
400+
if (args.Length == 2)
401+
{
402+
RunCommand1(args, args.AsSpan(2), command1);
403+
return;
404+
}
405+
switch (args[2])
406+
{
407+
case "barbaz":
408+
RunCommand2(args, args.AsSpan(3), command2);
409+
break;
410+
default:
411+
RunCommand1(args, args.AsSpan(2), command1);
412+
break;
413+
}
414+
break;
415+
case "baz":
416+
RunCommand3(args, args.AsSpan(2), command3);
417+
break;
418+
default:
419+
RunCommand0(args, args.AsSpan(1), command0);
420+
break;
421+
}
422+
break;
423+
default:
424+
ShowHelp(-1);
425+
break;
426+
}
427+
}
428+
```
429+
430+
The C# compiler performs complex generation for string constant switches, making them extremely fast, and it would be difficult to achieve faster routing than this.
336431

337432
Parse and Value Binding
338433
---
434+
The method parameter names and types determine how to parse and bind values from the command-line arguments. When using lambda expressions, optional values and `params` arrays supported from C# 12 are also supported.
339435

436+
```csharp
437+
ConsoleApp.Run(args, (
438+
[Argument]DateTime dateTime, // Argument
439+
[Argument]Guid guidvalue, //
440+
int intVar, // required
441+
bool boolFlag, // flag
442+
MyEnum enumValue, // enum
443+
int[] array, // array
444+
MyClass obj, // object
445+
string optional = "abcde", // optional
446+
double? nullableValue = null, // nullable
447+
params string[] paramsArray // params
448+
) => { });
449+
```
340450

451+
When using `ConsoleApp.Run`, you can check the syntax of the command line in the tooltip to see how it is generated.
341452

453+
![image](https://github.com/Cysharp/ConsoleAppFramework/assets/46207/af480566-adac-4767-bd5e-af89ab6d71f1)
342454

343-
// TODO:reason and policy of limitation of parsing
344-
345-
`[Argument]`
455+
For the rules on converting parameter names to option names, aliases, and how to set documentation, refer to the [Option aliases](#option-aliases-and-help-version) section.
346456

347-
`bool`
457+
Parameters marked with the `[Argument]` attribute receive values in order without parameter names. This attribute can only be set on sequential parameters from the beginning.
348458

459+
To convert from string arguments to various types, basic primitive types (`string`, `char`, `sbyte`, `byte`, `short`, `int`, `long`, `uint`, `ushort`, `ulong`, `decimal`, `float`, `double`) use `TryParse`. For types that implement `ISpanParsable<T>` (`DateTime`, `DateTimeOffset`, `Guid`, `BigInteger`, `Complex`, `Half`, `Int128`, etc.), [IParsable<TSelf>.TryParse](https://learn.microsoft.com/en-us/dotnet/api/system.iparsable-1.tryparse?view=net-8.0#system-ispanparsable-1-tryparse(system-readonlyspan((system-char))-system-iformatprovider-0@)) or [ISpanParsable<TSelf>.TryParse](https://learn.microsoft.com/en-us/dotnet/api/system.ispanparsable-1.tryparse?view=net-8.0#system-ispanparsable-1-tryparse(system-readonlyspan((system-char))-system-iformatprovider-0@)) is used.
349460

461+
For `enum`, it is parsed using `Enum.TryParse(ignoreCase: true)`.
350462

463+
`bool` is treated as a flag and is always optional. It becomes `true` when the parameter name is passed.
351464

465+
### Array
352466

353-
467+
Array parsing has three special patterns.
354468

469+
For a regular `T[]`, if the value starts with `[`, it is parsed using `JsonSerialzier.Deserialize`. Otherwise, it is parsed as comma-separated values. For example, `[1,2,3]` or `1,2,3` are allowed as values. To set an empty array, pass `[]`.
355470

356-
`enum`
357-
`nullable?`
358-
`DateTime`
471+
For `params T[]`, all subsequent arguments become the values of the array. For example, if there is an input like `--paramsArray foo bar baz`, it will be bound to a value like `["foo", "bar", "baz"]`.
359472

360-
`ISpanParsable<T>`
361-
#### default
362-
#### json
363-
#### params T[]
473+
### Object
364474

475+
If none of the above cases apply, `JsonSerializer.Deserialize<T>` is used to perform binding as JSON. However, `CancellationToken` and `ConsoleAppContext` are treated as special types and excluded from binding. Also, parameters with the `[FromServices]` attribute are not subject to binding.
365476

366-
#### Custom Value Converter
477+
### Custom Value Converter
367478

368-
// TODO:
479+
To perform custom binding to existing types that do not support `ISpanParsable<T>`, you can create and set up a custom parser. For example, if you want to pass `System.Numerics.Vector3` as a comma-separated string like `1.3,4.12,5.947` and parse it, you can create an `Attribute` with `AttributeTargets.Parameter` that implements `IArgumentParser<T>`'s `static bool TryParse(ReadOnlySpan<char> s, out Vector3 result)` as follows:
369480

370481
```csharp
371482
[AttributeUsage(AttributeTargets.Parameter)]
@@ -396,34 +507,76 @@ public class Vector3ParserAttribute : Attribute, IArgumentParser<Vector3>
396507
}
397508
```
398509

510+
By setting this attribute on a parameter, the custom parser will be called when parsing the args.
399511

512+
```csharp
513+
ConsoleApp.Run(args, ([Vector3Parser] Vector3 position) => Console.WriteLine(position));
514+
```
515+
516+
### Syntax Parsing Policy and Performance
517+
518+
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.
400519

520+
For example, specifications that change behavior based on `-x` and `-X` or allow bundling `-f -d -x` as `-fdx` are not easy to understand and also take time to parse. The poor performance of System.CommandLine may be influenced by its adherence to complex grammar. Therefore, ConsoleAppFramework prioritizes performance and clear rules. It uses lower-kebab-case as the basis while allowing case-insensitive matching. It does not support ambiguous grammar that cannot be processed in a single pass or takes time to parse.
401521

522+
[System.CommandLine seems to be aiming for a new direction in .NET 9 and .NET 10](https://github.com/dotnet/command-line-api/issues/2338), but from a performance perspective, it will never surpass ConsoleAppFramework.
402523

403524
CancellationToken(Gracefully Shutdown) and Timeout
404525
---
526+
In ConsoleAppFramework, when you pass a `CancellationToken` as an argument, it can be used to check for interruption commands (SIGINT/SIGTERM/SIGKILL - Ctrl+C) rather than being treated as a parameter. For handling this, ConsoleAppFramework performs special code generation when a `CancellationToken` is included in the parameters.
405527

528+
```csharp
529+
using var posixSignalHandler = PosixSignalHandler.Register(ConsoleApp.Timeout);
530+
var arg0 = posixSignalHandler.Token;
406531

532+
await Task.Run(() => command(arg0!)).WaitAsync(posixSignalHandler.TimeoutToken);
533+
```
407534

535+
If a CancellationToken is not passed, the application is immediately forced to terminate when an interruption command (Ctrl+C) is received. However, if a CancellationToken is present, it internally uses [`PosixSignalRegistration`](https://learn.microsoft.com/en-us/dotnet/api/system.runtime.interopservices.posixsignalregistration) to hook SIGINT/SIGTERM/SIGKILL and sets the CancellationToken to a canceled state. Additionally, it prevents forced termination to allow for a graceful shutdown.
408536

537+
If the CancellationToken is handled correctly, the application can perform proper termination processing based on the application's handling. However, if the CancellationToken is mishandled, the application may not terminate even when an interruption command is received. To avoid this, a timeout timer starts after the interruption command, and the application is forcibly terminated again after the specified time.
409538

410-
Exit Code
411-
---
412-
If the method returns `int` or `Task<int>` or `ValueTask<int> value, ConsoleAppFramework will set the return value to the exit code.
539+
The default timeout is 5 seconds, but it can be changed using `ConsoleApp.Timeout`. For example, setting it to `ConsoleApp.Timeout = Timeout.InfiniteTimeSpan;` disables the forced termination caused by the timeout.
413540

541+
The hooking behavior using `PosixSignalRegistration` is determined by the presence of a `CancellationToken` (or always takes effect if a filter is set). Therefore, even for synchronous methods, it is possible to change the behavior by including a `CancellationToken` as an argument.
414542

543+
Exit Code
544+
---
545+
If the method returns `int` or `Task<int>`, `ConsoleAppFramework` will set the return value to the exit code. Due to the nature of code generation, when writing lambda expressions, you need to explicitly specify either `int` or `Task<int>`.
415546

416-
> **NOTE**: If the method throws an unhandled exception, ConsoleAppFramework always set `1` to the exit code.
417-
547+
```csharp
548+
// return Random ExitCode...
549+
ConsoleApp.Run(args, int () => Random.Shared.Next());
550+
```
418551

552+
```csharp
553+
// return StatusCode
554+
await ConsoleApp.RunAsync(args, async Task<int> (string url, CancellationToken cancellationToken) =>
555+
{
556+
using var client = new HttpClient();
557+
var response = await client.GetAsync(url, cancellationToken);
558+
return (int)response.StatusCode;
559+
});
560+
```
419561

562+
If the method throws an unhandled exception, ConsoleAppFramework always set `1` to the exit code. Also, in that case, output `Exception.ToString` to `ConsoleApp.LogError` (the default is `Console.WriteLine`). If you want to modify this code, please create a custom filter. For more details, refer to the [Filter](#filtermiddleware-pipline--consoleappcontext) section.
420563

421564
Attribute based parameters validation
422565
---
566+
`ConsoleAppFramework` performs validation when the parameters are marked with attributes for validation from `System.ComponentModel.DataAnnotations` (more precisely, attributes that implement `ValidationAttribute`). The validation occurs after parameter binding and before command execution. If the validation fails, it throws a `ValidationException`.
423567

568+
```csharp
569+
ConsoleApp.Run(args, ([EmailAddress] string firstArg, [Range(0, 2)] int secondArg) => { });
570+
```
424571

572+
For example, if you pass arguments like `args = "--first-arg invalid.email --second-arg 10".Split(' ');`, you will see validation failure messages such as:
425573

574+
```txt
575+
The firstArg field is not a valid e-mail address.
576+
The field secondArg must be between 0 and 2.
577+
```
426578

579+
By default, the ExitCode is set to 1 in this case.
427580

428581
Filter(Middleware) Pipline / ConsoleAppContext
429582
---
@@ -786,10 +939,15 @@ internal class ServiceProviderScopeFilter(IServiceProvider serviceProvider, Cons
786939

787940
Publish to executable file
788941
---
942+
There are multiple ways to run a CLI application in .NET:
943+
944+
* [dotnet run](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-run)
945+
* [dotnet build](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-build)
946+
* [dotnet publish](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-publish)
947+
948+
`run` is convenient when you want to execute the `csproj` directly, such as for starting command tools in CI. `build` and `publish` are quite similar, so it's possible to discuss them in general terms, but it's a bit difficult to talk about the precise differences. For more details, it's a good idea to check out [`build` vs `publish` -- can they be friends? · Issue #26247 · dotnet/sdk](https://github.com/dotnet/sdk/issues/26247).
789949

790-
* Native AOT
791-
* dotnet run
792-
* dotnet publish
950+
Also, to run with Native AOT, please refer to the [Native AOT deployment overview](https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/). In any case, ConsoleAppFramework thoroughly implements a dependency-free and reflection-free approach, so it shouldn't be an obstacle to execution.
793951

794952
License
795953
---

sandbox/GeneratorSandbox/Program.cs

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,48 @@
66
using Microsoft.Extensions.Logging;
77
using Microsoft.Extensions.Options;
88
using System.ComponentModel.DataAnnotations;
9+
using System.Numerics;
910
using System.Threading.Channels;
1011
using ZLogger;
1112

12-
// args = ["--msg", "foobarbaz"];
1313

14+
args = "--first-arg invalid.email --second-arg 10".Split(' ');
1415

15-
// Microsoft.Extensions.DependencyInjection
16+
ConsoleApp.Timeout = Timeout.InfiniteTimeSpan;
1617

17-
// Package Import: Microsoft.Extensions.Hosting
18-
var builder = Host.CreateApplicationBuilder(); // don't pass args.
1918

20-
using var host = builder.Build(); // using
21-
ConsoleApp.ServiceProvider = host.Services; // use host ServiceProvider
2219

23-
ConsoleApp.Run(args, ([FromServices] ILogger<Program> logger) => logger.LogInformation("Hello World!"));
2420

21+
ConsoleApp.Run(args, (
22+
[Argument] DateTime dateTime, // Argument
23+
[Argument] Guid guidvalue, //
24+
int intVar, // required
25+
bool boolFlag, // flag
26+
MyEnum enumValue, // enum
27+
int[] array, // array
28+
MyClass obj, // object
29+
string optional = "abcde", // optional
30+
double? nullableValue = null, // nullable
31+
params string[] paramsArray // params
32+
) => { });
2533

2634

2735

2836

37+
38+
39+
40+
41+
public enum MyEnum
42+
{
43+
44+
}
45+
46+
public class MyClass
47+
{
48+
49+
}
50+
2951
// inject logger
3052
public class MyCommand(ILogger<MyCommand> logger, IOptions<PositionOptions> options)
3153
{
@@ -49,7 +71,32 @@ public class PositionOptions
4971

5072

5173

74+
[AttributeUsage(AttributeTargets.Parameter)]
75+
public class Vector3ParserAttribute : Attribute, IArgumentParser<Vector3>
76+
{
77+
public static bool TryParse(ReadOnlySpan<char> s, out Vector3 result)
78+
{
79+
Span<Range> ranges = stackalloc Range[3];
80+
var splitCount = s.Split(ranges, ',');
81+
if (splitCount != 3)
82+
{
83+
result = default;
84+
return false;
85+
}
86+
87+
float x;
88+
float y;
89+
float z;
90+
if (float.TryParse(s[ranges[0]], out x) && float.TryParse(s[ranges[1]], out y) && float.TryParse(s[ranges[2]], out z))
91+
{
92+
result = new Vector3(x, y, z);
93+
return true;
94+
}
5295

96+
result = default;
97+
return false;
98+
}
99+
}
53100

54101

55102
internal class DIFilter(string foo, int bar, ConsoleAppFilter next)

0 commit comments

Comments
 (0)