Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions ReadMe.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ ConsoleAppFramework
===
[![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)

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.
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.

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

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!

Getting Started
--
This library is distributed via NuGet, minimal requirement is .NET 8 and C# 12.
This library is distributed via NuGet, minimal requirement is .NET 8 and C# 13.

> dotnet add package [ConsoleAppFramework](https://www.nuget.org/packages/ConsoleAppFramework)

Expand All @@ -168,6 +169,13 @@ using ConsoleAppFramework;
ConsoleApp.Run(args, (string name) => Console.WriteLine($"Hello {name}"));
```

> When using .NET 8, you need to explicitly set LangVersion to 13 or above.
> ```xml
> <PropertyGroup>
> <TargetFramework>net8.0</TargetFramework>
> <LangVersion>13</LangVersion>
> </PropertyGroup>

> 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.

You can execute command like `sampletool --name "foo"`.
Expand Down Expand Up @@ -605,6 +613,25 @@ By setting this attribute on a parameter, the custom parser will be called when
ConsoleApp.Run(args, ([Vector3Parser] Vector3 position) => Console.WriteLine(position));
```

### Double-dash escaping

Arguments after double-dash (`--`) can be received as escaped arguments without being parsed. This is useful when creating commands like `dotnet run`.
```csharp
// dotnet run --project foo.csproj -- --foo 100 --bar bazbaz
var app = ConsoleApp.Create();
app.Add("run", (string project, ConsoleAppContext context) =>
{
// run --project foo.csproj -- --foo 100 --bar bazbaz
Console.WriteLine(string.Join(" ", context.Arguments));
// --project foo.csproj
Console.WriteLine(string.Join(" ", context.CommandArguments!));
// --foo 100 --bar bazbaz
Console.WriteLine(string.Join(" ", context.EscapedArguments!));
});
app.Run(args);
```
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.

### Syntax Parsing Policy and Performance

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.
Expand Down
4 changes: 3 additions & 1 deletion sandbox/GeneratorSandbox/GeneratorSandbox.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>13</LangVersion>

<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
Expand Down
49 changes: 28 additions & 21 deletions sandbox/GeneratorSandbox/Program.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
#nullable enable

using ConsoleAppFramework;
using GeneratorSandbox;
using Microsoft.Extensions.DependencyInjection;
using System.Linq;
using System.Text.Json;
//using Microsoft.Extensions.Configuration;
//using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -29,14 +31,30 @@
// services.Configure<PositionOptions>(configuration.GetSection("Position"));
// });

//app.Add<MyCommand>();
//app.Run(args);
// sc.BuildServiceProvider()

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

ConsoleApp.Run(args, () => { });
// dotnet run --project foo.csproj -- --foo 100 --bar bazbaz

var app = ConsoleApp.Create();

app.Add("run", (string project, ConsoleAppContext context) =>
{
// run --project foo.csproj -- --foo 100 --bar bazbaz
Console.WriteLine(string.Join(" ", context.Arguments));

// --project foo.csproj
Console.WriteLine(string.Join(" ", context.CommandArguments!));

// --foo 100 --bar bazbaz
Console.WriteLine(string.Join(" ", context.EscapedArguments!));
});

app.Run(args);



//ConsoleApp.Run(args, (ConsoleAppContext ctx) => { });

// inject options
//public class MyCommand(IOptions<PositionOptions> options)
Expand Down Expand Up @@ -115,24 +133,13 @@ public class MyService

public class MyCommands
{
/// <summary>
///
/// </summary>
/// <param name="msg">foobarbaz!</param>
[Command("Error1")]
public void Error1(string msg = @"\")
public void Cmd1(int x, int y, ConsoleAppContext ctx)
{
Console.WriteLine(msg);
}
[Command("Error2")]
public void Error2(string msg = "\\")
{
Console.WriteLine(msg);
}
[Command("Output")]
public void Output(string msg = @"\\")

public Task Cmd2(int x, int y)
{
Console.WriteLine(msg); // 「\」
return Task.CompletedTask;
}
}

Expand Down
45 changes: 43 additions & 2 deletions src/ConsoleAppFramework.Abstractions/ConsoleApp.Abstractions.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,52 @@
namespace ConsoleAppFramework;
using System.ComponentModel;

namespace ConsoleAppFramework;

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

public record class ConsoleAppContext(string CommandName, string[] Arguments, object? State);
public record ConsoleAppContext
{
public string CommandName { get; init; }
public string[] Arguments { get; init; }
public object? State { get; init; }

[EditorBrowsable(EditorBrowsableState.Never)]
public int CommandDepth { get; }

[EditorBrowsable(EditorBrowsableState.Never)]
public int EscapeIndex { get; }

public ReadOnlySpan<string> CommandArguments
{
get => (EscapeIndex == -1)
? Arguments.AsSpan(CommandDepth)
: Arguments.AsSpan(CommandDepth, EscapeIndex - CommandDepth);
}

public ReadOnlySpan<string> EscapedArguments
{
get => (EscapeIndex == -1)
? Array.Empty<string>()
: Arguments.AsSpan(EscapeIndex + 1);
}

public ConsoleAppContext(string commandName, string[] arguments, object? state, int commandDepth, int escapeIndex)
{
this.CommandName = commandName;
this.Arguments = arguments;
this.State = state;
this.CommandDepth = commandDepth;
this.EscapeIndex = escapeIndex;
}

public override string ToString()
{
return string.Join(" ", Arguments);
}
}

public abstract class ConsoleAppFilter(ConsoleAppFilter next)
{
Expand Down
20 changes: 10 additions & 10 deletions src/ConsoleAppFramework/Command.cs
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ public record class CommandParameter
// increment = false when passed from [Argument]
public string BuildParseMethod(int argCount, string argumentName, bool increment)
{
var incrementIndex = increment ? "!TryIncrementIndex(ref i, args.Length) || " : "";
var incrementIndex = increment ? "!TryIncrementIndex(ref i, commandArgs.Length) || " : "";
return Core(Type.TypeSymbol, false);

string Core(ITypeSymbol type, bool nullable)
Expand All @@ -193,7 +193,7 @@ string Core(ITypeSymbol type, bool nullable)

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

switch (type.SpecialType)
Expand All @@ -202,11 +202,11 @@ string Core(ITypeSymbol type, bool nullable)
// no parse
if (increment)
{
return $"if (!TryIncrementIndex(ref i, args.Length)) {{ ThrowArgumentParseFailed(\"{argumentName}\", args[i]); }} else {{ arg{argCount} = args[i]; }}";
return $"if (!TryIncrementIndex(ref i, commandArgs.Length)) {{ ThrowArgumentParseFailed(\"{argumentName}\", commandArgs[i]); }} else {{ arg{argCount} = commandArgs[i]; }}";
}
else
{
return $"arg{argCount} = args[i];";
return $"arg{argCount} = commandArgs[i];";
}

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

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

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

if (tryParseKnownPrimitive)
{
return $"if ({incrementIndex}!{type.ToFullyQualifiedFormatDisplayString()}.TryParse(args[i], {outArgVar})) {{ ThrowArgumentParseFailed(\"{argumentName}\", args[i]); }}{elseExpr}";
return $"if ({incrementIndex}!{type.ToFullyQualifiedFormatDisplayString()}.TryParse(commandArgs[i], {outArgVar})) {{ ThrowArgumentParseFailed(\"{argumentName}\", commandArgs[i]); }}{elseExpr}";
}
else if (tryParseIParsable)
{
return $"if ({incrementIndex}!{type.ToFullyQualifiedFormatDisplayString()}.TryParse(args[i], null, {outArgVar})) {{ ThrowArgumentParseFailed(\"{argumentName}\", args[i]); }}{elseExpr}";
return $"if ({incrementIndex}!{type.ToFullyQualifiedFormatDisplayString()}.TryParse(commandArgs[i], null, {outArgVar})) {{ ThrowArgumentParseFailed(\"{argumentName}\", commandArgs[i]); }}{elseExpr}";
}
else
{
return $"try {{ arg{argCount} = System.Text.Json.JsonSerializer.Deserialize<{type.ToFullyQualifiedFormatDisplayString()}>(args[{(increment ? "++i" : "i")}], JsonSerializerOptions); }} catch {{ ThrowArgumentParseFailed(\"{argumentName}\", args[i]); }}";
return $"try {{ arg{argCount} = System.Text.Json.JsonSerializer.Deserialize<{type.ToFullyQualifiedFormatDisplayString()}>(commandArgs[{(increment ? "++i" : "i")}], JsonSerializerOptions); }} catch {{ ThrowArgumentParseFailed(\"{argumentName}\", commandArgs[i]); }}";
}
}
}
Expand Down
41 changes: 38 additions & 3 deletions src/ConsoleAppFramework/ConsoleAppBaseCode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,42 @@ internal interface IArgumentParser<T>
static abstract bool TryParse(ReadOnlySpan<char> s, out T result);
}

internal record class ConsoleAppContext(string CommandName, string[] Arguments, object? State);
internal record ConsoleAppContext
{
public string CommandName { get; init; }
public string[] Arguments { get; init; }
public object? State { get; init; }
internal int CommandDepth { get; }
internal int EscapeIndex { get; }

public ReadOnlySpan<string> CommandArguments
{
get => (EscapeIndex == -1)
? Arguments.AsSpan(CommandDepth)
: Arguments.AsSpan(CommandDepth, EscapeIndex - CommandDepth);
}

public ReadOnlySpan<string> EscapedArguments
{
get => (EscapeIndex == -1)
? Array.Empty<string>()
: Arguments.AsSpan(EscapeIndex + 1);
}

public ConsoleAppContext(string commandName, string[] arguments, object? state, int commandDepth, int escapeIndex)
{
this.CommandName = commandName;
this.Arguments = arguments;
this.State = state;
this.CommandDepth = commandDepth;
this.EscapeIndex = escapeIndex;
}

public override string ToString()
{
return string.Join(" ", Arguments);
}
}

internal abstract class ConsoleAppFilter(ConsoleAppFilter next)
{
Expand Down Expand Up @@ -329,12 +364,12 @@ static void ShowVersion()

static partial void ShowHelp(int helpId);

static async Task RunWithFilterAsync(string commandName, string[] args, ConsoleAppFilter invoker)
static async Task RunWithFilterAsync(string commandName, string[] args, int commandDepth, int escapeIndex, ConsoleAppFilter invoker)
{
using var posixSignalHandler = PosixSignalHandler.Register(Timeout);
try
{
await Task.Run(() => invoker.InvokeAsync(new ConsoleAppContext(commandName, args, null), posixSignalHandler.Token)).WaitAsync(posixSignalHandler.TimeoutToken);
await Task.Run(() => invoker.InvokeAsync(new ConsoleAppContext(commandName, args, null, commandDepth, escapeIndex), posixSignalHandler.Token)).WaitAsync(posixSignalHandler.TimeoutToken);
}
catch (Exception ex)
{
Expand Down
Loading