diff --git a/.github/workflows/toc.yml b/.github/workflows/toc.yml deleted file mode 100644 index fb0942c..0000000 --- a/.github/workflows/toc.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: TOC Generator - -on: - push: - paths: - - 'ReadMe.md' - -jobs: - generateTOC: - name: TOC Generator - runs-on: ubuntu-latest - steps: - - uses: technote-space/toc-generator@v2.4.0 - with: - TOC_TITLE: "## Table of Contents" diff --git a/ConsoleAppFramework.sln b/ConsoleAppFramework.sln index b162dfc..a591f4e 100644 --- a/ConsoleAppFramework.sln +++ b/ConsoleAppFramework.sln @@ -5,20 +5,10 @@ VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{1F399F98-7439-4F05-847B-CC1267B4B7F2}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsoleAppFramework", "src\ConsoleAppFramework\ConsoleAppFramework.csproj", "{AEBCDB61-F8FA-40EB-B6D9-636D112BC390}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sandbox", "sandbox", "{A2CF2984-E8E2-48FC-B5A1-58D74A2467E6}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SingleContainedApp", "sandbox\SingleContainedApp\SingleContainedApp.csproj", "{017F402E-36EA-46B3-A5AF-1773EEB7B755}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MultiContainedApp", "sandbox\MultiContainedApp\MultiContainedApp.csproj", "{5E16EBD8-3396-4952-9B2D-DD2E2E3C883B}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SingleContainedAppWithConfig", "sandbox\SingleContainedAppWithConfig\SingleContainedAppWithConfig.csproj", "{0B3BE82E-753A-415D-AD4E-90350C6E5C3D}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{AAD2D900-C305-4449-A9FC-6C7696FFEDFA}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsoleAppFramework.Tests", "tests\ConsoleAppFramework.Tests\ConsoleAppFramework.Tests.csproj", "{AF15C841-5D45-4E61-BFCE-A6E6B7BA7629}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{6DF6534A-0F9D-44A4-BF89-AE1F3B243914}" ProjectSection(SolutionItems) = preProject .dockerignore = .dockerignore @@ -28,11 +18,15 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution ReadMe.md = ReadMe.md EndProjectSection EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNetApp", "sandbox\AspNetApp\AspNetApp.csproj", "{E065696C-9DF6-4E68-933B-BFA0165781DD}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsoleAppFramework", "src\ConsoleAppFramework\ConsoleAppFramework.csproj", "{09BEEA7B-B6D3-4011-BCAB-6DF976713695}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GeneratorSandbox", "sandbox\GeneratorSandbox\GeneratorSandbox.csproj", "{ACDA48BA-0BFE-4917-B335-7836DAA5929A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFrameworkBenchmark", "sandbox\CliFrameworkBenchmark\CliFrameworkBenchmark.csproj", "{F558E4F2-1AB0-4634-B613-69DFE79894AF}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Net6WebApp", "sandbox\Net6WebApp\Net6WebApp.csproj", "{48781D9F-D3E0-4A72-ADD1-A47ECDC23F0A}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsoleAppFramework.GeneratorTests", "tests\ConsoleAppFramework.GeneratorTests\ConsoleAppFramework.GeneratorTests.csproj", "{C54F7FE8-650A-4DC7-877F-0DE929351800}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Net6Console", "sandbox\Net6Console\Net6Console.csproj", "{19E33348-979A-4283-A74D-0844CC384A88}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NativeAot", "sandbox\NativeAot\NativeAot.csproj", "{EC1A3299-6597-4AD2-92DE-EDF309875A97}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -40,51 +34,36 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {AEBCDB61-F8FA-40EB-B6D9-636D112BC390}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {AEBCDB61-F8FA-40EB-B6D9-636D112BC390}.Debug|Any CPU.Build.0 = Debug|Any CPU - {AEBCDB61-F8FA-40EB-B6D9-636D112BC390}.Release|Any CPU.ActiveCfg = Release|Any CPU - {AEBCDB61-F8FA-40EB-B6D9-636D112BC390}.Release|Any CPU.Build.0 = Release|Any CPU - {017F402E-36EA-46B3-A5AF-1773EEB7B755}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {017F402E-36EA-46B3-A5AF-1773EEB7B755}.Debug|Any CPU.Build.0 = Debug|Any CPU - {017F402E-36EA-46B3-A5AF-1773EEB7B755}.Release|Any CPU.ActiveCfg = Release|Any CPU - {017F402E-36EA-46B3-A5AF-1773EEB7B755}.Release|Any CPU.Build.0 = Release|Any CPU - {5E16EBD8-3396-4952-9B2D-DD2E2E3C883B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5E16EBD8-3396-4952-9B2D-DD2E2E3C883B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5E16EBD8-3396-4952-9B2D-DD2E2E3C883B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5E16EBD8-3396-4952-9B2D-DD2E2E3C883B}.Release|Any CPU.Build.0 = Release|Any CPU - {0B3BE82E-753A-415D-AD4E-90350C6E5C3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0B3BE82E-753A-415D-AD4E-90350C6E5C3D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0B3BE82E-753A-415D-AD4E-90350C6E5C3D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0B3BE82E-753A-415D-AD4E-90350C6E5C3D}.Release|Any CPU.Build.0 = Release|Any CPU - {AF15C841-5D45-4E61-BFCE-A6E6B7BA7629}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {AF15C841-5D45-4E61-BFCE-A6E6B7BA7629}.Debug|Any CPU.Build.0 = Debug|Any CPU - {AF15C841-5D45-4E61-BFCE-A6E6B7BA7629}.Release|Any CPU.ActiveCfg = Release|Any CPU - {AF15C841-5D45-4E61-BFCE-A6E6B7BA7629}.Release|Any CPU.Build.0 = Release|Any CPU - {E065696C-9DF6-4E68-933B-BFA0165781DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E065696C-9DF6-4E68-933B-BFA0165781DD}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E065696C-9DF6-4E68-933B-BFA0165781DD}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E065696C-9DF6-4E68-933B-BFA0165781DD}.Release|Any CPU.Build.0 = Release|Any CPU - {48781D9F-D3E0-4A72-ADD1-A47ECDC23F0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {48781D9F-D3E0-4A72-ADD1-A47ECDC23F0A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {48781D9F-D3E0-4A72-ADD1-A47ECDC23F0A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {48781D9F-D3E0-4A72-ADD1-A47ECDC23F0A}.Release|Any CPU.Build.0 = Release|Any CPU - {19E33348-979A-4283-A74D-0844CC384A88}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {19E33348-979A-4283-A74D-0844CC384A88}.Debug|Any CPU.Build.0 = Debug|Any CPU - {19E33348-979A-4283-A74D-0844CC384A88}.Release|Any CPU.ActiveCfg = Release|Any CPU - {19E33348-979A-4283-A74D-0844CC384A88}.Release|Any CPU.Build.0 = Release|Any CPU + {09BEEA7B-B6D3-4011-BCAB-6DF976713695}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {09BEEA7B-B6D3-4011-BCAB-6DF976713695}.Debug|Any CPU.Build.0 = Debug|Any CPU + {09BEEA7B-B6D3-4011-BCAB-6DF976713695}.Release|Any CPU.ActiveCfg = Release|Any CPU + {09BEEA7B-B6D3-4011-BCAB-6DF976713695}.Release|Any CPU.Build.0 = Release|Any CPU + {ACDA48BA-0BFE-4917-B335-7836DAA5929A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ACDA48BA-0BFE-4917-B335-7836DAA5929A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ACDA48BA-0BFE-4917-B335-7836DAA5929A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ACDA48BA-0BFE-4917-B335-7836DAA5929A}.Release|Any CPU.Build.0 = Release|Any CPU + {F558E4F2-1AB0-4634-B613-69DFE79894AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F558E4F2-1AB0-4634-B613-69DFE79894AF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F558E4F2-1AB0-4634-B613-69DFE79894AF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F558E4F2-1AB0-4634-B613-69DFE79894AF}.Release|Any CPU.Build.0 = Release|Any CPU + {C54F7FE8-650A-4DC7-877F-0DE929351800}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C54F7FE8-650A-4DC7-877F-0DE929351800}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C54F7FE8-650A-4DC7-877F-0DE929351800}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C54F7FE8-650A-4DC7-877F-0DE929351800}.Release|Any CPU.Build.0 = Release|Any CPU + {EC1A3299-6597-4AD2-92DE-EDF309875A97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EC1A3299-6597-4AD2-92DE-EDF309875A97}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EC1A3299-6597-4AD2-92DE-EDF309875A97}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EC1A3299-6597-4AD2-92DE-EDF309875A97}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution - {AEBCDB61-F8FA-40EB-B6D9-636D112BC390} = {1F399F98-7439-4F05-847B-CC1267B4B7F2} - {017F402E-36EA-46B3-A5AF-1773EEB7B755} = {A2CF2984-E8E2-48FC-B5A1-58D74A2467E6} - {5E16EBD8-3396-4952-9B2D-DD2E2E3C883B} = {A2CF2984-E8E2-48FC-B5A1-58D74A2467E6} - {0B3BE82E-753A-415D-AD4E-90350C6E5C3D} = {A2CF2984-E8E2-48FC-B5A1-58D74A2467E6} - {AF15C841-5D45-4E61-BFCE-A6E6B7BA7629} = {AAD2D900-C305-4449-A9FC-6C7696FFEDFA} - {E065696C-9DF6-4E68-933B-BFA0165781DD} = {A2CF2984-E8E2-48FC-B5A1-58D74A2467E6} - {48781D9F-D3E0-4A72-ADD1-A47ECDC23F0A} = {A2CF2984-E8E2-48FC-B5A1-58D74A2467E6} - {19E33348-979A-4283-A74D-0844CC384A88} = {A2CF2984-E8E2-48FC-B5A1-58D74A2467E6} + {09BEEA7B-B6D3-4011-BCAB-6DF976713695} = {1F399F98-7439-4F05-847B-CC1267B4B7F2} + {ACDA48BA-0BFE-4917-B335-7836DAA5929A} = {A2CF2984-E8E2-48FC-B5A1-58D74A2467E6} + {F558E4F2-1AB0-4634-B613-69DFE79894AF} = {A2CF2984-E8E2-48FC-B5A1-58D74A2467E6} + {C54F7FE8-650A-4DC7-877F-0DE929351800} = {AAD2D900-C305-4449-A9FC-6C7696FFEDFA} + {EC1A3299-6597-4AD2-92DE-EDF309875A97} = {A2CF2984-E8E2-48FC-B5A1-58D74A2467E6} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {7F3E353A-C125-4020-8481-11DC6496358C} diff --git a/src/ConsoleAppFramework/Icon.png b/Icon.png similarity index 100% rename from src/ConsoleAppFramework/Icon.png rename to Icon.png diff --git a/ReadMe.md b/ReadMe.md index e815cee..3a3662f 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -2,1196 +2,952 @@ 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 is an infrastructure of creating CLI(Command-line interface) tools, daemon, and multi batch application. You can create full feature of command line tool on only one-line. +ConsoleAppFramework v5 is Zero Dependency, Zero Overhead, Zero Reflection, Zero Allocation, AOT Safe CLI Framework powered by C# Source Generator; achieves exceptionally high performance 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`](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://user-images.githubusercontent.com/46207/147662718-f7756523-67a9-4295-b090-3cfc94203017.png) +![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. -This simplicity is by C# 10.0 and .NET 6 new features, similar as [ASP.NET Core 6.0 Minimal APIs](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis). - -Most minimal API is one-line(with top-level-statements, global-usings). +The magical performance is achieved by statically generating everything and parsing inline. Let's take a look at a minimal example: ```csharp -ConsoleApp.Run(args, (string name) => Console.WriteLine($"Hello {name}")); -``` - -Of course, ConsoleAppFramework has extensibility. +using ConsoleAppFramework; -```csharp -// Register two commands(use short-name, argument) -// hello -m -// sum [x] [y] -var app = ConsoleApp.Create(args); -app.AddCommand("hello", ([Option("m", "Message to display.")] string message) => Console.WriteLine($"Hello {message}")); -app.AddCommand("sum", ([Option(0)] int x, [Option(1)] int y) => Console.WriteLine(x + y)); -app.Run(); +// args: ./cmd --foo 10 --bar 20 +ConsoleApp.Run(args, (int foo, int bar) => Console.WriteLine($"Sum: {foo + bar}")); ``` -You can register public method as command. This provides a simple way to registering multiple commands. +Unlike typical Source Generators that use attributes as keys for generation, ConsoleAppFramework analyzes the provided lambda expressions or method references and generates the actual code body of the Run method. ```csharp -// AddCommands register as command. -// echo --msg --repeat(default = 3) -// sum [x] [y] -var app = ConsoleApp.Create(args); -app.AddCommands(); -app.Run(); - -public class Foo : ConsoleAppBase +namespace ConsoleAppFramework; + +internal static partial class ConsoleApp { - public void Echo(string msg, int repeat = 3) + public static void Run(string[] args, Action command) { - for (var i = 0; i < repeat; i++) - { - Console.WriteLine(msg); - } - } + if (TryShowHelpOrVersion(args, 2, -1)) return; - public void Sum([Option(0)]int x, [Option(1)]int y) - { - Console.WriteLine((x + y).ToString()); - } -} -``` + var arg0 = default(int); + var arg0Parsed = false; + var arg1 = default(int); + var arg1Parsed = false; -If you have many commands, you can define class separetely and use `AddAllCommandType` to register all commands one-line. + try + { + for (int i = 0; i < args.Length; i++) + { + var name = args[i]; -```csharp -// Register `Foo` and `Bar` as SubCommands(You can also use AddSubCommands to register manually). -// foo echo --msg -// foo sum [x] [y] -// bar hello2 -var app = ConsoleApp.Create(args); -app.AddAllCommandType(); -app.Run(); - -public class Foo : ConsoleAppBase -{ - public void Echo(string msg) - { - Console.WriteLine(msg); - } + switch (name) + { + case "--foo": + { + if (!int.TryParse(args[++i], out arg0)) { ThrowArgumentParseFailed("foo", args[i]); } + arg0Parsed = true; + break; + } + case "--bar": + { + if (!int.TryParse(args[++i], out arg1)) { ThrowArgumentParseFailed("bar", args[i]); } + arg1Parsed = true; + break; + } + default: + if (string.Equals(name, "--foo", StringComparison.OrdinalIgnoreCase)) + { + if (!int.TryParse(args[++i], out arg0)) { ThrowArgumentParseFailed("foo", args[i]); } + arg0Parsed = true; + break; + } + if (string.Equals(name, "--bar", StringComparison.OrdinalIgnoreCase)) + { + if (!int.TryParse(args[++i], out arg1)) { ThrowArgumentParseFailed("bar", args[i]); } + arg1Parsed = true; + break; + } + ThrowArgumentNameNotFound(name); + break; + } + } + if (!arg0Parsed) ThrowRequiredArgumentNotParsed("foo"); + if (!arg1Parsed) ThrowRequiredArgumentNotParsed("bar"); - public void Sum([Option(0)]int x, [Option(1)]int y) - { - Console.WriteLine((x + y).ToString()); + command(arg0!, arg1!); + } + catch (Exception ex) + { + Environment.ExitCode = 1; + if (ex is ValidationException) + { + LogError(ex.Message); + } + else + { + LogError(ex.ToString()); + } + } } -} -public class Bar : ConsoleAppBase -{ - public void Hello2() + static partial void ShowHelp(int helpId) { - Console.WriteLine("H E L L O"); + Log(""" +Usage: [options...] [-h|--help] [--version] + +Options: + --foo (Required) + --bar (Required) +"""); } } ``` - ConsoleAppFramework is built on [.NET Generic Host](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/generic-host), you can use configuration, logging, DI, lifetime management by Microsoft.Extensions packages. ConsoleAppFramework do parameter binding from string args, routing many commands, dotnet style help builder, etc. - -![image](https://user-images.githubusercontent.com/46207/72047323-a08e0c80-32fd-11ea-850a-7f926adf3d22.png) - -Here is the full-sample of power of ConsoleAppFramework. - -```csharp -// You can use full feature of Generic Host(same as ASP.NET Core). - -var builder = ConsoleApp.CreateBuilder(args); -builder.ConfigureServices((ctx,services) => -{ - // Register EntityFramework database context - services.AddDbContext(); - - // Register appconfig.json to IOption - services.Configure(ctx.Configuration); - - // Using Cysharp/ZLogger for logging to file - services.AddLogging(logging => - { - logging.AddZLoggerFile("log.txt"); - }); -}); - -var app = builder.Build(); - -// setup many command, async, short-name/description option, subcommand, DI -app.AddCommand("calc-sum", (int x, int y) => Console.WriteLine(x + y)); -app.AddCommand("sleep", async ([Option("t", "seconds of sleep time.")] int time) => -{ - await Task.Delay(TimeSpan.FromSeconds(time)); -}); -app.AddSubCommand("verb", "childverb", () => Console.WriteLine("called via 'verb childverb'")); - -// You can insert all public methods as sub command => db select / db insert -// or AddCommand() all public methods as command => select / insert -app.AddSubCommands(); +As you can see, the code is straightforward and simple, making it easy to imagine the execution cost of the framework portion. That's right, it's zero. This technique was influenced by Rust's macros. Rust has [Attribute-like macros and Function-like macros](https://doc.rust-lang.org/book/ch19-06-macros.html), and ConsoleAppFramework's generation can be considered as Function-like macros. -// some argument from DI. -app.AddRootCommand((ConsoleAppContext ctx, IOptions config, string name) => { }); +The `ConsoleApp` class, along with everything else, is generated entirely by the Source Generator, resulting in no dependencies, including ConsoleAppFramework itself. This characteristic should contribute to the small assembly size and ease of handling, including support for Native AOT. -app.Run(); +Moreover, CLI applications typically involve single-shot execution from a cold start. As a result, common optimization techniques such as dynamic code generation (IL Emit, ExpressionTree.Compile) and caching (ArrayPool) do not work effectively. ConsoleAppFramework generates everything statically in advance, achieving performance equivalent to optimized hand-written code without reflection or boxing. -// ---- +ConsoleAppFramework offers a rich set of features as a framework. The Source Generator analyzes which modules are being used and generates the minimal code necessary to implement the desired functionality. -[Command("db")] -public class DatabaseApp : ConsoleAppBase, IAsyncDisposable -{ - readonly ILogger logger; - readonly MyDbContext dbContext; - readonly IOptions config; - - // you can get DI parameters. - public DatabaseApp(ILogger logger,IOptions config, MyDbContext dbContext) - { - this.logger = logger; - this.dbContext = dbContext; - this.config = config; - } - - [Command("select")] - public async Task QueryAsync(int id) - { - // select * from... - } - - // also allow defaultValue. - [Command("insert")] - public async Task InsertAsync(string value, int id = 0) - { - // insert into... - } - - // support cleanup(IDisposable/IAsyncDisposable) - public async ValueTask DisposeAsync() - { - await dbContext.DisposeAsync(); - } -} - -public class MyConfig -{ - public string FooValue { get; set; } = default!; - public string BarValue { get; set; } = default!; -} -``` - -ConsoleAppFramework can create easily to many command application. Also enable to use GenericHost configuration is best way to share configuration/workflow when creating batch application for other .NET web app. If tool is for CI, git pull and run by `dotnet run -- [Command] [Option]` is very helpful. - -dotnet's standard CommandLine api - [System.CommandLine](https://github.com/dotnet/command-line-api) is low level, require many boilerplate codes. ConsoleAppFramework is like ASP.NET Core in CLI Applications, no needs boilerplate. However, with the power of Generic Host, it is simple and easy, but much more powerful. - - - -## Table of Contents - -- [Getting Started](#getting-started) -- [ConsoleApp / ConsoleAppBuilder](#consoleapp--consoleappbuilder) -- [Delegate convention](#delegate-convention) -- [AddCommand](#addcommand) - - [`AddRootCommand`](#addrootcommand) - - [`AddCommand` / `AddCommands`](#addcommand--addcommandst) - - [`AddSubCommand` / `AddSubCommands`](#addsubcommand--addsubcommandst) - - [`AddAllCommandType`](#addallcommandtype) -- [Complex Argument](#complex-argument) -- [Exit Code](#exit-code) -- [Implicit Using](#implicit-using) -- [CommandAttribute](#commandattribute) -- [OptionAttribute](#optionattribute) -- [Command parameters validation](#command-parameters-validation) -- [Daemon](#daemon) -- [Abort Timeout](#abort-timeout) -- [Filter](#filter) -- [Logging](#logging) -- [Configuration](#configuration) -- [DI](#di) -- [Cleanup](#cleanup) -- [ConsoleAppContext](#consoleappcontext) -- [ConsoleAppOptions](#consoleappoptions) -- [Terminate handling in Console.Read](#terminate-handling-in-consoleread) -- [Publish to executable file](#publish-to-executable-file) -- [v3 Legacy Compatibility](#v3-legacy-compatibility) -- [License](#license) - - +* SIGINT/SIGTERM(Ctrl+C) handling with gracefully shutdown via `CancellationToken` +* Filter(middleware) pipeline to intercept before/after execution +* Exit code management +* Support for async commands +* Registration of multiple commands +* Registration of nested commands +* Setting option aliases and descriptions from code document comment +* `System.ComponentModel.DataAnnotations` attribute-based Validation +* Dependency Injection for command registration by type and public methods +* `Microsoft.Extensions`(Logging, Configuration, etc...) integration +* High performance value parsing via `ISpanParsable` +* Parsing of params arrays +* Parsing of JSON 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 -- -NuGet: [ConsoleAppFramework](https://www.nuget.org/packages/ConsoleAppFramework) +This library is distributed via NuGet, minimal requirement is .NET 8 and C# 12. -``` -Install-Package ConsoleAppFramework -``` +> PM> Install-Package [ConsoleAppFramework](https://www.nuget.org/packages/ConsoleAppFramework) + +ConsoleAppFramework is an analyzer (Source Generator) and does not have any dll references. When referenced, the entry point class `ConsoleAppFramework.ConsoleApp` is generated internally. -If you are using .NET 6, automatically enabled implicit global `using ConsoleAppFramework;`. So you can write one line code. +The first argument of `Run` or `RunAsync` can be `string[] args`, and the second argument can be any lambda expression, method, or function reference. Based on the content of the second argument, the corresponding function is automatically generated. ```csharp +using ConsoleAppFramework; + ConsoleApp.Run(args, (string name) => Console.WriteLine($"Hello {name}")); ``` You can execute command like `sampletool --name "foo"`. -The Option parser is no longer needed. You can also use the `OptionAttribute` to describe the parameter and set short-name. +* The return value can be `void`, `int`, `Task`, or `Task` + * If an `int` is returned, that value will be set to `Environment.ExitCode` +* By default, option argument names are converted to `--lower-kebab-case` + * For example, `jsonValue` becomes `--json-value` + * Option argument names are case-insensitive, but lower-case matches faster -```csharp -ConsoleApp.Run(args, ([Option("n", "name of send user.")] string name) => Console.WriteLine($"Hello {name}")); -``` - -``` -Usage: sampletool [options...] +When passing a method, you can write it as follows: -Options: - -n, --name name of user. (Required) +```csharp +ConsoleApp.Run(args, Sum); -Commands: - help Display help. - version Display version. +void Sum(int x, int y) => Console.Write(x + y); ``` -Method parameter will be required parameter, optional parameter will be oprional parameter with default value. Also support boolean flag, if parameter is bool, in default it will be optional parameter and with `--foo` set true to parameter. +Additionally, for static functions, you can pass them as function pointers. In that case, the managed function pointer arguments will be generated, resulting in maximum performance. ```csharp -// lambda expression does not support default value so require to use local function -static void Hello([Option("m")]string message, [Option("e")] bool end, [Option("r")] int repeat = 3) +unsafe { - for (int i = 0; i < repeat; i++) - { - Console.WriteLine(message); - } - if (end) - { - Console.WriteLine("END"); - } + ConsoleApp.Run(args, &Sum); } -ConsoleApp.Run(args, Hello); +static void Sum(int x, int y) => Console.Write(x + y); ``` ```csharp -Options: - -m, --message (Required) - -e, --end (Optional) - -r, --repeat (Default: 3) +public static unsafe void Run(string[] args, delegate* managed command) ``` -`help` command (or no argument to pass) and `version` command is enabled in default(You can disable this in options or can override by add same name of command). Also enables `command --help` option. This help format is similar as `dotnet` command, `version` command shows `AssemblyInformationalVersion` or `AssemblylVersion`. +Unfortunately, currently [static lambdas cannot be assigned to function pointers](https://github.com/dotnet/csharplang/discussions/6746), so defining a named function is necessary. -``` -> sampletool help -Usage: sampletool [options...] - -Options: - -n, --name name of user. (Required) - -r, --repeat repeat count. (Default: 3) - -Commands: - help Display help. - version Display version. -``` - -``` -> sampletool version -1.0.0 -``` - -You can use `Run` or `AddCommands` to add multi commands easily. +When defining an asynchronous method using a lambda expression, the `async` keyword is required. ```csharp -ConsoleApp.Run(args); - -// require to inherit ConsoleAppBase -public class MyCommands : ConsoleAppBase +// --foo, --bar +await ConsoleApp.RunAsync(args, async (int foo, int bar, CancellationToken cancellationToken) => { - // You can receive DI services in constructor. - - // All public methods is registred. + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + Console.WriteLine($"Sum: {foo + bar}"); +}); +``` - // Using [RootCommand] attribute will be root-command - [RootCommand] - public void Hello( - [Option("n", "name of send user.")] string name, - [Option("r", "repeat count.")] int repeat = 3) - { - for (int i = 0; i < repeat; i++) - { - Console.WriteLine($"Hello My ConsoleApp from {name}"); - } - } +You can use either the `Run` or `RunAsync` method for invocation. It is optional to use `CancellationToken` as an argument. This becomes a special parameter and is excluded from the command options. Internally, it uses [`PosixSignalRegistration`](https://learn.microsoft.com/en-us/dotnet/api/system.runtime.interopservices.posixsignalregistration) to handle `SIGINT`, `SIGTERM`, and `SIGKILL`. When these signals are invoked (e.g., Ctrl+C), the CancellationToken is set to CancellationRequested. If `CancellationToken` is not used as an argument, these signals will not be handled, and the program will terminate immediately. For more details, refer to the [CancellationToken and Gracefully Shutdown](#cancellationtokengracefully-shutdown-and-timeout) section. - // [Option(int)] describes that parameter is passed by index - [Command("escape")] - public void UrlEscape([Option(0)] string input) - { - Console.WriteLine(Uri.EscapeDataString(input)); - } +Option aliases and Help, Version +--- +By default, if `-h` or `--help` is provided, or if no arguments are passed, the help display will be invoked. - // define async method returns Task - [Command("timer")] - public async Task Timer([Option(0)] uint waitSeconds) - { - Console.WriteLine(waitSeconds + " seconds"); - while (waitSeconds != 0) - { - // ConsoleAppFramework does not stop immediately on terminate command(Ctrl+C) - // for allows gracefully shutdown(keeping safe cleanup) - // so you should pass Context.CancellationToken to async method. - // If not, abort timeout by HostOptions.ShutdownTimeout(default is 00:00:05). - await Task.Delay(TimeSpan.FromSeconds(1), Context.CancellationToken); - waitSeconds--; - Console.WriteLine(waitSeconds + " seconds"); - } - } -} +```csharp +ConsoleApp.Run(args, (string message) => Console.Write($"Hello, {message}")); ``` -You can call like +```txt +Usage: [options...] [-h|--help] [--version] +Options: + --message (Required) ``` -sampletool -n "foo" -r 3 -sampletool escape http://foo.bar/ -sampletool timer 10 -``` - -This is recommended way to register multi commands. -If you omit `[Command]` attribute, command and option name is used by there name and convert to `kebab-case` in default. +In ConsoleAppFramework, instead of using attributes, you can provide descriptions and aliases for functions by writing Document Comments. This avoids the common issue in frameworks where arguments become cluttered with attributes, making the code difficult to read. With this approach, a natural writing style is achieved. ```csharp +ConsoleApp.Run(args, Commands.Hello); -// Command is url-escape -// Option is --input-file -public void UrlEscape(string inputFile) +static class Commands { + /// + /// Display Hello. + /// + /// -m, Message to show. + public static void Hello(string message) => Console.Write($"Hello, {message}"); } ``` -This converting behaviour can configure by `ConsoleAppOptions.NameConverter`. +```txt +Usage: [options...] [-h|--help] [--version] -ConsoleApp / ConsoleAppBuilder ---- -`ConsoleApp` is an entrypoint of creating ConsoleAppFramework app. It has three APIs, `Create`, `CreateBuilder`, `CreateFromHostBuilder` and `Run`. +Display Hello. -```csharp -// Create is shorthand of CraeteBuilder(args).Build(); -var app = ConsoleApp.Create(args); +Options: + -m|--message Message to show. (Required) +``` -// Builder returns IHost so you can configure application hosting option. -var app = ConsoleApp.CreateBuilder(args) - .ConfigureServices(services => - { - }) - .Build(); +To add aliases to parameters, list the aliases separated by `|` before the comma in the comment. For example, if you write a comment like `-a|-b|--abcde, Description.`, then `-a`, `-b`, and `--abcde` will be treated as aliases, and `Description.` will be the description. -// Run is shorthand of Create(args).AddRootCommand(rootCommand).Run(); -// If you want to create simple app, this API is faster. -ConsoleApp.Run(args, /* lambda expression */); +Unfortunately, due to current C# specifications, lambda expressions and [local functions do not support document comments](https://github.com/dotnet/csharplang/issues/2110), so a class is required. -// Run is shorthand of Create(args).AddCommands().Run(); -// AddCommands is recommend option to register many commands. -ConsoleApp.Run(args); -``` +In addition to `-h|--help`, there is another special built-in option: `--version`. This displays the `AssemblyInformationalVersion` or `AssemblyVersion`. -When calling `Create/CreateBuilder/CreateFromHostBuilder`, also configure `ConsoleAppOptions`. Full option details, see [ConsoleAppOptions](#consoleappoptions) section. +Command +--- +If you want to register multiple commands or perform complex operations (such as adding filters), instead of using `Run/RunAsync`, obtain the `ConsoleAppBuilder` using `ConsoleApp.Create()`. Call `Add`, `Add`, or `UseFilter` multiple times on the `ConsoleAppBuilder` to register commands and filters, and finally execute the application using `Run` or `RunAsync`. ```csharp -var app = ConsoleApp.Create(args, options => -{ - options.ShowDefaultCommand = false; - options.NameConverter = x => x.ToLower(); -}); -``` +var app = ConsoleApp.Create(); -Advanced API of `ConsoleApp`, `CreateFromHostBuilder` creates ConsoleApp from IHostBuilder. +app.Add("", (string msg) => Console.WriteLine(msg)); +app.Add("echo", (string msg) => Console.WriteLine(msg)); +app.Add("sum", (int x, int y) => Console.WriteLine(x + y)); -```csharp -// Setup services outside of ConsoleAppFramework. -var hostBuilder = Host.CreateDefaultBuilder() - .ConfigureServices(); - -var app = ConsoleApp.CreateFromHostBuilder(hostBuilder); +// --msg +// echo --msg +// sum --x --y +app.Run(args); ``` -`ConsoleAppBuilder` itself is `IHostBuilder` so you can use any configuration methods like `ConfigureServices`, `ConfigureLogging`, etc. If method chain is not returns `ConsoleAppBuilder`(for example, using external lib's extension methods), can not get `ConsoleApp` directly. In that case, use `BuildAsConsoleApp()` instead of `Build()`. - -`ConsoleApp` exposes some utility properties. +The first argument of `Add` is the command name. If you specify an empty string `""`, it becomes the root command. Unlike parameters, command names are case-sensitive and cannot have multiple names. -* `IHost` Host -* `ILogger` Logger -* `IServiceProvider` Services -* `IConfiguration` Configuration -* `IHostEnvironment` Environment -* `IHostApplicationLifetime` Lifetime - -`Run()` and `RunAsync(CancellationToken)` to finally invoke application. Run is shorthand of `RunAsync().GetAwaiter().GetResult()` so receives same result of `await RunAsync()`. On Entrypoint, there is not much need to do `await RunAsync()`. Therefore, it is usually a good to choose `Run()`. - -Delegate convention ---- -`AddCommand` accepts `Delegate` in argument. In C# 10.0 allows naturaly syntax of lambda expressions. +With `Add`, you can add multiple commands at once using a class-based approach, where public methods are treated as commands. If you want to write document comments for multiple commands, this approach allows for cleaner code, so it is recommended. Additionally, as mentioned later, you can also write clean code for Dependency Injection (DI) using constructor injection. ```csharp -app.AddCommand("no-argument", () => { }); -app.AddCommand("any-arguments", (int x, string y, TimeSpan z) => { }); -app.AddCommand("instance", new MyClass().Cmd); -app.AddCommand("async", async () => { }); -app.AddCommand("attribute", ([Option("msg")]string message) => { }); +var app = ConsoleApp.Create(); +app.Add(); +app.Run(args); -static void Hello1() { } -app.AddCommand("local-static", Hello1); - -void Hello2() { } -app.AddCommand("local-method", Hello2); - -async Task Async() { } -app.AddCommand("async-method", Async); +public class MyCommands +{ + /// Root command test. + /// -m, Message to show. + [Command("")] + public void Root(string msg) => Console.WriteLine(msg); -void OptionalParameter(int x = 10, int y = 20) { } -app.AddCommand("optional", OptionalParameter); + /// Display message. + /// Message to show. + public void Echo(string msg) => Console.WriteLine(msg); -public class MyClass -{ - public void Cmd() - { - Console.WriteLine("OK"); - } + /// Sum parameters. + /// left value. + /// right value. + public void Sum(int x, int y) => Console.WriteLine(x + y); } ``` -lambda expressions can not use optional parameter so if you want to need it, using local/static functions. +When you check the registered commands with `--help`, it will look like this. Note that you can register multiple `Add` and also add commands using `Add`. -Delegate(both lambda and method) allows to receive `ConsoleAppContext` or any your DI types. DI types is ignored as parameter. +```txt +Usage: [command] [options...] [-h|--help] [--version] -```csharp -// option is --param1, --param2 -app.AddCommand("di", (ConsoleAppContext ctx, ILogger logger, int param1, int param2) => { }); -``` +Root command test. -AddCommand ---- -### `AddRootCommand` +Options: + -m|--msg Message to show. (Required) -`RootCommand` means default(no command name) command of application. `ConsoleApp.Run(Delegate)` uses root command. +Commands: + echo Display message. + sum Sum parameters. +``` -### `AddCommand` / `AddCommands` +By default, the command name is derived from the method name converted to `lower-kebab-case`. However, you can change the name to any desired value using the `[Command(string commandName)]` attribute. -`AddCommand` requires first argument as command-name. `AddCommands` allows to register many command via `ConsoleAppBase` `ConsoleAppBase` has `Context`, it has executing information and `CancellationToken`. +If the class implements `IDisposable` or `IAsyncDisposable`, the Dispose or DisposeAsync method will be called after the command execution. -```csharp -// Commands: -// hello -// world -app.AddCommands(); -app.Run(); +### Nested command -// Inherit ConsoleAPpBase -public class MyCommands : ConsoleAppBase, IDisposable -{ - readonly ILogger logger; +You can create a deep command hierarchy by adding commands with paths separated by space(` `) when registering them. This allows you to add commands at nested levels. - // You can receive DI services in constructor. - public MyCommands(ILogger logger) - { - this.logger = logger; - } - - // All public methods is registered. - - // Using [RootCommand] attribute will be root-command - [RootCommand] - public void Hello() - { - // Context has any useful information. - Console.WriteLine(this.Context.Timestamp); - } +```csharp +var app = ConsoleApp.Create(); - public async Task World() - { - await Task.Delay(1000, this.Context.CancellationToken); - } +app.Add("foo", () => { }); +app.Add("foo bar", () => { }); +app.Add("foo bar barbaz", () => { }); +app.Add("foo baz", () => { }); - // If implements IDisposable, called for cleanup - public void Dispose() - { - } -} +// Commands: +// foo +// foo bar +// foo bar barbaz +// foo baz +app.Run(args); ``` -### `AddSubCommand` / `AddSubCommands` - -`AddSubCommand(string parentCommandName, string commandName, Delegate command)` registers nested command. +`Add` can also add commands to a hierarchy by passing a `string commandPath` argument. ```csharp +var app = ConsoleApp.Create(); +app.Add("foo"); + // Commands: -// foo bar1 -// foo bar2 -// foo bar3 -app.AddSubCommand("foo", "bar1", () => { }); -app.AddSubCommand("foo", "bar2", () => { }); -app.AddSubCommand("foo", "bar3", () => { }); +// foo Root command test. +// foo echo Display message. +// foo sum Sum parameters. +app.Run(args); ``` -`AddSubCommands` is similar as `AddCommands` but used type-name(or `[Command]` name) as parentCommandName. +### Performance of Commands + +In `ConsoleAppFramework`, the number and types of registered commands are statically determined at compile time. For example, let's register the following four commands: ```csharp -// Commands: -// my-commands hello -// my-commands world -app.AddSubCommands(); +app.Add("foo", () => { }); +app.Add("foo bar", (int x, int y) => { }); +app.Add("foo bar barbaz", (DateTime dateTime) => { }); +app.Add("foo baz", async (string foo = "test", CancellationToken cancellationToken = default) => { }); ``` -### `AddAllCommandType` - -`AddAllCommandType` searches all `ConsoleAppBase` type in assembly and register by `AddSubCommands`. +The Source Generator generates four fields and holds them with specific types. ```csharp -// Commands: -// foo echo -// foo sum -// bar hello2 -app.AddAllCommandType(); - -// Batches. -public class Foo : ConsoleAppBase +partial struct ConsoleAppBuilder { - public void Echo(string msg) - { - Console.WriteLine(msg); - } + Action command0 = default!; + Action command1 = default!; + Action command2 = default!; + Func command3 = default!; - public void Sum(int x, int y) + partial void AddCore(string commandName, Delegate command) { - Console.WriteLine((x + y).ToString()); + switch (commandName) + { + case "foo": + this.command0 = Unsafe.As(command); + break; + case "foo bar": + this.command1 = Unsafe.As>(command); + break; + case "foo bar barbaz": + this.command2 = Unsafe.As>(command); + break; + case "foo baz": + this.command3 = Unsafe.As>(command); + break; + default: + break; + } } } +``` + +This ensures the fastest execution speed without any additional unnecessary allocations such as arrays and without any boxing since it holds static delegate types. -public class Bar : ConsoleAppBase +Command routing also generates a switch of nested string constants. + +```csharp +partial void RunCore(string[] args) { - public void Hello2() + if (args.Length == 0) + { + ShowHelp(-1); + return; + } + switch (args[0]) { - Console.WriteLine("H E L L O"); + case "foo": + if (args.Length == 1) + { + RunCommand0(args, args.AsSpan(1), command0); + return; + } + switch (args[1]) + { + case "bar": + if (args.Length == 2) + { + RunCommand1(args, args.AsSpan(2), command1); + return; + } + switch (args[2]) + { + case "barbaz": + RunCommand2(args, args.AsSpan(3), command2); + break; + default: + RunCommand1(args, args.AsSpan(2), command1); + break; + } + break; + case "baz": + RunCommand3(args, args.AsSpan(2), command3); + break; + default: + RunCommand0(args, args.AsSpan(1), command0); + break; + } + break; + default: + ShowHelp(-1); + break; } } ``` -This is most easy to create many commands so useful for application batch that requires many many command. - -Commands are searched from loaded assemblies(in default `AppDomain.CurrentDomain.GetAssemblies()`), when does not touch other assemblies type, it will be trimmed and can not load it. In that case, use `AddAllCommandType(params Assembly[] searchAssemblies)` overload to pass target assembly, for example `AddAllCommandType(typeof(Foo).Assembly)` preserve types. +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. -Complex Argument +Parse and Value Binding --- -If the argument is not primitive, you can pass JSON string. +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. ```csharp -public class ComplexArgTest : ConsoleAppBase -{ - public void Foo(int[] array, Person person) - { - Console.WriteLine(string.Join(", ", array)); - Console.WriteLine(person.Age + ":" + person.Name); - } -} +ConsoleApp.Run(args, ( + [Argument]DateTime dateTime, // Argument + [Argument]Guid guidvalue, // + int intVar, // required + bool boolFlag, // flag + MyEnum enumValue, // enum + int[] array, // array + MyClass obj, // object + string optional = "abcde", // optional + double? nullableValue = null, // nullable + params string[] paramsArray // params + ) => { }); +``` -public class Person -{ - public int Age { get; set; } - public string Name { get; set; } -} -``` +When using `ConsoleApp.Run`, you can check the syntax of the command line in the tooltip to see how it is generated. -You can call like here. +![image](https://github.com/Cysharp/ConsoleAppFramework/assets/46207/af480566-adac-4767-bd5e-af89ab6d71f1) -``` -> sampletool -array [10,20,30] -person {"Age":10,"Name":"foo"} +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. -# including space, use escaping -> SampleApp.exe -array [10,20,30] -person "{\"Age\":10,\"Name\":\"foo bar\"}" -``` +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. -> be careful with JSON string double quotation. +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` (`DateTime`, `DateTimeOffset`, `Guid`, `BigInteger`, `Complex`, `Half`, `Int128`, etc.), [IParsable.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.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. -For the array handling, it can be a treat without correct JSON. -e.g. one-length argument can handle without `[]`. +For `enum`, it is parsed using `Enum.TryParse(ignoreCase: true)`. -```csharp -Foo(int[] array) -> SampleApp.exe -array 9999 -``` +`bool` is treated as a flag and is always optional. It becomes `true` when the parameter name is passed. -multiple-argument can handle by split with ` ` or `,`. +### Array -```csharp -Foo(int[] array) -> SampleApp.exe -array "11 22 33" -> SampleApp.exe -array "11,22,33" -> SampleApp.exe -array "[11,22,33]" -``` +Array parsing has three special patterns. -string argument can handle without `"`. +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 `[]`. -```csharp -Foo(string[] array) -> SampleApp.exe -array hello -> SampleApp.exe -array "foo bar baz" -> SampleApp.exe -array foo,bar,baz -> SampleApp.exe -array "["foo","bar","baz"]" -``` +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"]`. -Exit Code ---- -If the method returns `int` or `Task` or `ValueTask value, ConsoleAppFramework will set the return value to the exit code. +### Object + +If none of the above cases apply, `JsonSerializer.Deserialize` 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. + +### Custom Value Converter + +To perform custom binding to existing types that do not support `ISpanParsable`, 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`'s `static bool TryParse(ReadOnlySpan s, out Vector3 result)` as follows: ```csharp -public class ExampleApp : ConsoleAppBase +[AttributeUsage(AttributeTargets.Parameter)] +public class Vector3ParserAttribute : Attribute, IArgumentParser { - [Command("exit")] - public int ExitCode() - { - return 12345; - } - - [Command("exit-with-task")] - public async Task ExitCodeWithTask() + public static bool TryParse(ReadOnlySpan s, out Vector3 result) { - return 54321; + Span ranges = stackalloc Range[3]; + var splitCount = s.Split(ranges, ','); + if (splitCount != 3) + { + result = default; + return false; + } + + float x; + float y; + float z; + if (float.TryParse(s[ranges[0]], out x) && float.TryParse(s[ranges[1]], out y) && float.TryParse(s[ranges[2]], out z)) + { + result = new Vector3(x, y, z); + return true; + } + + result = default; + return false; } } ``` -> **NOTE**: If the method throws an unhandled exception, ConsoleAppFramework always set `1` to the exit code. +By setting this attribute on a parameter, the custom parser will be called when parsing the args. -Implicit Using ---- -In .NET 6, `global using ConsoleAppFramework` is enabled in default. If you remove global using, setup this element to target `.csproj`. - -```xml - - - +```csharp +ConsoleApp.Run(args, ([Vector3Parser] Vector3 position) => Console.WriteLine(position)); ``` -CommandAttribute +### 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. + +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. + +[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. + +CancellationToken(Gracefully Shutdown) and Timeout --- -`CommandAttribute` enables subscommand on `RunConsoleAppFramework()`(for single type CLI app), changes command name on `RunConsoleAppFramework()`(for muilti type command routing), also describes the description. +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. ```csharp -RunConsoleAppFramework(); - -public class App : ConsoleAppBase -{ - // as Root Command(no command argument) - public void Run() - { - } +using var posixSignalHandler = PosixSignalHandler.Register(ConsoleApp.Timeout); +var arg0 = posixSignalHandler.Token; - [Command("sec", "sub comman of this app")] - public void Second() - { - } -} +await Task.Run(() => command(arg0!)).WaitAsync(posixSignalHandler.TimeoutToken); ``` -```csharp -RunConsoleAppFramework(); +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. -public class App2 : ConsoleAppBase -{ - // routing command: `app2 exec` - [Command("exec", "exec app.")] - public void Exec1() - { - } -} +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. -// command attribute also can use to class. -[Command("mycmd")] -public class App3 : ConsoleAppBase -{ - // routing command: `mycmd e2` - [Command("e2", "exec app 2.")] - public void ExecExec() - { - } -} -``` +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. -OptionAttribute ---- -OptionAttribute configure parameter, it can set shortName or order index, and help description. +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. -If you want to add only description, set "" or null to shortName parameter. +Exit Code +--- +If the method returns `int` or `Task`, `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`. ```csharp -public void Hello( - [Option("n", "name of send user.")]string name, - [Option("r", "repeat count.")]int repeat = 3) -{ -} - -[Command("escape")] -public void UrlEscape([Option(0, "input of this command")]string input) -{ -} +// return Random ExitCode... +ConsoleApp.Run(args, int () => Random.Shared.Next()); +``` -[Command("unescape")] -public void UrlUnescape([Option(null, "input of this command")]string input) +```csharp +// return StatusCode +await ConsoleApp.RunAsync(args, async Task (string url, CancellationToken cancellationToken) => { -} + using var client = new HttpClient(); + var response = await client.GetAsync(url, cancellationToken); + return (int)response.StatusCode; +}); ``` -## Command parameters validation +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. -Values of command parameters can be validated via validation attributes from `System.ComponentModel.DataAnnotations` -namespace and custom ones inheriting `ValidationAttribute` type. +Attribute based parameters validation +--- +`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`. ```csharp -using System.ComponentModel.DataAnnotations; -// ... - -internal class TestConsoleApp : ConsoleAppBase -{ - [Command("some-command")] - public void SomeCommand( - [EmailAddress] string firstArg, - [Range(0, 2)] int secondArg) => Console.WriteLine($"hello from {nameof(TestConsoleApp)}"); -} +ConsoleApp.Run(args, ([EmailAddress] string firstArg, [Range(0, 2)] int secondArg) => { }); ``` -Output (command invoked with params [**--first-arg "invalid-email-address" --second-arg" 10**]) +For example, if you pass arguments like `args = "--first-arg invalid.email --second-arg 10".Split(' ');`, you will see validation failure messages such as: +```txt +The firstArg field is not a valid e-mail address. +The field secondArg must be between 0 and 2. ``` -Some parameters have invalid values: -first-arg (invalid-email-address): The String field is not a valid e-mail address. -second-arg (10): The field Int32 must be between 0 and 2. -``` -Daemon +By default, the ExitCode is set to 1 in this case. + +Filter(Middleware) Pipline / ConsoleAppContext --- -If use infinite-loop, it becomes daemon program. `ConsoleAppContext.CancellationToken` is lifecycle token of application. You can check `CancellationToken.IsCancellationRequested` and shutdown gracefully. +Filters are provided as a mechanism to hook into the execution before and after. To use filters, define an `internal class` that implements `ConsoleAppFilter`. ```csharp -public class Daemon : ConsoleAppBase +internal class NopFilter(ConsoleAppFilter next) : ConsoleAppFilter(next) // ctor needs `ConsoleAppFilter next` and call base(next) { - [RootCommand] - public async Task Run() + // implement InvokeAsync as filter body + public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) { - // you can write infinite-loop while stop request(Ctrl+C or docker terminate). try { - while (!this.Context.CancellationToken.IsCancellationRequested) - { - try - { - Console.WriteLine("Wait One Minutes"); - } - catch (Exception ex) - { - // error occured but continue to run(or terminate). - Console.WriteLine(ex, "Found error"); - } - - // wait for next time - await Task.Delay(TimeSpan.FromMinutes(1), this.Context.CancellationToken); - } + /* on before */ + await Next.InvokeAsync(context, cancellationToken); // invoke next filter or command body + /* on after */ } - catch (Exception ex) when (!(ex is OperationCanceledException)) + catch { - // you can write finally exception handling(without cancellation) + /* on error */ + throw; } finally { - // you can write cleanup code here. + /* on finally */ } } } ``` -Abort Timeout ---- -ConsoleAppFramework's execution lifetime is managed via generic host. If you do cancel(Ctrl+C), host starts cancellation process with timeout. If you don't pass `CancellationToken` in async method, does not cancel immediately. +Filters can be attached multiple times to "global", "class", or "method" using `UseFilter` or `[ConsoleAppFilter]`. The order of filters is global → class → method, and the execution order is determined by the definition order from top to bottom. ```csharp -public async Task Wait1() -{ - // Not good. - await Task.Delay(TimeSpan.FromMinutes(60)); -} +var app = ConsoleApp.Create(); -public async Task Wait2() -{ - // Good. - await Task.Delay(TimeSpan.FromMinutes(60), this.Context.CancellationToken); -} -``` +// global filters +app.UseFilter(); //order 1 +app.UseFilter(); //order 2 -Default timeout time is `00:00:05`, you can change via `ConfigureHostOptions`. +app.Add(); +app.Run(args); -```csharp -var app = ConsoleApp.CreateBuilder(args) - .ConfigureHostOptions(options => - { - // change timeout. - options.ShutdownTimeout = TimeSpan.FromMinutes(30); - }) - .Build(); +// per class filters +[ConsoleAppFilter] // order 3 +[ConsoleAppFilter] // order 4 +public class MyCommand +{ + // per method filters + [ConsoleAppFilter] // order 5 + [ConsoleAppFilter] // order 6 + public void Echo(string msg) => Console.WriteLine(msg); +} ``` -Filter ---- -Filter can hook before/after batch running event. You can implement `ConsoleAppFilter` for it and attach to global/class/method. +Filters allow various processes to be shared. For example, the process of measuring execution time can be written as follows: ```csharp -public class MyFilter : ConsoleAppFilter +internal class LogRunningTimeFilter(ConsoleAppFilter next) : ConsoleAppFilter(next) { - // Filter is instantiated by DI so you can get parameter by constructor injection. - - public async override ValueTask Invoke(ConsoleAppContext context, Func next) + public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) { + var startTime = Stopwatch.GetTimestamp(); + ConsoleApp.Log($"Execute command at {DateTime.UtcNow.ToLocalTime()}"); // LocalTime for human readable time try { - /* on before */ - await next(context); // next + await Next.InvokeAsync(context, cancellationToken); + ConsoleApp.Log($"Command execute successfully at {DateTime.UtcNow.ToLocalTime()}, Elapsed: " + (Stopwatch.GetElapsedTime(startTime))); } catch { - /* on after */ + ConsoleApp.Log($"Command execute failed at {DateTime.UtcNow.ToLocalTime()}, Elapsed: " + (Stopwatch.GetElapsedTime(startTime))); throw; } - finally - { - /* on finally */ - } } } ``` -`ConsoleAppContext.Timestamp` has start time so if subtraction from now, get elapsed time. +In case of an exception, the `ExitCode` is usually `1`, and the stack trace is also displayed. However, by applying an exception handling filter, the behavior can be changed. ```csharp -public class LogRunningTimeFilter : ConsoleAppFilter +internal class ChangeExitCodeFilter(ConsoleAppFilter next) : ConsoleAppFilter(next) { - public override async ValueTask Invoke(ConsoleAppContext context, Func next) + public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) { - context.Logger.LogInformation("Call method at " + context.Timestamp.ToLocalTime()); // LocalTime for human readable time try { - await next(context); - context.Logger.LogInformation("Call method Completed successfully, Elapsed:" + (DateTimeOffset.UtcNow - context.Timestamp)); + await Next.InvokeAsync(context, cancellationToken); } - catch + catch (Exception ex) { - context.Logger.LogInformation("Call method Completed Failed, Elapsed:" + (DateTimeOffset.UtcNow - context.Timestamp)); - throw; + if (ex is OperationCanceledException) return; + + Environment.ExitCode = 9999; // change custom exit code + ConsoleApp.LogError(ex.Message); // .ToString() shows stacktrace, .Message can avoid showing stacktrace to user. } } } ``` -In default, ConsoleAppFramework does not prevent double startup but if create filter, can do. +Filters are executed after the command name routing is completed. If you want to prohibit multiple executions for each command name, you can use `ConsoleAppContext.CommandName` as the key. ```csharp -public class MutexFilter : ConsoleAppFilter +internal class PreventMultipleSameCommandInvokeFilter(ConsoleAppFilter next) : ConsoleAppFilter(next) { - public override async ValueTask Invoke(ConsoleAppContext context, Func next) + public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) { - var name = context.MethodInfo.DeclaringType.Name + "." + context.MethodInfo.Name; - using (var mutex = new Mutex(true, name, out var createdNew)) + var basePath = Assembly.GetEntryAssembly()?.Location.Replace(Path.DirectorySeparatorChar, '_'); + var mutexKey = $"{basePath}$$${context.CommandName}"; // lock per command-name + + using var mutex = new Mutex(true, mutexKey, out var createdNew); + if (!createdNew) { - if (!createdNew) - { - throw new Exception($"already running {name} in another process."); - } - - await next(context); + throw new Exception($"already running command:{context.CommandName} in another process."); } + + await Next.InvokeAsync(context, cancellationToken); } } ``` -There filters can pass to `ConsoleAppOptions.GlobalFilters` on startup or attach by attribute on class, method. +If you want to pass values between filters or to commands, you can use `ConsoleAppContext.State`. For example, if you want to perform authentication processing and pass around the ID, you can write code like the following. Since `ConsoleAppContext` is an immutable record, you need to pass the rewritten context to Next using the `with` syntax. ```csharp -var app = ConsoleApp.Create(args, options => +internal class AuthenticationFilter(ConsoleAppFilter next) : ConsoleAppFilter(next) { - options.GlobalFilters = new ConsoleAppFilter[] + public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) { - new MutextFilter() { Order = -9999 } , - new LogRunningTimeFilter() { Oder = -9998 }, + var requestId = Guid.NewGuid(); + var userId = await GetUserIdAsync(); + + // setup new state to context + var authedContext = context with { State = new ApplicationContext(requestId, userId) }; + await Next.InvokeAsync(authedContext, cancellationToken); } -}); -[ConsoleAppFilter(typeof(MyFilter3))] -public class MyBatch : ConsoleAppBase -{ - [ConsoleAppFilter(typeof(MyFilter4), Order = -9999)] - [ConsoleAppFilter(typeof(MyFilter5), Order = 9999)] - public void Do() + // get user-id from DB/auth saas/others + async Task GetUserIdAsync() { + await Task.Delay(TimeSpan.FromSeconds(1)); + return 1999; } } + +record class ApplicationContext(Guid RequiestId, int UserId); ``` -Execution order can control by `int Order` property. +Commands can accept `ConsoleAppContext` as an argument. This allows using the values processed by filters. -Logging ---- -In default, `Context.Logger` has `ILogger` and `ILogger` can inject to constructor. Default `ConsoleLogger` format in `Host.CreateDefaultBuilder` is supernumerary and not suitable for console application. ConsoleAppFramework provides `SimpleConsoleLogger` to replace default ConsoleLogger in default. If you want to keep default `ConsoleLogger`, use `ConsoleAppOptions.ReplaceToUseSimpleConsoleLogger` to `false`. +```csharp +var app = ConsoleApp.Create(); -If you want to use high performance logger/output to file, also use [Cysharp/ZLogger](https://github.com/Cysharp/ZLogger/) that easy to integrate ConsoleAppFramework. +app.UseFilter(); -```csharp -using ZLogger; +app.Add("", (int x, int y, ConsoleAppContext context) => +{ + var appContext = (ApplicationContext)context.State!; + var requestId = appContext.RequiestId; + var userId = appContext.UserId; -var app = ConsoleApp.CreateDefaultBuilder(args) - .ConfigureLogging(x => - { - x.ClearProviders(); // clear all providers - x.SetMinimumLevel(LogLevel.Trace); // change log level if you want + Console.WriteLine($"Request:{requestId} User:{userId} Sum:{x + y}"); +}); - x.AddZLoggerConsole(); // add ZLogger Console - x.AddZLoggerFile("fileName.log"); // add ZLogger file output - }) - .Build(); +app.Run(args); ``` -Configuration ---- -ConsoleAppFramework is just an infrastructure. You can add `appsettings.json` or other configs as .NET Core offers via `Microsoft.Extensions.Options`. -You can add `appsettings.json` and `appsettings.{environment}.json` and typesafe load via map config to Class w/IOption. +`ConsoleAppContext` also has a `ConsoleAppContext.Arguments` property that allows you to obtain the (`string[] args`) passed to Run/RunAsync. -Here's single contained batch with Config loading sample. +### Sharing Filters Between Projects -```json -// appconfig.json(Content, Copy to Output Directory) -{ - "Foo": 42, - "Bar": true -} +`ConsoleAppFilter` is defined as `internal` for each project by the Source Generator, so the filters to be implemented must also be `internal`. Sharing at the csproj or DLL level is not possible, so source code needs to be shared by linking references. + +```xml + + + ``` +If you want to share via NuGet, you need to distribute the source code or distribute it in a format that includes the source code using `.props`. + +### Performance of filter + +In general frameworks, filters are dynamically added at runtime, resulting in a variable number of filters. Therefore, they need to be allocated using a dynamic array. In ConsoleAppFramework, the number of filters is statically determined at compile time, eliminating the need for any additional allocations such as arrays or lambda expression captures. The allocation amount is equal to the number of filter classes being used plus 1 (for wrapping the command method), resulting in the shortest execution path. + ```csharp -using Microsoft.Extensions.DependencyInjection; +app.UseFilter(); +app.UseFilter(); +app.UseFilter(); +app.UseFilter(); +app.UseFilter(); -var app = ConsoleApp.CreateBuilder(args) - .ConfigureServices((hostContext, services) => - { - // mapping config json to IOption - // requires "Microsoft.Extensions.Options.ConfigurationExtensions" package - // if you want to map subscetion in json, use Configure(hostContext.Configuration.GetSection("foo")) - services.Configure(hostContext.Configuration); - }) - .Build(); +// The above code will generate the following code: -public class ConfigAppSample : ConsoleAppBase +sealed class Command0Invoker(string[] args, Action command) : ConsoleAppFilter(null!) { - MyConfig config; - - // get configuration from DI. - public ConfigAppSample(IOptions config) + public ConsoleAppFilter BuildFilter() { - this.config = config.Value; + var filter0 = new NopFilter(this); + var filter1 = new NopFilter(filter0); + var filter2 = new NopFilter(filter1); + var filter3 = new NopFilter(filter2); + var filter4 = new NopFilter(filter3); + return filter4; } - public void ShowOption() + public override Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) { - Console.WriteLine(config.Bar); - Console.WriteLine(config.Foo); + return RunCommand0Async(context.Arguments, args, command, context, cancellationToken); } } ``` -for the details, please see [.NET Core Generic Host](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/generic-host) documentation. +When an `async Task` completes synchronously, it returns the equivalent of `Task.CompletedTask`, so `ValueTask` is not necessary. -DI +Dependency Injection(Logging, Configuration, etc...) --- -You can use DI(constructor injection) by GenericHost. +The execution processing of `ConsoleAppFramework` fully supports `DI`. When you want to use a logger, read a configuration, or share processing with an ASP.NET project, using `Microsoft.Extensions.DependencyInjection` or other DI libraries can make processing convenient. + +Lambda expressions passed to Run, class constructors, methods, and filter constructors can inject services obtained from `IServiceProvider`. Let's look at a minimal example. Setting any `System.IServiceProvider` to `ConsoleApp.ServiceProvider` enables DI throughout the system. ```csharp -IOptions config; -ILogger logger; +// Microsoft.Extensions.DependencyInjection +var services = new ServiceCollection(); +services.AddTransient(); -public MyApp(IOptions config, ILogger logger) -{ - this.config = config; - this.logger = logger; -} -``` +using var serviceProvider = services.BuildServiceProvider(); -DI also allows delegate registration. +// Any DI library can be used as long as it can create an IServiceProvider +ConsoleApp.ServiceProvider = serviceProvider; -```csharp -app.AddCommand("di", (ConsoleAppContext ctx, ILogger logger, int param1, int param2) => { }); +// When passing to a lambda expression/method, using [FromServices] indicates that it is passed via DI, not as a parameter +ConsoleApp.Run(args, ([FromServices]MyService service, int x, int y) => Console.WriteLine(x + y)); ``` -DI also inject to filter. +When passing to a lambda expression or method, the `[FromServices]` attribute is used to distinguish it from command parameters. When passing a class, Constructor Injection can be used, resulting in a simpler appearance. + +Let's try injecting a logger and enabling output to a file. The libraries used are Microsoft.Extensions.Logging and [Cysharp/ZLogger](https://github.com/Cysharp/ZLogger/) (a high-performance logger built on top of MS.E.Logging). -Cleanup ---- -You can implement `IDisposable.Dispose` or `IAsyncDisposable.DisposeAsync` explicitly, that is called after command finished. ```csharp -public class MyApp : ConsoleAppBase, IDisposable +// Package Import: ZLogger +var services = new ServiceCollection(); +services.AddLogging(x => { - public void Hello() - { - Console.WriteLine("Hello"); - } + x.ClearProviders(); + x.SetMinimumLevel(LogLevel.Trace); + x.AddZLoggerConsole(); + x.AddZLoggerFile("log.txt"); +}); - // Dispose/DisposeAsync method is not registered as Command. - public void Dispose() - { - Console.WriteLine("DISPOSED"); - } -} -``` +using var serviceProvider = services.BuildServiceProvider(); // using for logger flush(important!) +ConsoleApp.ServiceProvider = serviceProvider; -If implements both `IDisposable` and `IAsyncDisposable`, called only `IAsyncDisposable`. +var app = ConsoleApp.Create(); +app.Add(); +app.Run(args); -```csharp -public class MyApp : ConsoleAppBase, IDisposable, IAsyncDisposable +// inject logger to constructor +public class MyCommand(ILogger logger) { - public void Hello() - { - Console.WriteLine("Hello"); - } - - public void Dispose() - { - Console.WriteLine("Not called."); - } - - public async ValueTask DisposeAsync() + [Command("")] + public void Echo(string msg) { - Console.WriteLine("called."); + logger.ZLogInformation($"Message is {msg}"); } } ``` -ConsoleAppContext ---- -ConsoleAppContext is injected to property on method executing. +`ConsoleApp` has replaceable default logging methods `ConsoleApp.Log` and `ConsoleApp.LogError` used for Help display and exception handling. If using `ILogger`, it's better to replace these as well. ```csharp -public class ConsoleAppContext -{ - public string?[] Arguments { get; } - public DateTime Timestamp { get; } - public CancellationToken CancellationToken { get; } - public ILogger Logger { get; } - public MethodInfo MethodInfo { get; } - public IServiceProvider ServiceProvider { get; } - public IDictionary Items { get; } - - public void Cancel(); - public void Terminate(); -} -``` +using var serviceProvider = services.BuildServiceProvider(); // using for cleanup(important) +ConsoleApp.ServiceProvider = serviceProvider; -`Cancel()` set `CancellationToken` to canceled. Also `Terminate()` set token to cancled and terminate process(internal throws `OperationCanceledException` immediately). +// setup ConsoleApp system logger +var logger = serviceProvider.GetRequiredService>(); +ConsoleApp.Log = msg => logger.LogInformation(msg); +ConsoleApp.LogError = msg => logger.LogError(msg); +``` -ConsoleAppOptions ---- -You can configure framework behaviour by ConsoleAppOptions. +DI can also be effectively used when reading application configuration from `appsettings.json`. For example, suppose you have the following JSON file. -```csharp -var app = ConsoleApp.Create(args, options => +```json { - options.StrictOption = false, // default is true. - options.ShowDefaultCommand = false, // default is true -}); + "Position": { + "Title": "Editor", + "Name": "Joe Smith" + }, + "MyKey": "My appsettings.json Value", + "AllowedHosts": "*" +} ``` -```csharp -public class ConsoleAppOptions -{ - /// Argument parser uses strict(-short, --long) option. Default is true. - public bool StrictOption { get; set; } = true; - - /// Show default command(help/version) to help. Default is true. - public bool ShowDefaultCommand { get; set; } = true; +Using `Microsoft.Extensions.Configuration.Json`, reading, binding, and registering with DI can be done as follows. - public bool ReplaceToUseSimpleConsoleLogger { get; set; } = true; - - public JsonSerializerOptions? JsonSerializerOptions { get; set; } +```csharp +// Package Import: Microsoft.Extensions.Configuration.Json +var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json") + .Build(); - public ConsoleAppFilter[]? GlobalFilters { get; set; } +// Bind to services +var services = new ServiceCollection(); +services.Configure(configuration.GetSection("Position")); - public bool NoAttributeCommandAsImplicitlyDefault { get; set; } +using var serviceProvider = services.BuildServiceProvider(); +ConsoleApp.ServiceProvider = serviceProvider; - public Func NameConverter { get; set; } = KebabCaseConvert; +var app = ConsoleApp.Create(); +app.Add(); +app.Run(args); - public string? ApplicationName { get; set; } = null; +// inject options +public class MyCommand(IOptions options) +{ + [Command("")] + public void Echo(string msg) + { + ConsoleApp.Log($"Binded Option: {options.Value.Title} {options.Value.Name}"); + } } -``` - -If StrictOption = false, does not distinguish between the number of `-`. For example, this method -``` -public void Hello([Option("m", "Message to display.")]string message) +public class PositionOptions +{ + public string Title { get; set; } = ""; + public string Name { get; set; } = ""; +} ``` -can pass argument by `-m`, `--message` and `-message`. This is styled like a go lang command. But if you want to strictly distinguish argument of `-`, set `StrcitOption = true`(default), that allows `-m` and `--message`. +If you have other applications such as ASP.NET in the entire project and want to use common DI and configuration set up using `Microsoft.Extensions.Hosting`, you can share them by setting the `IServiceProvider` of `IHost` after building. -Also, by default, the `help` and `version` commands appear as help, which can be hidden by setting `ShowDefaultCommand = false`. +```csharp +// Package Import: Microsoft.Extensions.Hosting +var builder = Host.CreateApplicationBuilder(); // don't pass args. -NameConverter is used type-name, method-name, parameter-name converting as command. Default is convert to lower kebab-case. +using var host = builder.Build(); // using +ConsoleApp.ServiceProvider = host.Services; // use host ServiceProvider -```csharp -// my-command query-data --organization-id --user-id -public class MyCommand -{ - public void QueryData(string organizationId, string userId); -} +ConsoleApp.Run(args, ([FromServices] ILogger logger) => logger.LogInformation("Hello World!")); ``` -You can set func to change this behaviour like `NameConverter = x => x.ToLower();`. - -`ApplicationName` configure help usages `Usage: ***`, default(null) shows filename without extension. +ConsoleAppFramework has its own lifetime management (see the [CancellationToken(Gracefully Shutdown) and Timeout](#cancellationtokengracefully-shutdown-and-timeout) section), so Host's Start/Stop is not necessary. However, be sure to use the Host itself. -Terminate handling in Console.Read ---- -ConsoleAppFramework handle terminate signal(Ctrl+C) gracefully with `ConsoleAppContext.CancellationToken`. If your application waiting with Console.Read/ReadLine/ReadKey, requires additional handling. +As it is, the DI scope is not set, but by using a global filter, you can add a scope for each command execution. `ConsoleAppFilter` can also inject services via constructor injection, so let's get the `IServiceProvider`. ```csharp -// case of Console.Read/ReadLine, pressed Ctrl+C, Read returns null. -ConsoleApp.Run(args, (ConsoleAppContext ctx) => -{ - var read = Console.ReadLine(); - if (read == null) ctx.Terminate(); -}); -``` +var app = ConsoleApp.Create(); +app.UseFilter(); -```csharp -// case of Console.ReadKey, can not cancel itself so use with Task.Run and WaitAsync. -ConsoleApp.Run(args, async (ConsoleAppContext ctx) => +internal class ServiceProviderScopeFilter(IServiceProvider serviceProvider, ConsoleAppFilter next) : ConsoleAppFilter(next) { - var key = await Task.Run(() => Console.ReadKey()).WaitAsync(ctx.CancellationToken); -}); + public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) + { + // create Microsoft.Extensions.DependencyInjection scope + await using var scope = serviceProvider.CreateAsyncScope(); + await Next.InvokeAsync(context, cancellationToken); + } +} ``` Publish to executable file --- -[dotnet run](https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-run) is useful for local development or execute in CI tool. For example in CI, git pull and execute by `dotnet run -- --options` is easy to manage and execute utilities. - -[dotnet publish](https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-publish) to create executable file. [.NET Core 3.0 offers Single Executable File](https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-core-3-0) via `PublishSingleFile`. - -CLI tool can use [.NET Core Local/Global Tools](https://docs.microsoft.com/en-us/dotnet/core/tools/global-tools). If you want to create it, check the [Tutorial: Create a .NET tool using the .NET CLI](https://docs.microsoft.com/en-us/dotnet/core/tools/global-tools-how-to-create) and [Use a global tool](https://docs.microsoft.com/en-us/dotnet/core/tools/global-tools-how-to-use) or [Use a local tool](https://docs.microsoft.com/en-us/dotnet/core/tools/local-tools-how-to-use). - -v3 Legacy Compatibility ---- -v1-v3 does not exist minimal api style(`ConsoleApp.Create/CreateBuilder`). - -```csharp -await Host.CreateDefaultBuilder() - .RunConsoleAppFrameworkAsync(args); -``` +There are multiple ways to run a CLI application in .NET: -`RunConsoleAppFrameworkAsync` is still exists but does not recommend to use. Also, since v4, there is a change in the default behavior. When `RunConsoleAppFrameworkAsync` is used, the option settings of v3 and earlier will be used. +* [dotnet run](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-run) +* [dotnet build](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-build) +* [dotnet publish](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-publish) -```csharp -options.NoAttributeCommandAsImplicitlyDefault = true; -options.StrictOption = false; -options.NameConverter = x => x.ToLower(); -options.ReplaceToUseSimpleConsoleLogger = false; -``` +`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). -You can also get this option setting in `ConsoleAppOptions.CreateLegacyCompatible()`. +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. License --- diff --git a/sandbox/AspNetApp/AspNetApp.csproj b/sandbox/AspNetApp/AspNetApp.csproj deleted file mode 100644 index d61c2e1..0000000 --- a/sandbox/AspNetApp/AspNetApp.csproj +++ /dev/null @@ -1,8 +0,0 @@ - - - - net5.0 - false - - - diff --git a/sandbox/AspNetApp/Program.cs b/sandbox/AspNetApp/Program.cs deleted file mode 100644 index 265f310..0000000 --- a/sandbox/AspNetApp/Program.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace AspNetApp -{ - public class Program - { - public static void Main(string[] args) - { - CreateHostBuilder(args).Build().Run(); - } - - public static IHostBuilder CreateHostBuilder(string[] args) => - Host.CreateDefaultBuilder(args) - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - }); - } -} diff --git a/sandbox/AspNetApp/Startup.cs b/sandbox/AspNetApp/Startup.cs deleted file mode 100644 index 969f3a5..0000000 --- a/sandbox/AspNetApp/Startup.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace AspNetApp -{ - public class Startup - { - // This method gets called by the runtime. Use this method to add services to the container. - // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940 - public void ConfigureServices(IServiceCollection services) - { - } - - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } - - app.UseRouting(); - - app.UseEndpoints(endpoints => - { - endpoints.MapGet("/", async context => - { - await context.Response.WriteAsync("Hello World!"); - }); - }); - } - } -} diff --git a/sandbox/AspNetApp/appsettings.Development.json b/sandbox/AspNetApp/appsettings.Development.json deleted file mode 100644 index 8983e0f..0000000 --- a/sandbox/AspNetApp/appsettings.Development.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } - } -} diff --git a/sandbox/AspNetApp/appsettings.json b/sandbox/AspNetApp/appsettings.json deleted file mode 100644 index d9d9a9b..0000000 --- a/sandbox/AspNetApp/appsettings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } - }, - "AllowedHosts": "*" -} diff --git a/sandbox/CliFrameworkBenchmark/Benchmark.cs b/sandbox/CliFrameworkBenchmark/Benchmark.cs new file mode 100644 index 0000000..0a7fc0c --- /dev/null +++ b/sandbox/CliFrameworkBenchmark/Benchmark.cs @@ -0,0 +1,127 @@ +// This benchmark project is based on CliFx.Benchmarks. +// https://github.com/Tyrrrz/CliFx/tree/master/CliFx.Benchmarks/ + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Engines; +using BenchmarkDotNet.Order; +using CliFx; +using Cocona.Benchmark.External.Commands; +using CommandLine; +using ConsoleAppFramework; +using PowerArgs; +using Spectre.Console.Cli; +using System.ComponentModel.DataAnnotations.Schema; +using BenchmarkDotNet.Columns; + +namespace Cocona.Benchmark.External; + +// use ColdStart strategy to measure startup time evaluation +[SimpleJob(RunStrategy.ColdStart, launchCount: 1, warmupCount: 0, iterationCount: 1, invocationCount: 1)] +[MemoryDiagnoser] +[Orderer(SummaryOrderPolicy.FastestToSlowest)] +public class Benchmark +{ + private static readonly string[] Arguments = { "--str", "hello world", "-i", "13", "-b" }; + + [Benchmark(Description = "Cocona.Lite")] + public void ExecuteWithCoconaLite() + { + Cocona.CoconaLiteApp.Run(Arguments); + } + + [Benchmark(Description = "Cocona")] + public void ExecuteWithCocona() + { + Cocona.CoconaApp.Run(Arguments); + } + + //[Benchmark(Description = "ConsoleAppFramework")] + //public async ValueTask ExecuteWithConsoleAppFramework() => + // await Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(Arguments); + + [Benchmark(Description = "CliFx")] + public ValueTask ExecuteWithCliFx() + { + return new CliApplicationBuilder().AddCommand(typeof(CliFxCommand)).Build().RunAsync(Arguments); + } + + [Benchmark(Description = "System.CommandLine")] + public int ExecuteWithSystemCommandLine() + { + return SystemCommandLineCommand.Execute(Arguments); + } + + //[Benchmark(Description = "McMaster.Extensions.CommandLineUtils")] + //public int ExecuteWithMcMaster() => + // McMaster.Extensions.CommandLineUtils.CommandLineApplication.Execute(Arguments); + + //[Benchmark(Description = "CommandLineParser")] + //public void ExecuteWithCommandLineParser() => + // new Parser() + // .ParseArguments(Arguments, typeof(CommandLineParserCommand)) + // .WithParsed(c => c.Execute()); + + //[Benchmark(Description = "PowerArgs")] + //public void ExecuteWithPowerArgs() => + // PowerArgs.Args.InvokeMain(Arguments); + + //[Benchmark(Description = "Clipr")] + //public void ExecuteWithClipr() => + // clipr.CliParser.Parse(Arguments).Execute(); + + + //[Benchmark(Description = "ConsoleAppFramework v5")] + //public void ExecuteConsoleAppFramework5() + //{ + // ConsoleApp.Run(Arguments, ConsoleAppFrameworkCommand.Execute); + //} + + [Benchmark(Description = "ConsoleAppFramework v5", Baseline = true)] + public unsafe void ExecuteConsoleAppFramework() + { + ConsoleApp.Run(Arguments, &ConsoleAppFrameworkCommand.Execute); + } + + // for alpha testing + //private static readonly string[] TempArguments = { "", "--str", "hello world", "-i", "13", "-b" }; + //[Benchmark(Description = "ConsoleAppFramework.Builder")] + //public unsafe void ExecuteConsoleAppFrameworkBuilder() + //{ + // var builder = ConsoleApp.Create(); + // builder.Add("", ConsoleAppFrameworkCommand.Execute); + // builder.Run(TempArguments); + //} + + [Benchmark(Description = "Spectre.Console.Cli")] + public void ExecuteSpectreConsoleCli() + { + var app = new CommandApp(); + app.Run(Arguments); + } + + + //[Benchmark(Description = "ConsoleAppFramework Builder API")] + //public unsafe void ExecuteConsoleAppFramework2() + //{ + // var app = ConsoleApp.Create(); + // app.Add("", ConsoleAppFrameworkCommand.Execute); + // app.Run(Arguments); + //} + + //[Benchmark(Description = "ConsoleAppFramework CancellationToken")] + //public unsafe void ExecuteConsoleAppFramework3() + //{ + // var app = ConsoleApp.Create(); + // app.Add("", ConsoleAppFrameworkCommandWithCancellationToken.Execute); + // app.Run(Arguments); + //} + + //[Benchmark(Description = "ConsoleAppFramework With Filter")] + //public unsafe void ExecuteConsoleAppFramework4() + //{ + // var app = ConsoleApp.Create(); + // app.UseFilter(); + // app.Add("", ConsoleAppFrameworkCommand.Execute); + // app.Run(Arguments); + //} +} \ No newline at end of file diff --git a/sandbox/CliFrameworkBenchmark/CliFrameworkBenchmark.csproj b/sandbox/CliFrameworkBenchmark/CliFrameworkBenchmark.csproj new file mode 100644 index 0000000..5b0c95f --- /dev/null +++ b/sandbox/CliFrameworkBenchmark/CliFrameworkBenchmark.csproj @@ -0,0 +1,38 @@ + + + + Exe + net8.0 + enable + annotations + true + false + + + + + + + + + + + + + + + + + + + + + + + + Analyzer + false + + + + diff --git a/sandbox/CliFrameworkBenchmark/Commands/CliFxCommand.cs b/sandbox/CliFrameworkBenchmark/Commands/CliFxCommand.cs new file mode 100644 index 0000000..6d73e06 --- /dev/null +++ b/sandbox/CliFrameworkBenchmark/Commands/CliFxCommand.cs @@ -0,0 +1,19 @@ +using CliFx.Attributes; +using CliFx.Infrastructure; + +namespace Cocona.Benchmark.External.Commands; + +[CliFx.Attributes.Command] +public class CliFxCommand : CliFx.ICommand +{ + [CommandOption("str", 's')] + public string? StrOption { get; set; } + + [CommandOption("int", 'i')] + public int IntOption { get; set; } + + [CommandOption("bool", 'b')] + public bool BoolOption { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => ValueTask.CompletedTask; +} diff --git a/sandbox/CliFrameworkBenchmark/Commands/CliprCommand.cs b/sandbox/CliFrameworkBenchmark/Commands/CliprCommand.cs new file mode 100644 index 0000000..3a5b9ce --- /dev/null +++ b/sandbox/CliFrameworkBenchmark/Commands/CliprCommand.cs @@ -0,0 +1,19 @@ +using clipr; + +namespace Cocona.Benchmark.External.Commands; + +public class CliprCommand +{ + [NamedArgument('s', "str")] + public string? StrOption { get; set; } + + [NamedArgument('i', "int")] + public int IntOption { get; set; } + + [NamedArgument('b', "bool", Constraint = NumArgsConstraint.Optional, Const = true)] + public bool BoolOption { get; set; } + + public void Execute() + { + } +} \ No newline at end of file diff --git a/sandbox/CliFrameworkBenchmark/Commands/CoconaCommand.cs b/sandbox/CliFrameworkBenchmark/Commands/CoconaCommand.cs new file mode 100644 index 0000000..4e299f0 --- /dev/null +++ b/sandbox/CliFrameworkBenchmark/Commands/CoconaCommand.cs @@ -0,0 +1,14 @@ +namespace Cocona.Benchmark.External.Commands; + +public class CoconaCommand +{ + public void Execute( + [global::Cocona.Option("str", new []{'s'})] + string? strOption, + [global::Cocona.Option("int", new []{'i'})] + int intOption, + [global::Cocona.Option("bool", new []{'b'})] + bool boolOption) + { + } +} \ No newline at end of file diff --git a/sandbox/CliFrameworkBenchmark/Commands/CommandLineParserCommand.cs b/sandbox/CliFrameworkBenchmark/Commands/CommandLineParserCommand.cs new file mode 100644 index 0000000..b15dc2f --- /dev/null +++ b/sandbox/CliFrameworkBenchmark/Commands/CommandLineParserCommand.cs @@ -0,0 +1,17 @@ +namespace Cocona.Benchmark.External.Commands; + +public class CommandLineParserCommand +{ + [global::CommandLine.Option('s', "str")] + public string? StrOption { get; set; } + + [global::CommandLine.Option('i', "int")] + public int IntOption { get; set; } + + [global::CommandLine.Option('b', "bool")] + public bool BoolOption { get; set; } + + public void Execute() + { + } +} \ No newline at end of file diff --git a/sandbox/CliFrameworkBenchmark/Commands/ConsoleAppFrameworkCommand.cs b/sandbox/CliFrameworkBenchmark/Commands/ConsoleAppFrameworkCommand.cs new file mode 100644 index 0000000..8a5fb9a --- /dev/null +++ b/sandbox/CliFrameworkBenchmark/Commands/ConsoleAppFrameworkCommand.cs @@ -0,0 +1,54 @@ +//using ConsoleAppFramework; + +//namespace Cocona.Benchmark.External.Commands; + +//public class ConsoleAppFrameworkCommand : ConsoleAppBase +//{ +// public void Execute( +// [global::ConsoleAppFramework.Option("s")] +// string? str, +// [global::ConsoleAppFramework.Option("i")] +// int intOption, +// [global::ConsoleAppFramework.Option("b")] +// bool boolOption) +// { +// } +//} + +using ConsoleAppFramework; + +public class ConsoleAppFrameworkCommand +{ + /// + /// + /// + /// -s + /// -i + /// -b + public static void Execute(string? str, int intOption, bool boolOption) + { + + } +} + +public class ConsoleAppFrameworkCommandWithCancellationToken +{ + /// + /// + /// + /// -s + /// -i + /// -b + public static void Execute(string? str, int intOption, bool boolOption, CancellationToken cancellationToken) + { + + } +} + +//internal class NopConsoleAppFilter(ConsoleAppFilter next) : ConsoleAppFilter(next) +//{ +// public override Task InvokeAsync(CancellationToken cancellationToken) +// { +// return Next.InvokeAsync(cancellationToken); +// } +//} \ No newline at end of file diff --git a/sandbox/CliFrameworkBenchmark/Commands/McMasterCommand.cs b/sandbox/CliFrameworkBenchmark/Commands/McMasterCommand.cs new file mode 100644 index 0000000..d9ff0d7 --- /dev/null +++ b/sandbox/CliFrameworkBenchmark/Commands/McMasterCommand.cs @@ -0,0 +1,15 @@ +namespace Cocona.Benchmark.External.Commands; + +public class McMasterCommand +{ + [McMaster.Extensions.CommandLineUtils.Option("--str|-s")] + public string? StrOption { get; set; } + + [McMaster.Extensions.CommandLineUtils.Option("--int|-i")] + public int IntOption { get; set; } + + [McMaster.Extensions.CommandLineUtils.Option("--bool|-b")] + public bool BoolOption { get; set; } + + public int OnExecute() => 0; +} \ No newline at end of file diff --git a/sandbox/CliFrameworkBenchmark/Commands/PowerArgsCommand.cs b/sandbox/CliFrameworkBenchmark/Commands/PowerArgsCommand.cs new file mode 100644 index 0000000..33d14c2 --- /dev/null +++ b/sandbox/CliFrameworkBenchmark/Commands/PowerArgsCommand.cs @@ -0,0 +1,19 @@ +using PowerArgs; + +namespace Cocona.Benchmark.External.Commands; + +//public class PowerArgsCommand +//{ +// [ArgShortcut("--str"), ArgShortcut("-s")] +// public string? StrOption { get; set; } + +// [ArgShortcut("--int"), ArgShortcut("-i")] +// public int IntOption { get; set; } + +// [ArgShortcut("--bool"), ArgShortcut("-b")] +// public bool BoolOption { get; set; } + +// public void Main() +// { +// } +//} \ No newline at end of file diff --git a/sandbox/CliFrameworkBenchmark/Commands/SpectreConsoleCliCommand.cs b/sandbox/CliFrameworkBenchmark/Commands/SpectreConsoleCliCommand.cs new file mode 100644 index 0000000..0a8d4c2 --- /dev/null +++ b/sandbox/CliFrameworkBenchmark/Commands/SpectreConsoleCliCommand.cs @@ -0,0 +1,24 @@ +using Spectre.Console.Cli; +using System.ComponentModel; + +namespace Cocona.Benchmark.External.Commands; + +public class SpectreConsoleCliCommand : Command +{ + public sealed class Settings : CommandSettings + { + [CommandOption("-s")] + public string? strOption { get; init; } + + [CommandOption("-i")] + public int intOption { get; init; } + + [CommandOption("-b")] + public bool boolOption { get; init; } + } + + public override int Execute(CommandContext context, Settings settings) + { + return 0; + } +} \ No newline at end of file diff --git a/sandbox/CliFrameworkBenchmark/Commands/SystemCommandLineCommand.cs b/sandbox/CliFrameworkBenchmark/Commands/SystemCommandLineCommand.cs new file mode 100644 index 0000000..ca5a114 --- /dev/null +++ b/sandbox/CliFrameworkBenchmark/Commands/SystemCommandLineCommand.cs @@ -0,0 +1,53 @@ +using System.CommandLine; +using System.CommandLine.Invocation; + +namespace Cocona.Benchmark.External.Commands; + +public class SystemCommandLineCommand +{ + public static int ExecuteHandler(string s, int i, bool b) => 0; + + public static int Execute(string[] args) + { + var command = new RootCommand + { + new Option(new[] {"--str", "-s"}) + { + Argument = new Argument() + }, + new Option(new[] {"--int", "-i"}) + { + Argument = new Argument() + }, + new Option(new[] {"--bool", "-b"}) + { + Argument = new Argument() + } + }; + + command.Handler = CommandHandler.Create(ExecuteHandler); + return command.Invoke(args); + } + + public static Task ExecuteAsync(string[] args) + { + var command = new RootCommand + { + new Option(new[] {"--str", "-s"}) + { + Argument = new Argument() + }, + new Option(new[] {"--int", "-i"}) + { + Argument = new Argument() + }, + new Option(new[] {"--bool", "-b"}) + { + Argument = new Argument() + } + }; + + command.Handler = CommandHandler.Create(ExecuteHandler); + return command.InvokeAsync(args); + } +} \ No newline at end of file diff --git a/sandbox/CliFrameworkBenchmark/Program.cs b/sandbox/CliFrameworkBenchmark/Program.cs new file mode 100644 index 0000000..f71dd38 --- /dev/null +++ b/sandbox/CliFrameworkBenchmark/Program.cs @@ -0,0 +1,17 @@ +// This benchmark project is based on CliFx.Benchmarks. +// https://github.com/Tyrrrz/CliFx/tree/master/CliFx.Benchmarks/ + +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Reports; +using BenchmarkDotNet.Running; +using Perfolizer.Horology; + +namespace Cocona.Benchmark.External; + +class Program +{ + static void Main(string[] args) + { + BenchmarkRunner.Run(DefaultConfig.Instance.WithSummaryStyle(SummaryStyle.Default.WithTimeUnit(TimeUnit.Millisecond))); + } +} diff --git a/sandbox/CliFrameworkBenchmark/README.md b/sandbox/CliFrameworkBenchmark/README.md new file mode 100644 index 0000000..9c8ed35 --- /dev/null +++ b/sandbox/CliFrameworkBenchmark/README.md @@ -0,0 +1,5 @@ +This benchmark project is based on CliFx.Benchmarks and Cocona Benchmarks. + +https://github.com/Tyrrrz/CliFx/tree/master/CliFx.Benchmarks/ + +https://github.com/mayuki/Cocona/tree/master/perf/Cocona.Benchmark.External \ No newline at end of file diff --git a/sandbox/GeneratorSandbox/Filters.cs b/sandbox/GeneratorSandbox/Filters.cs new file mode 100644 index 0000000..d3d0b07 --- /dev/null +++ b/sandbox/GeneratorSandbox/Filters.cs @@ -0,0 +1,118 @@ + +using ConsoleAppFramework; +using Microsoft.Extensions.DependencyInjection; +using System.Diagnostics; +using System.Reflection; + +namespace GeneratorSandbox; + +// ReadMe sample filters + +internal class NopFilter(ConsoleAppFilter next) : ConsoleAppFilter(next) +{ + public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) + { + try + { + /* on before */ + await Next.InvokeAsync(context, cancellationToken); // next + /* on after */ + } + catch + { + /* on error */ + throw; + } + finally + { + /* on finally */ + } + } +} + +internal class AuthenticationFilter(ConsoleAppFilter next) : ConsoleAppFilter(next) +{ + public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) + { + var requestId = Guid.NewGuid(); + var userId = await GetUserIdAsync(); + + // setup new state to context + var authedContext = context with { State = new ApplicationContext(requestId, userId) }; + await Next.InvokeAsync(authedContext, cancellationToken); + } + + // get user-id from DB/auth saas/others + async Task GetUserIdAsync() + { + await Task.Delay(TimeSpan.FromSeconds(1)); + return 1999; + } +} + +record class ApplicationContext(Guid RequiestId, int UserId); + +internal class LogRunningTimeFilter(ConsoleAppFilter next) : ConsoleAppFilter(next) +{ + public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) + { + var startTime = Stopwatch.GetTimestamp(); + ConsoleApp.Log($"Execute command at {DateTime.UtcNow.ToLocalTime()}"); // LocalTime for human readable time + try + { + await Next.InvokeAsync(context, cancellationToken); + ConsoleApp.Log($"Command execute successfully at {DateTime.UtcNow.ToLocalTime()}, Elapsed: " + (Stopwatch.GetElapsedTime(startTime))); + } + catch + { + ConsoleApp.Log($"Command execute failed at {DateTime.UtcNow.ToLocalTime()}, Elapsed: " + (Stopwatch.GetElapsedTime(startTime))); + throw; + } + } +} + +internal class ChangeExitCodeFilter(ConsoleAppFilter next) : ConsoleAppFilter(next) +{ + public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) + { + try + { + await Next.InvokeAsync(context, cancellationToken); + } + catch (Exception ex) + { + if (ex is OperationCanceledException) return; + + Environment.ExitCode = 9999; // change custom exit code + ConsoleApp.LogError(ex.ToString()); + } + } +} + +internal class PreventMultipleSameCommandInvokeFilter(ConsoleAppFilter next) : ConsoleAppFilter(next) +{ + public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) + { + var basePath = Assembly.GetEntryAssembly()?.Location.Replace(Path.DirectorySeparatorChar, '_'); + var mutexKey = $"{basePath}$$${context.CommandName}"; // lock per command-name + + using var mutex = new Mutex(true, mutexKey, out var createdNew); + if (!createdNew) + { + throw new Exception($"already running command:{context.CommandName} in another process."); + } + + await Next.InvokeAsync(context, cancellationToken); + } +} + + +internal class ServiceProviderScopeFilter(IServiceProvider serviceProvider, ConsoleAppFilter next) : ConsoleAppFilter(next) +{ + public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) + { + // create Microsoft.Extensions.DependencyInjection scope + await using var scope = serviceProvider.CreateAsyncScope(); + await Next.InvokeAsync(context, cancellationToken); + } +} \ No newline at end of file diff --git a/sandbox/GeneratorSandbox/GeneratorSandbox.csproj b/sandbox/GeneratorSandbox/GeneratorSandbox.csproj new file mode 100644 index 0000000..8383bef --- /dev/null +++ b/sandbox/GeneratorSandbox/GeneratorSandbox.csproj @@ -0,0 +1,33 @@ + + + + Exe + net8.0 + enable + enable + true + 1701;1702;CS8321 + false + + + + + + + + + + + + Analyzer + false + + + + + + PreserveNewest + + + + diff --git a/sandbox/GeneratorSandbox/Program.cs b/sandbox/GeneratorSandbox/Program.cs new file mode 100644 index 0000000..d782802 --- /dev/null +++ b/sandbox/GeneratorSandbox/Program.cs @@ -0,0 +1,170 @@ +using ConsoleAppFramework; +using GeneratorSandbox; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.ComponentModel.DataAnnotations; +using System.Numerics; +using System.Threading.Channels; +using ZLogger; + + +args = "--first-arg invalid.email --second-arg 10".Split(' '); + +ConsoleApp.Timeout = Timeout.InfiniteTimeSpan; + + + + +ConsoleApp.Run(args, ( + [Argument] DateTime dateTime, // Argument + [Argument] Guid guidvalue, // + int intVar, // required + bool boolFlag, // flag + MyEnum enumValue, // enum + int[] array, // array + MyClass obj, // object + string optional = "abcde", // optional + double? nullableValue = null, // nullable + params string[] paramsArray // params + ) => { }); + + + + + + + + +public enum MyEnum +{ + +} + +public class MyClass +{ + +} + +// inject logger +public class MyCommand(ILogger logger, IOptions options) +{ + [Command("")] + public void Echo(string msg) + { + logger.ZLogTrace($"Binded Option: {options.Value.Title} {options.Value.Name}"); + logger.ZLogInformation($"Message is {msg}"); + } +} + + + +public class PositionOptions +{ + public string Title { get; set; } = ""; + public string Name { get; set; } = ""; +} + + + + + +[AttributeUsage(AttributeTargets.Parameter)] +public class Vector3ParserAttribute : Attribute, IArgumentParser +{ + public static bool TryParse(ReadOnlySpan s, out Vector3 result) + { + Span ranges = stackalloc Range[3]; + var splitCount = s.Split(ranges, ','); + if (splitCount != 3) + { + result = default; + return false; + } + + float x; + float y; + float z; + if (float.TryParse(s[ranges[0]], out x) && float.TryParse(s[ranges[1]], out y) && float.TryParse(s[ranges[2]], out z)) + { + result = new Vector3(x, y, z); + return true; + } + + result = default; + return false; + } +} + + +internal class DIFilter(string foo, int bar, ConsoleAppFilter next) + : ConsoleAppFilter(next) +{ + public override Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) + { + var newContext = context with { State = 100 }; + + Console.Write("invoke:"); + Console.Write(foo); + Console.Write(bar); + return Next.InvokeAsync(newContext, cancellationToken); + } +} + +public class MiniDI : IServiceProvider +{ + System.Collections.Generic.Dictionary dict = new(); + + public void Register(Type type, object instance) + { + dict[type] = instance; + } + + public object? GetService(Type serviceType) + { + return dict.TryGetValue(serviceType, out var instance) ? instance : null; + } +} + +namespace ConsoleAppFramework +{ + partial class ConsoleApp + { + static async Task RunWithFilterAsync2(string commandName, string[] args, 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.Factory.StartNew(static state => Task.CompletedTask, 1983, default, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default).Unwrap(); + + + + } + catch (Exception ex) + { + if (ex is OperationCanceledException) + { + Environment.ExitCode = 130; + return; + } + + Environment.ExitCode = 1; + if (ex is ValidationException) + { + LogError(ex.Message); + } + else + { + LogError(ex.ToString()); + } + } + } + } +} \ No newline at end of file diff --git a/sandbox/GeneratorSandbox/appsettings.json b/sandbox/GeneratorSandbox/appsettings.json new file mode 100644 index 0000000..442ff6b --- /dev/null +++ b/sandbox/GeneratorSandbox/appsettings.json @@ -0,0 +1,8 @@ +{ + "Position": { + "Title": "Editor", + "Name": "Joe Smith" + }, + "MyKey": "My appsettings.json Value", + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/sandbox/MultiContainedApp/Dockerfile b/sandbox/MultiContainedApp/Dockerfile deleted file mode 100644 index a43210c..0000000 --- a/sandbox/MultiContainedApp/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM mcr.microsoft.com/dotnet/core/sdk:2.1 AS sdk -COPY . . -RUN dotnet publish /sandbox/MultiContainedApp/MultiContainedApp.csproj -c Release -o /app - -FROM mcr.microsoft.com/dotnet/core/runtime:2.1 -COPY --from=sdk /app . -ENTRYPOINT ["dotnet", "MultiContainedApp.dll"] \ No newline at end of file diff --git a/sandbox/MultiContainedApp/MultiContainedApp.csproj b/sandbox/MultiContainedApp/MultiContainedApp.csproj deleted file mode 100644 index 07bb775..0000000 --- a/sandbox/MultiContainedApp/MultiContainedApp.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - Exe - net5.0 - 7.3 - false - - - - - - - diff --git a/sandbox/MultiContainedApp/Program.cs b/sandbox/MultiContainedApp/Program.cs deleted file mode 100644 index e90c42b..0000000 --- a/sandbox/MultiContainedApp/Program.cs +++ /dev/null @@ -1,131 +0,0 @@ -using ConsoleAppFramework; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using System; -using System.Diagnostics; -using System.Threading.Tasks; - -namespace MultiContainedApp -{ - class Program - { - static async Task Main(string[] args) - { - //d - //args = new string[] { "bar", "hello3", "-help" }; - //args = new string[] { "foo", "echo", "help"}; - //args = new string[] { "bar.hello2", "help" }; - args = new string[] { "foo-bar", "ec", "-msg", "tako" }; - - - await Host.CreateDefaultBuilder() - .ConfigureLogging(x => x.SetMinimumLevel(LogLevel.Trace)) - .RunConsoleAppFrameworkAsync(args, options: new ConsoleAppOptions - { - GlobalFilters = new ConsoleAppFilter[] { new MyFilter2 { Order = -1 }, new MyFilter() } - }); - - - - //await Host.CreateDefaultBuilder() - // .ConfigureLogging(x => - // { - // x.ClearProviders(); - // x.SetMinimumLevel(LogLevel.Trace); - - // }) - // .RunConsoleAppFrameworkAsync(args); - } - } - - [ConsoleAppFilter(typeof(MyFilter2), Order = 9999)] - [ConsoleAppFilter(typeof(MyFilter2), Order = 9999)] - // [Command("AAA")] - public class FooBar : ConsoleAppBase - { - [Command("ec", "My echo")] - public void Echo(string msg) - { - Console.WriteLine(msg + "OK??"); - } - - public void Sum(int x, int y) - { - Console.WriteLine((x + y).ToString()); - } - } - - public class Bar : ConsoleAppBase - { - [Command("ec", "My echo")] - public void Hello2(string msg) - { - Console.WriteLine("H E L L O 2"); - } - - - public void Sum(int x, int y) - { - Console.WriteLine((x + y).ToString()); - } - } - - - - //public class Foo : ConsoleAppBase - //{ - // [Command(new[] { "eo", "t" }, "Echo message to the logger")] - // [ConsoleAppFilter(typeof(EchoFilter), Order = 10000)] - // public void Echo([Option("msg", "Message to send.")]string msg) - // { - // // Console.WriteLine(new StackTrace().ToString()); - // this.Context.Logger.LogInformation(msg); - // } - - // [Command("s")] - // public void Sum([Option(0)]int x, [Option(1)]int y) - // { - // this.Context.Logger.LogInformation((x + y).ToString()); - // } - //} - - //public class Bar : ConsoleAppBase - //{ - // public void Hello2() - // { - // this.Context.Logger.LogInformation("H E L L O"); - // } - - // public void Hello3([Option(0)]int aaa) - // { - // this.Context.Logger.LogInformation("H E L L O:" + aaa); - // } - //} - - public class MyFilter : ConsoleAppFilter - { - public async override ValueTask Invoke(ConsoleAppContext context, Func next) - { - Console.WriteLine("call second"); - await next(context); - } - } - - public class MyFilter2 : ConsoleAppFilter - { - public async override ValueTask Invoke(ConsoleAppContext context, Func next) - { - Console.WriteLine("call first"); - await next(context); - } - } - - public class EchoFilter : ConsoleAppFilter - { - public async override ValueTask Invoke(ConsoleAppContext context, Func next) - { - Console.WriteLine("call ec"); - await next(context); - } - } -} diff --git a/sandbox/NativeAot/NativeAot.csproj b/sandbox/NativeAot/NativeAot.csproj new file mode 100644 index 0000000..d6775be --- /dev/null +++ b/sandbox/NativeAot/NativeAot.csproj @@ -0,0 +1,21 @@ + + + + Exe + net8.0 + enable + enable + false + + true + true + + + + + Analyzer + false + + + + diff --git a/sandbox/NativeAot/Program.cs b/sandbox/NativeAot/Program.cs new file mode 100644 index 0000000..a37522f --- /dev/null +++ b/sandbox/NativeAot/Program.cs @@ -0,0 +1,3 @@ +using ConsoleAppFramework; + +ConsoleApp.Run(args, (int x, int y) => Console.WriteLine(x + y)); \ No newline at end of file diff --git a/sandbox/Net6Console/GlobalUsing.cs b/sandbox/Net6Console/GlobalUsing.cs deleted file mode 100644 index 1638fa4..0000000 --- a/sandbox/Net6Console/GlobalUsing.cs +++ /dev/null @@ -1 +0,0 @@ -global using ConsoleAppFramework; \ No newline at end of file diff --git a/sandbox/Net6Console/MyDbContext.cs b/sandbox/Net6Console/MyDbContext.cs deleted file mode 100644 index 64aaa49..0000000 --- a/sandbox/Net6Console/MyDbContext.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Net6Console -{ - public class MyDbContext : DbContext - { - } -} diff --git a/sandbox/Net6Console/Net6Console.csproj b/sandbox/Net6Console/Net6Console.csproj deleted file mode 100644 index 8adc9de..0000000 --- a/sandbox/Net6Console/Net6Console.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net6.0 - enable - enable - false - - - - - - - - - - - - - - - diff --git a/sandbox/Net6Console/Program.cs b/sandbox/Net6Console/Program.cs deleted file mode 100644 index 1773ea4..0000000 --- a/sandbox/Net6Console/Program.cs +++ /dev/null @@ -1,15 +0,0 @@ -using ConsoleAppFramework; -using System.Runtime.InteropServices; - - -// RegisterShutdownHandlers(); - -//var r2 = Console.ReadKey(); - - -// Console.ReadKey -ConsoleApp.Run(args, async (ConsoleAppContext ctx) => -{ - var key = await Task.Run(() => Console.ReadKey(intercept: true)).WaitAsync(ctx.CancellationToken); - Console.WriteLine(key.KeyChar); -}); diff --git a/sandbox/Net6WebApp/Net6WebApp.csproj b/sandbox/Net6WebApp/Net6WebApp.csproj deleted file mode 100644 index 5fe49b0..0000000 --- a/sandbox/Net6WebApp/Net6WebApp.csproj +++ /dev/null @@ -1,11 +0,0 @@ - - - - Exe - net6.0 - enable - enable - false - - - diff --git a/sandbox/Net6WebApp/Program.cs b/sandbox/Net6WebApp/Program.cs deleted file mode 100644 index 297c40d..0000000 --- a/sandbox/Net6WebApp/Program.cs +++ /dev/null @@ -1,43 +0,0 @@ - - - -var sc = new ServiceCollection(); -sc.AddTransient(); - -#pragma warning disable ASP0000 // Do not call 'IServiceCollection.BuildServiceProvider' in 'ConfigureServices' -var p = sc.BuildServiceProvider(); -#pragma warning restore ASP0000 // Do not call 'IServiceCollection.BuildServiceProvider' in 'ConfigureServices' - -var b = WebApplication.CreateBuilder(); - - - -b.Logging.AddConsole(); - -//.Services.loggi - - -//var builder = WebApplication.CreateBuilder(args); -//builder.Logging - - -var hoge = ActivatorUtilities.CreateInstance(p, typeof(MyClass2), new object[] { 1, 2, "hoge", new MyClass3() }); - - - -public class MyClass -{ - -} - -public class MyClass2 -{ - public MyClass2(int x, MyClass3 mc3, MyClass mc, string z, int y) - { - Console.WriteLine("OK:" + (x, mc, y, mc3, z)); - } -} - -public class MyClass3 -{ -} \ No newline at end of file diff --git a/sandbox/SingleContainedApp/Program.cs b/sandbox/SingleContainedApp/Program.cs deleted file mode 100644 index 3a097f7..0000000 --- a/sandbox/SingleContainedApp/Program.cs +++ /dev/null @@ -1,254 +0,0 @@ -#pragma warning disable CS1998 - -using ConsoleAppFramework; -using ConsoleAppFramework.Logging; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using System; -using System.Collections.Generic; -using System.IO; -using System.Reflection; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace SingleContainedApp -{ - public class MyFirstBatch : ConsoleAppBase - { - public void Hello( - [Option("n", "name of send user.")] string name, - [Option("r", "repeat count.")] int repeat = 3) - { - for (int i = 0; i < repeat; i++) - { - this.Context.Logger.LogInformation($"Hello My Batch from {name}"); - } - } - - IOptions config; - ILogger logger; - - public MyFirstBatch(IOptions config, ILogger logger) - { - this.config = config; - this.logger = logger; - } - - [Command("log")] - public void LogWrite() - { - Context.Logger.LogTrace("t r a c e"); - Context.Logger.LogDebug("d e b u g"); - Context.Logger.LogInformation("i n f o"); - Context.Logger.LogCritical("c r i t i c a l"); - Context.Logger.LogWarning("w a r n"); - Context.Logger.LogError("e r r o r"); - } - - [Command("opt")] - public void ShowOption() - { - Console.WriteLine(config.Value.Bar); - Console.WriteLine(config.Value.Foo); - } - - - [Command("version", "yeah, go!")] - public void ShowVersion() - { - var version = Assembly.GetExecutingAssembly() - .GetCustomAttribute() - .Version; - Console.WriteLine(version); - } - - [Command("escape")] - public void UrlEscape([Option(0)] string input) - { - Console.WriteLine(Uri.EscapeDataString(input)); - } - - [Command("timer")] - public async Task Timer([Option(0)] uint waitSeconds) - { - Console.WriteLine(waitSeconds + " seconds"); - while (waitSeconds != 0) - { - await Task.Delay(TimeSpan.FromSeconds(1), Context.CancellationToken); - waitSeconds--; - Console.WriteLine(waitSeconds + " seconds"); - } - } - } - - public class MyConfig - { - public int Foo { get; set; } - public bool Bar { get; set; } - } - - public class OverrideCheck : ConsoleAppBase - { - [Command("encode", "encode input string to base64url")] - public void Encode([Option(0)] string input) => Console.WriteLine((input)); - - [Command("decode", "decode input base64url to string")] - public void Decode([Option(0)] string input) => Console.WriteLine((input)); - - [Command("escape", "escape base64 to base64url")] - public void Escape([Option(0)] string input) => Console.WriteLine((input)); - - [Command(new[] { "unescape", "-h" }, "unescape base64url to base64")] - public void Unescape([Option(0)] string input) => Console.WriteLine((input)); - - //[Command(new[] { "help", "-h", "-help", "--help" }, "show help")] - //public void Help() - //{ - // Console.WriteLine("Usage: base64urls [-version] [-help] [decode|encode|escape|unescape] [args]"); - // Console.WriteLine("E.g., run this: base64urls decode QyMgaXMgYXdlc29tZQ=="); - // Console.WriteLine("E.g., run this: base64urls encode \"C# is awesome.\""); - // Console.WriteLine("E.g., run this: base64urls escape \"This+is/goingto+escape==\""); - // Console.WriteLine("E.g., run this: base64urls unescape \"This-is_goingto-escape\""); - //} - } - - public class ComplexArgTest : ConsoleAppBase - { - public void Foo(int[] array, Person person) - { - Context.Logger.LogTrace(array.Length + ":" + string.Join(", ", array)); - Context.Logger.LogInformation(person.Age + ":" + person.Name); - } - } - - public class StandardArgTest : ConsoleAppBase - { - public void Run([Option(0, "message of x.")] string x) - { - // Console.WriteLine("1." + x); - //Console.WriteLine("2." + y); - } - } - - public class Person - { - public int Age { get; set; } - public string Name { get; set; } - } - - - public class ThrowOperationCanceledException : ConsoleAppBase - { - public async Task Throw() - { - //while (true) - //{ - // await Task.Delay(10); - // Context.CancellationToken.ThrowIfCancellationRequested(); - //} - - var cts = new CancellationTokenSource(); - cts.Cancel(); - cts.Token.ThrowIfCancellationRequested(); - } - } - - - public class SimpleTwoArgs : ConsoleAppBase - { - public async ValueTask Hello([Option("n")] string name, [Option("r")] int repeat) - { - Context.Logger.LogInformation($"name:{name}"); - - Context.Logger.LogInformation($"Wait {repeat} Seconds."); - await Task.Delay(TimeSpan.FromSeconds(repeat)); - - Context.Logger.LogInformation($"repeat:{repeat}"); - - return 100; - } - } - - public class Issue46 : ConsoleAppBase, IDisposable - { - - public void Run(string str, bool b = false) - { - Console.WriteLine("str:" + str + " b:" + b); - } - - void IDisposable.Dispose() - { - Console.WriteLine("DISPOSE!"); - } - } - - //class Program - //{ - // static async Task Main(string[] args) - // { - // //args = new[] { "-array", "10,20,30", "-person", @"{""Age"":10,""Name"":""foo""}" }; - - // //args = new[] { "--name", "aaa", "--repeat", "3" }; - - // //args = new[] { "--help" }; - // //args = new[] { "encode", "--help" }; - // //args = new[] { "-str", "input" }; - // //args = new[] { "-str", "input", "-b"}; - // //args = new[] { "-str" }; - - // await Host.CreateDefaultBuilder() - // .ConfigureLogging(logging => - // { - // logging.SetMinimumLevel(LogLevel.Trace).ReplaceToSimpleConsole(); - // }) - // .RunConsoleAppFrameworkAsync(args); - // // .RunConsoleAppEngineAsync - // //.ConfigureServices((hostContext, services) => - // //{ - // // // mapping config json to IOption - // // services.Configure(hostContext.Configuration); - // //}) - // //.RunConsoleAppEngineAsync(args); - // } - //} - - public class Program : ConsoleAppBase, IDisposable, IAsyncDisposable - { - static async Task Main(string[] args) - { - //args = new[] { "-m", "a ", "-b", "False" }; - args = new[] { "hello" }; - - await Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args, new ConsoleAppOptions - { - //StrictOption = true, // default is false. - //ShowDefaultCommand = false, // default is true - }); - - } - - - public void Hello( - int? foo = null, - [Option("", "", DefaultValue = "DateTime.Today")] DateTime? hello = null) - { - if (hello == null) hello = DateTime.Now; - Console.WriteLine(hello); - } - - void IDisposable.Dispose() - { - Console.WriteLine("standard dispose"); - } - - async ValueTask IAsyncDisposable.DisposeAsync() - { - Console.WriteLine("async dispose"); - } - } -} diff --git a/sandbox/SingleContainedApp/SampleFilter.cs b/sandbox/SingleContainedApp/SampleFilter.cs deleted file mode 100644 index 3004292..0000000 --- a/sandbox/SingleContainedApp/SampleFilter.cs +++ /dev/null @@ -1,51 +0,0 @@ -using ConsoleAppFramework; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace SingleContainedApp -{ - public class LogRunningTimeFilter : ConsoleAppFilter - { - public override async ValueTask Invoke(ConsoleAppContext context, Func next) - { - context.Logger.LogInformation("Call method at " + context.Timestamp.ToLocalTime()); // LocalTime for human readable time - try - { - await next(context); - context.Logger.LogInformation("Call method Completed successfully, Elapsed:" + (DateTimeOffset.UtcNow - context.Timestamp)); - } - catch - { - context.Logger.LogInformation("Call method Completed Failed, Elapsed:" + (DateTimeOffset.UtcNow - context.Timestamp)); - throw; - } - } - } - - public class MutexFilter : ConsoleAppFilter - { - public override async ValueTask Invoke(ConsoleAppContext context, Func next) - { - using (var mutex = new Mutex(false, context.MethodInfo.Name)) - { - if (!mutex.WaitOne(0, false)) - { - throw new Exception($"already running {context.MethodInfo.Name} in another process."); - } - - try - { - await next(context); - } - finally - { - mutex.ReleaseMutex(); - } - } - } - } -} diff --git a/sandbox/SingleContainedApp/SingleContainedApp.csproj b/sandbox/SingleContainedApp/SingleContainedApp.csproj deleted file mode 100644 index 7cf65c6..0000000 --- a/sandbox/SingleContainedApp/SingleContainedApp.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - Exe - net5.0 - 7.3 - false - - - - - - - - - Always - - - - - - - - diff --git a/sandbox/SingleContainedApp/appsettings.json b/sandbox/SingleContainedApp/appsettings.json deleted file mode 100644 index d3ec32f..0000000 --- a/sandbox/SingleContainedApp/appsettings.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "Foo": 42, - "Bar": true -} \ No newline at end of file diff --git a/sandbox/SingleContainedAppWithConfig/Program.cs b/sandbox/SingleContainedAppWithConfig/Program.cs deleted file mode 100644 index 9f5400a..0000000 --- a/sandbox/SingleContainedAppWithConfig/Program.cs +++ /dev/null @@ -1,61 +0,0 @@ -using ConsoleAppFramework; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using System.Threading.Tasks; - -public class Baz : ConsoleAppBase -{ - private readonly IOptions config; - // Batche inject Config on constructor. - public Baz(IOptions config, MyServiceA serviceA, MyServiceB serviceB, MyServiceC serviceC) - { - this.config = config; - } - - public void Hello3() - { - // https://docs.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loglevel?view=aspnetcore-2.2 - this.Context.Logger.LogTrace("Trace"); // 0 - this.Context.Logger.LogDebug("Debug"); // 1 - this.Context.Logger.LogInformation("Info"); // 2 - this.Context.Logger.LogWarning("Warning"); // 3 - this.Context.Logger.LogError("Error"); // 4 - this.Context.Logger.LogInformation($"GlobalValue: {config.Value.GlobalValue}, EnvValue: {config.Value.EnvValue}"); - } -} - -public class MyServiceA { } -public class MyServiceB { } -public class MyServiceC { } - -namespace SingleContainedAppWithConfig -{ - class Program - { - static async Task Main(string[] args) - { - // using ConsoleAppFramework.Configuration; - await Host.CreateDefaultBuilder() - .ConfigureServices((hostContext, services) => - { - services.AddOptions(); - // mapping json element to class - services.Configure(hostContext.Configuration.GetSection("AppConfig")); - - services.AddScoped(); - services.AddTransient(); - services.AddSingleton(); - }) - .RunConsoleAppFrameworkAsync(args); - } - } - - // config mapping class - public class AppConfig - { - public string GlobalValue { get; set; } - public string EnvValue { get; set; } - } -} \ No newline at end of file diff --git a/sandbox/SingleContainedAppWithConfig/SingleContainedAppWithConfig.csproj b/sandbox/SingleContainedAppWithConfig/SingleContainedAppWithConfig.csproj deleted file mode 100644 index 0ce567c..0000000 --- a/sandbox/SingleContainedAppWithConfig/SingleContainedAppWithConfig.csproj +++ /dev/null @@ -1,36 +0,0 @@ - - - - Exe - net5.0 - 7.3 - false - - - - - - - - - - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - - - - - - diff --git a/sandbox/SingleContainedAppWithConfig/appsettings.Development.json b/sandbox/SingleContainedAppWithConfig/appsettings.Development.json deleted file mode 100644 index 83f4c0d..0000000 --- a/sandbox/SingleContainedAppWithConfig/appsettings.Development.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "AppConfig": { - "EnvValue": "ENV VALUE!!!!(DEVELOPMENT)" - } -} diff --git a/sandbox/SingleContainedAppWithConfig/appsettings.Production.json b/sandbox/SingleContainedAppWithConfig/appsettings.Production.json deleted file mode 100644 index 14c3732..0000000 --- a/sandbox/SingleContainedAppWithConfig/appsettings.Production.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "AppConfig": { - "EnvValue": "ENV VALUE!!!!(PRODUCTION)" - } -} diff --git a/sandbox/SingleContainedAppWithConfig/appsettings.Staging.json b/sandbox/SingleContainedAppWithConfig/appsettings.Staging.json deleted file mode 100644 index c113821..0000000 --- a/sandbox/SingleContainedAppWithConfig/appsettings.Staging.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "AppConfig": { - "EnvValue": "ENV VALUE!!!!(STAGING)" - } -} diff --git a/sandbox/SingleContainedAppWithConfig/appsettings.json b/sandbox/SingleContainedAppWithConfig/appsettings.json deleted file mode 100644 index bf7b454..0000000 --- a/sandbox/SingleContainedAppWithConfig/appsettings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "AppConfig": { - "GlobalValue": "GLOBAL VALUE!!!!", - "EnvValue": "ENV VALUE!!!!" - }, - "Logging": { - "ConsoleAppFramework.Logging.SimpleConsoleLoggerProvider": { - "LogLevel": { - "Default": "Trace", - "System": "Information", - "Microsoft": "Information" - } - } - } -} diff --git a/src/ConsoleAppFramework/.template.config/template.json b/src/ConsoleAppFramework/.template.config/template.json deleted file mode 100644 index 4f4f2c8..0000000 --- a/src/ConsoleAppFramework/.template.config/template.json +++ /dev/null @@ -1,11 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using System.Text; - -[assembly:InternalsVisibleTo("ConsoleAppFramework.Tests, PublicKey="+ - "002400000480000094000000060200000024000052534131000400000100010089b12412c27f5f"+ - "aece3868b659bf311f2550c5cc1b4cbbe032a048ec36fa288872d51e8ddfd77a83e835c6ea3940"+ - "c331fe89d1ba9146a12abed588f194cd437cfe81252634d49214acf6b11dc9e97cfc6d0f818082"+ - "e5bbafb50e890eed19517c199213075b294d5fa59556cd42186041a1a95f8cff3869575bed4de2"+ - "dd8f5dc2")] \ No newline at end of file diff --git a/src/ConsoleAppFramework/AssemblyInfo.cs b/src/ConsoleAppFramework/AssemblyInfo.cs deleted file mode 100644 index 4f4f2c8..0000000 --- a/src/ConsoleAppFramework/AssemblyInfo.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using System.Text; - -[assembly:InternalsVisibleTo("ConsoleAppFramework.Tests, PublicKey="+ - "002400000480000094000000060200000024000052534131000400000100010089b12412c27f5f"+ - "aece3868b659bf311f2550c5cc1b4cbbe032a048ec36fa288872d51e8ddfd77a83e835c6ea3940"+ - "c331fe89d1ba9146a12abed588f194cd437cfe81252634d49214acf6b11dc9e97cfc6d0f818082"+ - "e5bbafb50e890eed19517c199213075b294d5fa59556cd42186041a1a95f8cff3869575bed4de2"+ - "dd8f5dc2")] \ No newline at end of file diff --git a/src/ConsoleAppFramework/Command.cs b/src/ConsoleAppFramework/Command.cs new file mode 100644 index 0000000..94a26e0 --- /dev/null +++ b/src/ConsoleAppFramework/Command.cs @@ -0,0 +1,415 @@ +using Microsoft.CodeAnalysis; +using System; +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; +using System.Reflection.Metadata; +using System.Text; + +namespace ConsoleAppFramework; + +public enum MethodKind +{ + Lambda, Method, FunctionPointer +} + +public enum DelegateBuildType +{ + MakeDelegateWhenHasDefaultValue, + OnlyActionFunc, + None +} + +public record class Command +{ + public required bool IsAsync { get; init; } // Task or Task + public required bool IsVoid { get; init; } // void or int + + public bool IsRootCommand => Name == ""; + public required string Name { get; init; } + + public required CommandParameter[] Parameters { get; init; } + public required string Description { get; init; } + public required MethodKind MethodKind { get; init; } + public required DelegateBuildType DelegateBuildType { get; init; } + public CommandMethodInfo? CommandMethodInfo { get; set; } // can set...! + public required FilterInfo[] Filters { get; init; } + public bool HasFilter => Filters.Length != 0; + + public string? BuildDelegateSignature(out string? delegateType) + { + if (DelegateBuildType == DelegateBuildType.MakeDelegateWhenHasDefaultValue) + { + if (MethodKind == MethodKind.Lambda && Parameters.Any(x => x.HasDefaultValue || x.IsParams)) + { + delegateType = BuildDelegateType("RunCommand"); + return "RunCommand"; + } + } + + delegateType = null; + + if (DelegateBuildType == DelegateBuildType.None) + { + return null; + } + + if (MethodKind == MethodKind.FunctionPointer) return BuildFunctionPointerDelegateSignature(); + + if (IsAsync) + { + if (IsVoid) + { + // Func<...,Task> + if (Parameters.Length == 0) + { + return $"Func"; + } + else + { + var parameters = string.Join(", ", Parameters.Select(x => x.ToTypeDisplayString())); + return $"Func<{parameters}, Task>"; + } + } + else + { + // Func<...,Task> + if (Parameters.Length == 0) + { + return $"Func>"; + } + else + { + var parameters = string.Join(", ", Parameters.Select(x => x.ToTypeDisplayString())); + return $"Func<{parameters}, Task>"; + } + } + } + else + { + if (IsVoid) + { + // Action + if (Parameters.Length == 0) + { + return "Action"; + } + else + { + var parameters = string.Join(", ", Parameters.Select(x => x.ToTypeDisplayString())); + return $"Action<{parameters}>"; + } + } + else + { + // Func + if (Parameters.Length == 0) + { + return "Func"; + } + else + { + var parameters = string.Join(", ", Parameters.Select(x => x.ToTypeDisplayString())); + return $"Func<{parameters}, int>"; + } + } + } + } + + public string BuildFunctionPointerDelegateSignature() + { + var retType = (IsAsync, IsVoid) switch + { + (true, true) => "Task", + (true, false) => "Task", + (false, true) => "void", + (false, false) => "int" + }; + + var parameters = string.Join(", ", Parameters.Select(x => x.ToTypeDisplayString())); + var comma = Parameters.Length > 0 ? ", " : ""; + return $"delegate* managed<{parameters}{comma}{retType}>"; + } + + public string BuildDelegateType(string delegateName) + { + var retType = (IsAsync, IsVoid) switch + { + (true, true) => "Task", + (true, false) => "Task", + (false, true) => "void", + (false, false) => "int" + }; + + var parameters = string.Join(", ", Parameters.Select(x => x.ToString())); + return $"delegate {retType} {delegateName}({parameters});"; + } +} + +public record class CommandParameter +{ + public required ITypeSymbol Type { get; init; } + public required Location Location { get; init; } + public required bool IsNullableReference { get; init; } + public required bool IsParams { get; init; } + public required string Name { get; init; } + public required string OriginalParameterName { get; init; } + public required bool HasDefaultValue { get; init; } + public object? DefaultValue { get; init; } + public required ITypeSymbol? CustomParserType { get; init; } + public required bool IsFromServices { get; init; } + public required bool IsConsoleAppContext { get; init; } + public required bool IsCancellationToken { get; init; } + public bool IsParsable => !(IsFromServices || IsCancellationToken || IsConsoleAppContext); + public bool IsFlag => Type.SpecialType == SpecialType.System_Boolean; + public required bool HasValidation { get; init; } + public required int ArgumentIndex { get; init; } // -1 is not Argument, other than marked as [Argument] + public bool IsArgument => ArgumentIndex != -1; + public required string[] Aliases { get; init; } + public required string Description { get; init; } + public bool RequireCheckArgumentParsed => !(HasDefaultValue || IsParams || IsFlag); + + public string BuildParseMethod(int argCount, string argumentName, WellKnownTypes wellKnownTypes, bool increment) + { + var index = increment ? "++i" : "i"; + return Core(Type, false); + + string Core(ITypeSymbol type, bool nullable) + { + var tryParseKnownPrimitive = false; + var tryParseIParsable = false; + + var outArgVar = (!nullable) ? $"out arg{argCount}" : $"out var temp{argCount}"; + var elseExpr = (!nullable) ? "" : $" else {{ arg{argCount} = temp{argCount}; }}"; + + // Nullable + if (type.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) + { + var valueType = (type as INamedTypeSymbol)!.TypeArguments[0]; + return Core(valueType, true); + } + + if (CustomParserType != null) + { + return $"if (!{CustomParserType.ToFullyQualifiedFormatDisplayString()}.TryParse(args[{index}], {outArgVar})) {{ ThrowArgumentParseFailed(\"{argumentName}\", args[i]); }}{elseExpr}"; + } + + switch (type.SpecialType) + { + case SpecialType.System_String: + return $"arg{argCount} = args[{index}];"; // no parse + case SpecialType.System_Boolean: + return $"arg{argCount} = true;"; // bool is true flag + case SpecialType.System_Char: + case SpecialType.System_SByte: + case SpecialType.System_Byte: + case SpecialType.System_Int16: + case SpecialType.System_UInt16: + case SpecialType.System_Int32: + case SpecialType.System_UInt32: + case SpecialType.System_Int64: + case SpecialType.System_UInt64: + case SpecialType.System_Decimal: + case SpecialType.System_Single: + case SpecialType.System_Double: + case SpecialType.System_DateTime: + tryParseKnownPrimitive = true; + break; + default: + // Enum + if (type.TypeKind == TypeKind.Enum) + { + return $"if (!Enum.TryParse<{type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}>(args[{index}], true, {outArgVar})) {{ ThrowArgumentParseFailed(\"{argumentName}\", args[i]); }}{elseExpr}"; + } + + // ParamsArray + if (IsParams) + { + return $"{(increment ? "i++; " : "")}if (!TryParseParamsArray(args, ref arg{argCount}, ref i)) {{ ThrowArgumentParseFailed(\"{argumentName}\", args[i]); }}{elseExpr}"; + } + + // Array + if (type.TypeKind == TypeKind.Array) + { + var elementType = (type as IArrayTypeSymbol)!.ElementType; + var parsable = wellKnownTypes.ISpanParsable; + if (parsable != null) // has parsable + { + if (elementType.AllInterfaces.Any(x => x.EqualsUnconstructedGenericType(parsable))) + { + return $"if (!TrySplitParse(args[{index}], {outArgVar})) {{ ThrowArgumentParseFailed(\"{argumentName}\", args[i]); }}{elseExpr}"; + } + } + break; + } + + // System.DateTimeOffset, System.Guid, System.Version + tryParseKnownPrimitive = wellKnownTypes.HasTryParse(type); + + if (!tryParseKnownPrimitive) + { + // ISpanParsable (BigInteger, Complex, Half, Int128, etc...) + var parsable = wellKnownTypes.ISpanParsable; + if (parsable != null) // has parsable + { + tryParseIParsable = type.AllInterfaces.Any(x => x.EqualsUnconstructedGenericType(parsable)); + } + } + + break; + } + + if (tryParseKnownPrimitive) + { + return $"if (!{type.ToFullyQualifiedFormatDisplayString()}.TryParse(args[{index}], {outArgVar})) {{ ThrowArgumentParseFailed(\"{argumentName}\", args[i]); }}{elseExpr}"; + } + else if (tryParseIParsable) + { + return $"if (!{type.ToFullyQualifiedFormatDisplayString()}.TryParse(args[{index}], null, {outArgVar})) {{ ThrowArgumentParseFailed(\"{argumentName}\", args[i]); }}{elseExpr}"; + } + else + { + return $"try {{ arg{argCount} = System.Text.Json.JsonSerializer.Deserialize<{type.ToFullyQualifiedFormatDisplayString()}>(args[{index}]); }} catch {{ ThrowArgumentParseFailed(\"{argumentName}\", args[i]); }}"; + } + } + } + + public string DefaultValueToString(bool castValue = true, bool enumIncludeTypeName = true) + { + if (DefaultValue is bool b) + { + return b ? "true" : "false"; + } + if (DefaultValue is string s) + { + return "\"" + s + "\""; + } + if (DefaultValue == null) + { + // null -> default(T) to support both class and struct + return $"default({Type.ToFullyQualifiedFormatDisplayString()})"; + } + if (Type.TypeKind == TypeKind.Enum) + { + var symbol = Type.GetMembers().OfType().FirstOrDefault(x => object.Equals(x.ConstantValue, DefaultValue)); + if (symbol == null) + { + return $"default({Type.ToFullyQualifiedFormatDisplayString()})"; + } + else + { + return enumIncludeTypeName ? $"{Type.ToFullyQualifiedFormatDisplayString()}.{symbol.Name}" : symbol.Name; + } + } + + if (!castValue) return DefaultValue.ToString(); + return $"({Type.ToFullyQualifiedFormatDisplayString()}){DefaultValue}"; + } + + public string? GetEnumSymbolName(object value) + { + var symbol = Type.GetMembers().OfType().FirstOrDefault(x => x.ConstantValue == value); + if (symbol == null) return ""; + return symbol.Name; + } + + public string ToTypeDisplayString() + { + var t = Type.ToFullyQualifiedFormatDisplayString(); + return IsNullableReference ? $"{t}?" : t; + } + + public string ToTypeShortString() + { + var t = Type.ToDisplayString(NullableFlowState.NotNull, SymbolDisplayFormat.MinimallyQualifiedFormat); + return IsNullableReference ? $"{t}?" : t; + } + + public override string ToString() + { + var sb = new StringBuilder(); + if (IsParams) + { + sb.Append("params "); + } + sb.Append(ToTypeDisplayString()); + sb.Append(" "); + sb.Append(OriginalParameterName); + if (HasDefaultValue) + { + sb.Append(" = "); + sb.Append(DefaultValueToString(castValue: false)); + } + + return sb.ToString(); + } +} + +public record class CommandMethodInfo +{ + public required string TypeFullName { get; init; } + public required string MethodName { get; init; } + public required ITypeSymbol[] ConstructorParameterTypes { get; init; } + public required bool IsIDisposable { get; init; } + public required bool IsIAsyncDisposable { get; init; } + + public string BuildNew() + { + var p = ConstructorParameterTypes.Select(parameter => + { + var type = parameter.ToFullyQualifiedFormatDisplayString(); + return $"({type})ServiceProvider!.GetService(typeof({type}))!"; + }); + + return $"new {TypeFullName}({string.Join(", ", p)})"; + } +} + +public record class FilterInfo +{ + public required string TypeFullName { get; init; } + public required ITypeSymbol[] ConstructorParameterTypes { get; init; } + + FilterInfo() + { + + } + + public static FilterInfo? Create(ITypeSymbol type) + { + var publicConstructors = type.GetMembers() + .OfType() + .Where(x => x.MethodKind == Microsoft.CodeAnalysis.MethodKind.Constructor && x.DeclaredAccessibility == Accessibility.Public) + .ToArray(); + + if (publicConstructors.Length != 1) + { + return null; + } + + var filter = new FilterInfo + { + TypeFullName = type.ToFullyQualifiedFormatDisplayString(), + ConstructorParameterTypes = publicConstructors[0].Parameters.Select(x => x.Type).ToArray() + }; + + return filter; + } + + public string BuildNew(string nextFilterName) + { + var p = ConstructorParameterTypes.Select(parameter => + { + var type = parameter.ToFullyQualifiedFormatDisplayString(); + if (type.Contains("ConsoleAppFramework.ConsoleAppFilter")) + { + return nextFilterName; + } + else + { + return $"({type})ServiceProvider!.GetService(typeof({type}))!"; + } + }); + + return $"new {TypeFullName}({string.Join(", ", p)})"; + } +} \ No newline at end of file diff --git a/src/ConsoleAppFramework/CommandAttribute.cs b/src/ConsoleAppFramework/CommandAttribute.cs deleted file mode 100644 index 986c0c0..0000000 --- a/src/ConsoleAppFramework/CommandAttribute.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; - -namespace ConsoleAppFramework -{ - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)] - public class CommandAttribute : Attribute - { - public string[] CommandNames { get; } - public string? Description { get; } - - public CommandAttribute(string commandName) - : this(new[] { commandName }, null) - { - } - - public CommandAttribute(string commandName, string description) - : this(new[] { commandName }, description) - { - } - - public CommandAttribute(string[] commandNames) - : this(commandNames, null) - { - } - - public CommandAttribute(string[] commandNames, string? description) - { - this.CommandNames = commandNames; - this.Description = description; - } - } - - [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] - public class RootCommandAttribute : Attribute - { - } -} \ No newline at end of file diff --git a/src/ConsoleAppFramework/CommandDescriptor.cs b/src/ConsoleAppFramework/CommandDescriptor.cs deleted file mode 100644 index e88a180..0000000 --- a/src/ConsoleAppFramework/CommandDescriptor.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System; -using System.Linq; -using System.Reflection; - -namespace ConsoleAppFramework -{ - internal enum CommandType - { - DefaultCommand, - Command, - SubCommand - } - - internal class CommandDescriptor - { - public CommandType CommandType { get; } - public MethodInfo MethodInfo { get; } - public object? Instance { get; } - public CommandAttribute? CommandAttribute { get; } - public string? ParentCommand { get; } - - public string[] GetNames(ConsoleAppOptions options) - { - if (CommandAttribute != null) return CommandAttribute.CommandNames; - return new[] { options.NameConverter(MethodInfo.Name) }; - } - - public string GetNamesFormatted(ConsoleAppOptions options) - { - return string.Join(", ", GetNames(options)); - } - - public string[] Aliases - { - get - { - if (CommandAttribute == null || CommandAttribute.CommandNames.Length <= 1) - { - return Array.Empty(); - } - else - { - return CommandAttribute.CommandNames.Skip(1).ToArray(); - } - } - } - - public string GetCommandName(ConsoleAppOptions options) - { - if (ParentCommand != null) - { - return $"{ParentCommand} {GetNamesFormatted(options)}"; - } - else - { - return GetNamesFormatted(options); - } - } - - public string Description - { - get - { - if (CommandAttribute != null) - { - return CommandAttribute.Description ?? ""; - } - else - { - return ""; - } - } - } - - public CommandDescriptor(CommandType commandType, MethodInfo methodInfo, object? instance = null, CommandAttribute? additionalCommandAttribute = null, string? parentCommand = null) - { - CommandType = commandType; - MethodInfo = methodInfo; - Instance = instance; - CommandAttribute = additionalCommandAttribute ?? methodInfo.GetCustomAttribute(); - ParentCommand = parentCommand; - } - } -} \ No newline at end of file diff --git a/src/ConsoleAppFramework/CommandDescriptorCollection.cs b/src/ConsoleAppFramework/CommandDescriptorCollection.cs deleted file mode 100644 index 725642e..0000000 --- a/src/ConsoleAppFramework/CommandDescriptorCollection.cs +++ /dev/null @@ -1,184 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; - -namespace ConsoleAppFramework -{ - internal class CommandDescriptorCollection - { - CommandDescriptor? rootCommandDescriptor; - readonly Dictionary descriptors = new Dictionary(StringComparer.OrdinalIgnoreCase); - readonly Dictionary> subCommandDescriptors = new Dictionary>(StringComparer.OrdinalIgnoreCase); - readonly ConsoleAppOptions options; - - public CommandDescriptorCollection(ConsoleAppOptions options) - { - this.options = options; - } - - public void AddCommand(CommandDescriptor commandDescriptor) - { - foreach (var name in commandDescriptor.GetNames(options)) - { - if (subCommandDescriptors.ContainsKey(name) || !descriptors.TryAdd(name, commandDescriptor)) - { - throw new InvalidOperationException($"Duplicate command name is added. Name:{name} Method:{commandDescriptor.MethodInfo.DeclaringType?.Name}.{commandDescriptor.MethodInfo.Name}"); - } - } - } - - public void AddSubCommand(string parentCommand, CommandDescriptor commandDescriptor) - { - if (descriptors.ContainsKey(parentCommand)) - { - throw new InvalidOperationException($"Duplicate parent-command is added. Name:{parentCommand} Method:{commandDescriptor.MethodInfo.DeclaringType?.Name}.{commandDescriptor.MethodInfo.Name}"); - } - - if (!subCommandDescriptors.TryGetValue(parentCommand, out var commandDict)) - { - commandDict = new Dictionary(StringComparer.OrdinalIgnoreCase); - subCommandDescriptors.Add(parentCommand, commandDict); - } - - foreach (var name in commandDescriptor.GetNames(options)) - { - if (!commandDict.TryAdd(name, commandDescriptor)) - { - throw new InvalidOperationException($"Duplicate command name is added. Name:{parentCommand} {name} Method:{commandDescriptor.MethodInfo.DeclaringType?.Name}.{commandDescriptor.MethodInfo.Name}"); - } - } - } - - public void AddRootCommand(CommandDescriptor commandDescriptor) - { - if (this.rootCommandDescriptor != null) - { - throw new InvalidOperationException($"Found more than one root command. Method:{rootCommandDescriptor.MethodInfo.DeclaringType?.Name}.{rootCommandDescriptor.MethodInfo.Name} and {commandDescriptor.MethodInfo.DeclaringType?.Name}.{commandDescriptor.MethodInfo.Name}"); - } - - this.rootCommandDescriptor = commandDescriptor; - } - - // Only check command name(not foo) - public bool TryGetDescriptor(string[] args, [MaybeNullWhen(false)] out CommandDescriptor descriptor, out int offset) - { - // 1. Try to match sub command - if (args.Length >= 2) - { - if (subCommandDescriptors.TryGetValue(args[0], out var dict)) - { - if (dict.TryGetValue(args[1], out descriptor)) - { - offset = 2; - return true; - } - else - { - goto NOTMATCH; - } - } - } - - // 2. Try to match command - if (args.Length >= 1) - { - if (descriptors.TryGetValue(args[0], out descriptor)) - { - offset = 1; - return true; - } - } - - // 3. default - if (rootCommandDescriptor != null) - { - offset = 0; - descriptor = rootCommandDescriptor; - return true; - } - - // not match. - NOTMATCH: - offset = 0; - descriptor = default; - return false; - } - - public void TryAddDefaultHelpMethod() - { - // add if not exists. - descriptors.TryAdd(DefaultCommands.Help, DefaultCommands.HelpCommand); - } - - public void TryAddDefaultVersionMethod() - { - descriptors.TryAdd(DefaultCommands.Version, DefaultCommands.VersionCommand); - } - - public bool TryGetHelpMethod([MaybeNullWhen(false)] out CommandDescriptor commandDescriptor) - { - return descriptors.TryGetValue(DefaultCommands.Help, out commandDescriptor); - } - - public bool TryGetVersionMethod([MaybeNullWhen(false)] out CommandDescriptor commandDescriptor) - { - return descriptors.TryGetValue(DefaultCommands.Version, out commandDescriptor); - } - - public CommandDescriptor? GetRootCommandDescriptor() => rootCommandDescriptor; - - /// - /// GetAll(except default) descriptors. - /// - public IEnumerable GetAllDescriptors() - { - IEnumerable IterateCore() - { - foreach (var item in descriptors.Values) - { - yield return item; - } - foreach (var item in subCommandDescriptors.Values) - { - foreach (var item2 in item.Values) - { - yield return item2; - } - } - } - - return IterateCore().Distinct(); - } - - public CommandDescriptor[] GetSubCommands(string rootCommand) - { - if (subCommandDescriptors.TryGetValue(rootCommand, out var dict)) - { - return dict.Values.Distinct().ToArray(); - } - - return Array.Empty(); - } - - class TupleStringComparer : IEqualityComparer<(string, string)> - { - public bool Equals((string, string) x, (string, string) y) - { - if (StringComparer.OrdinalIgnoreCase.Equals(x.Item1, y.Item1)) - { - if (StringComparer.OrdinalIgnoreCase.Equals(x.Item2, y.Item2)) - { - return true; - } - } - return false; - } - - public int GetHashCode((string, string) obj) - { - return (StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Item1), StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Item2)).GetHashCode(); - } - } - } -} \ No newline at end of file diff --git a/src/ConsoleAppFramework/CommandHelpBuilder.cs b/src/ConsoleAppFramework/CommandHelpBuilder.cs index 667c309..0e7a73e 100644 --- a/src/ConsoleAppFramework/CommandHelpBuilder.cs +++ b/src/ConsoleAppFramework/CommandHelpBuilder.cs @@ -1,239 +1,240 @@ -using Microsoft.Extensions.DependencyInjection; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; +using Microsoft.CodeAnalysis; using System.Text; -namespace ConsoleAppFramework +namespace ConsoleAppFramework; + +public static class CommandHelpBuilder { - internal class CommandHelpBuilder + public static string BuildRootHelpMessage(Command command) { - readonly Func getExecutionCommandName; - readonly bool isStrictOption; - readonly IServiceProviderIsService isService; - readonly ConsoleAppOptions options; + return BuildHelpMessageCore(command, showCommandName: false, showCommand: false); + } + + public static string BuildRootHelpMessage(Command[] commands) + { + var sb = new StringBuilder(); + + var rootCommand = commands.FirstOrDefault(x => x.IsRootCommand); + var withoutRoot = commands.Where(x => !x.IsRootCommand).ToArray(); - public CommandHelpBuilder(Func? getExecutionCommandName, IServiceProviderIsService isService, ConsoleAppOptions options) + if (rootCommand != null && withoutRoot.Length == 0) { - this.getExecutionCommandName = getExecutionCommandName ?? GetExecutionCommandNameDefault; - this.isStrictOption = options.StrictOption; - this.isService = isService; - this.options = options; + return BuildRootHelpMessage(commands[0]); } - private string GetExecutionCommandNameDefault() + if (rootCommand != null) { - if (options.ApplicationName != null) - { - return options.ApplicationName; - } - else - { - return Path.GetFileNameWithoutExtension(Assembly.GetEntryAssembly()!.Location); - } + sb.AppendLine(BuildHelpMessageCore(rootCommand, false, withoutRoot.Length != 0)); + } + else + { + sb.AppendLine("Usage: [command] [-h|--help] [--version]"); + sb.AppendLine(); } - public string GetExecutionCommandName() => getExecutionCommandName(); + if (withoutRoot.Length == 0) return sb.ToString(); - public string BuildHelpMessage(CommandDescriptor? defaultCommand, IEnumerable commands, bool shortCommandName) - { - var sb = new StringBuilder(); + var helpDefinitions = withoutRoot.OrderBy(x => x.Name).ToArray(); - bool showHeader = (defaultCommand != null); - if (defaultCommand != null) - { - // Display a help messages for default method - sb.Append(BuildHelpMessage(CreateCommandHelpDefinition(defaultCommand, shortCommandName), showCommandName: false, fromMultiCommand: false)); - } + var list = BuildMethodListMessage(helpDefinitions, out _); + sb.Append(list); - var orderedCommands = options.HelpSortCommandsByFullName - ? commands.OrderBy(x => x.GetCommandName(options)).ToArray() - : commands.OrderBy(x => x.GetNamesFormatted(options)).ToArray(); - if (orderedCommands.Length > 0) - { - if (defaultCommand == null) - { - sb.Append(BuildUsageMessage()); - sb.AppendLine(); - } + return sb.ToString(); + } - sb.Append(BuildMethodListMessage(orderedCommands, shortCommandName, out var maxWidth)); - } + public static string BuildCommandHelpMessage(Command command) + { + return BuildHelpMessageCore(command, showCommandName: command.Name != "", showCommand: false); + } - return sb.ToString(); - } + static string BuildHelpMessageCore(Command command, bool showCommandName, bool showCommand) + { + var definition = CreateCommandHelpDefinition(command); - public string BuildHelpMessage(CommandDescriptor command) + var sb = new StringBuilder(); + + sb.AppendLine(BuildUsageMessage(definition, showCommandName, showCommand)); + + if (!string.IsNullOrEmpty(definition.Description)) { - return BuildHelpMessage(CreateCommandHelpDefinition(command, false), showCommandName: false, fromMultiCommand: false); + sb.AppendLine(); + sb.AppendLine(definition.Description); } - internal string BuildHelpMessage(CommandHelpDefinition definition, bool showCommandName, bool fromMultiCommand) + if (definition.Options.Any()) { - var sb = new StringBuilder(); - - sb.AppendLine(BuildUsageMessage(definition, showCommandName, fromMultiCommand)); - sb.AppendLine(); + var hasArgument = definition.Options.Any(x => x.Index.HasValue); + var hasOptions = definition.Options.Any(x => !x.Index.HasValue); - if (!string.IsNullOrEmpty(definition.Description)) + if (hasArgument) { - sb.AppendLine(definition.Description); sb.AppendLine(); + sb.AppendLine(BuildArgumentsMessage(definition)); } - if (definition.Options.Any()) + if (hasOptions) { - sb.Append(BuildArgumentsMessage(definition)); - sb.Append(BuildOptionsMessage(definition)); - } - else - { - sb.AppendLine("Options:"); - sb.AppendLine(" ()"); sb.AppendLine(); + sb.AppendLine(BuildOptionsMessage(definition)); } - - return sb.ToString(); } - internal string BuildUsageMessage() - { - var sb = new StringBuilder(); - sb.AppendLine($"Usage: {GetExecutionCommandName()} "); + return sb.ToString(); + } + + static string BuildUsageMessage(CommandHelpDefinition definition, bool showCommandName, bool showCommand) + { + var sb = new StringBuilder(); + sb.Append($"Usage:"); - return sb.ToString(); + if (showCommandName) + { + sb.Append($" {definition.CommandName}"); } - internal string BuildUsageMessage(CommandHelpDefinition definition, bool showCommandName, bool fromMultiCommand) + if (showCommand) { - var sb = new StringBuilder(); - sb.Append($"Usage: {GetExecutionCommandName()}"); + sb.Append(" [command]"); + } - if (showCommandName) - { - sb.Append($" {(definition.CommandAliases.Any() ? ((fromMultiCommand ? definition.Command + " " : "") + definition.CommandAliases[0]) : definition.Command)}"); - } + if (definition.Options.Any(x => x.Index.HasValue)) + { + sb.Append(" [arguments...]"); + } - foreach (var opt in definition.Options.Where(x => x.Index.HasValue)) - { - sb.Append($" <{(string.IsNullOrEmpty(opt.Description) ? opt.Options[0] : opt.Description)}>"); - } + if (definition.Options.Any(x => !x.Index.HasValue)) + { + sb.Append(" [options...]"); + } - if (definition.Options.Any(x => !x.Index.HasValue)) - { - sb.Append(" [options...]"); - } + sb.Append(" [-h|--help] [--version]"); - return sb.ToString(); - } + return sb.ToString(); + } - internal string BuildArgumentsMessage(CommandHelpDefinition definition) - { - var argumentsFormatted = definition.Options - .Where(x => x.Index.HasValue) - .Select(x => (Argument: $"[{x.Index}] {x.FormattedValueTypeName}", x.Description)) - .ToArray(); + static string BuildArgumentsMessage(CommandHelpDefinition definition) + { + var argumentsFormatted = definition.Options + .Where(x => x.Index.HasValue) + .Select(x => (Argument: $"[{x.Index}] {x.FormattedValueTypeName}", x.Description)) + .ToArray(); - if (!argumentsFormatted.Any()) return string.Empty; + if (!argumentsFormatted.Any()) return string.Empty; - var maxWidth = argumentsFormatted.Max(x => x.Argument.Length); + var maxWidth = argumentsFormatted.Max(x => x.Argument.Length); - var sb = new StringBuilder(); + var sb = new StringBuilder(); - sb.AppendLine("Arguments:"); - foreach (var arg in argumentsFormatted) + sb.AppendLine("Arguments:"); + var first = true; + foreach (var arg in argumentsFormatted) + { + if (first) { - var padding = maxWidth - arg.Argument.Length; + first = false; + } + else + { + sb.AppendLine(); + } + var padding = maxWidth - arg.Argument.Length; - sb.Append(" "); - sb.Append(arg.Argument); + sb.Append(" "); + sb.Append(arg.Argument); + if (!string.IsNullOrEmpty(arg.Description)) + { for (var i = 0; i < padding; i++) { sb.Append(' '); } sb.Append(" "); - sb.AppendLine(arg.Description); + sb.Append(arg.Description); } - - sb.AppendLine(); - - return sb.ToString(); } - internal string BuildOptionsMessage(CommandHelpDefinition definition) - { - var optionsFormatted = definition.Options - .Where(x => !x.Index.HasValue) - .Select(x => (Options: string.Join(", ", x.Options) + (x.IsFlag ? string.Empty : $" {x.FormattedValueTypeName}"), x.Description, x.IsRequired, x.IsFlag, x.DefaultValue)) - .ToArray(); + return sb.ToString(); + } - if (!optionsFormatted.Any()) return string.Empty; + static string BuildOptionsMessage(CommandHelpDefinition definition) + { + var optionsFormatted = definition.Options + .Where(x => !x.Index.HasValue) + .Select(x => (Options: string.Join("|", x.Options) + (x.IsFlag ? string.Empty : $" {x.FormattedValueTypeName}{(x.IsParams ? "..." : "")}"), x.Description, x.IsRequired, x.IsFlag, x.DefaultValue)) + .ToArray(); - var maxWidth = optionsFormatted.Max(x => x.Options.Length); + if (!optionsFormatted.Any()) return string.Empty; - var sb = new StringBuilder(); + var maxWidth = optionsFormatted.Max(x => x.Options.Length); - sb.AppendLine("Options:"); - foreach (var opt in optionsFormatted) - { - var options = opt.Options; - var padding = maxWidth - options.Length; + var sb = new StringBuilder(); - sb.Append(" "); - sb.Append(options); - for (var i = 0; i < padding; i++) - { - sb.Append(' '); - } - - sb.Append(" "); - sb.Append(opt.Description); + sb.AppendLine("Options:"); + var first = true; + foreach (var opt in optionsFormatted) + { + if (first) + { + first = false; + } + else + { + sb.AppendLine(); + } - if (opt.IsFlag) - { - sb.Append($" (Optional)"); - } - else if (opt.DefaultValue != null) - { - sb.Append($" (Default: {opt.DefaultValue})"); - } - else if (opt.IsRequired) - { - sb.Append($" (Required)"); - } + var options = opt.Options; + var padding = maxWidth - options.Length; - sb.AppendLine(); + sb.Append(" "); + sb.Append(options); + for (var i = 0; i < padding; i++) + { + sb.Append(' '); } - sb.AppendLine(); + sb.Append(" "); + sb.Append(opt.Description); - return sb.ToString(); + if (opt.IsFlag) + { + sb.Append($" (Optional)"); + } + else if (opt.DefaultValue != null) + { + sb.Append($" (Default: {opt.DefaultValue})"); + } + else if (opt.IsRequired) + { + sb.Append($" (Required)"); + } } - internal string BuildMethodListMessage(IEnumerable types, bool shortCommandName, out int maxWidth) - { - maxWidth = 0; - return BuildMethodListMessage(types.Select(x => CreateCommandHelpDefinition(x, shortCommandName)), true, out maxWidth); - } + return sb.ToString(); + } - internal string BuildMethodListMessage(IEnumerable commandHelpDefinitions, bool appendCommand, out int maxWidth) - { - var formatted = commandHelpDefinitions - .Select(x => (Command: $"{(x.CommandAliases.Length != 0 ? ((appendCommand ? x.Command + " " : "") + string.Join(", ", x.CommandAliases)) : x.Command)}", Description: x.Description)) - .ToArray(); - maxWidth = formatted.Max(x => x.Command.Length); + static string BuildMethodListMessage(IEnumerable commands, out int maxWidth) + { + var formatted = commands + .Select(x => + { + return (Command: x.Name, x.Description); + }) + .ToArray(); + maxWidth = formatted.Max(x => x.Command.Length); - var sb = new StringBuilder(); + var sb = new StringBuilder(); - sb.AppendLine("Commands:"); - foreach (var item in formatted) + sb.AppendLine("Commands:"); + foreach (var item in formatted) + { + sb.Append(" "); + sb.Append(item.Command); + if (string.IsNullOrEmpty(item.Description)) + { + sb.AppendLine(); + } + else { - sb.Append(" "); - sb.Append(item.Command); - var padding = maxWidth - item.Command.Length; for (var i = 0; i < padding; i++) { @@ -243,148 +244,109 @@ internal string BuildMethodListMessage(IEnumerable comman sb.Append(" "); sb.AppendLine(item.Description); } - - return sb.ToString(); } - internal CommandHelpDefinition CreateCommandHelpDefinition(CommandDescriptor descriptor, bool shortCommandName) - { - var parameterDefinitions = new List(); - - foreach (var item in descriptor.MethodInfo.GetParameters()) - { - // ignore DI params. - if (item.ParameterType == typeof(ConsoleAppContext) || (isService != null && isService.IsService(item.ParameterType))) continue; - - // -i, -input | [default=foo]... + return sb.ToString(); + } - var index = default(int?); - var itemName = this.options.NameConverter(item.Name!); + static CommandHelpDefinition CreateCommandHelpDefinition(Command descriptor) + { + var parameterDefinitions = new List(); - var options = new List(); - var option = item.GetCustomAttribute(); - if (option != null) - { - if (option.Index != -1) - { - index = option.Index; - options.Add($"[{option.Index}]"); - } - else - { - // If Index is -1, ShortName is initialized at Constractor. - if (option.ShortName != null) - { - options.Add($"-{option.ShortName.Trim('-')}"); - } - } - } + foreach (var item in descriptor.Parameters) + { + // ignore DI params. + if (!item.IsParsable) continue; - if (!index.HasValue) - { - if (isStrictOption) - { - options.Add($"--{itemName}"); - } - else - { - options.Add($"-{itemName}"); - } - } + // -i, -input | [default=foo]... - var description = string.Empty; - if (option != null && !string.IsNullOrEmpty(option.Description)) - { - description = option.Description ?? string.Empty; - } - else + var index = item.ArgumentIndex == -1 ? null : (int?)item.ArgumentIndex; + var options = new List(); + if (item.ArgumentIndex != -1) + { + options.Add($"[{item.ArgumentIndex}]"); + } + else + { + // aliases first + foreach (var alias in item.Aliases) { - description = string.Empty; + options.Add(alias); } + options.Add("--" + item.Name); + } - var isFlag = item.ParameterType == typeof(bool); + var description = item.Description; + var isFlag = item.Type.SpecialType == Microsoft.CodeAnalysis.SpecialType.System_Boolean; + var isParams = item.IsParams; - var defaultValue = default(string); - if (item.HasDefaultValue) + var defaultValue = default(string); + if (item.HasDefaultValue) + { + defaultValue = item.DefaultValue == null ? "null" : item.DefaultValueToString(castValue: false, enumIncludeTypeName: false); + if (isFlag) { - if (option?.DefaultValue != null) + if (item.DefaultValue is true) { - defaultValue = option.DefaultValue; + // bool option with true default value is not flag. + isFlag = false; } - else + else if (item.DefaultValue is false) { - defaultValue = (item.DefaultValue?.ToString() ?? "null"); - } - if (isFlag) - { - if (item.DefaultValue is true) - { - // bool option with true default value is not flag. - isFlag = false; - } - else if (item.DefaultValue is false) - { - // false default value should be omitted for flag. - defaultValue = null; - } + // false default value should be omitted for flag. + defaultValue = null; } } - - var paramTypeName = item.ParameterType.Name; - if (item.ParameterType.IsGenericType && item.ParameterType.GetGenericTypeDefinition() == typeof(Nullable<>)) - { - paramTypeName = item.ParameterType.GetGenericArguments()[0].Name + "?"; - } - - parameterDefinitions.Add(new CommandOptionHelpDefinition(options.Distinct().ToArray(), description, paramTypeName, defaultValue, index, isFlag)); } - return new CommandHelpDefinition( - shortCommandName ? descriptor.GetNamesFormatted(options) : descriptor.GetCommandName(options), - descriptor.Aliases, - parameterDefinitions.OrderBy(x => x.Index ?? int.MaxValue).ToArray(), - descriptor.Description - ); + var paramTypeName = item.ToTypeShortString(); + parameterDefinitions.Add(new CommandOptionHelpDefinition(options.Distinct().ToArray(), description, paramTypeName, defaultValue, index, isFlag, isParams)); } - public class CommandHelpDefinition - { - public string Command { get; } - public string[] CommandAliases { get; } - public CommandOptionHelpDefinition[] Options { get; } - public string Description { get; } + var commandName = descriptor.Name; + return new CommandHelpDefinition( + commandName, + parameterDefinitions.ToArray(), + descriptor.Description + ); + } - public CommandHelpDefinition(string command, string[] commandAliases, CommandOptionHelpDefinition[] options, string description) - { - Command = command; - CommandAliases = commandAliases; - Options = options; - Description = description; - } - } + class CommandHelpDefinition + { + public string CommandName { get; } + public CommandOptionHelpDefinition[] Options { get; } + public string Description { get; } - public class CommandOptionHelpDefinition + public CommandHelpDefinition(string command, CommandOptionHelpDefinition[] options, string description) { - public string[] Options { get; } - public string Description { get; } - public string? DefaultValue { get; } - public string ValueTypeName { get; } - public int? Index { get; } - - public bool IsRequired => DefaultValue == null; - public bool IsFlag { get; } - public string FormattedValueTypeName => "<" + ValueTypeName + ">"; - - public CommandOptionHelpDefinition(string[] options, string description, string valueTypeName, string? defaultValue, int? index, bool isFlag) - { - Options = options; - Description = description; - ValueTypeName = valueTypeName; - DefaultValue = defaultValue; - Index = index; - IsFlag = isFlag; - } + CommandName = command; + Options = options; + Description = description; } + } + class CommandOptionHelpDefinition + { + public string[] Options { get; } + public string Description { get; } + public string? DefaultValue { get; } + public string ValueTypeName { get; } + public int? Index { get; } + + public bool IsRequired => DefaultValue == null && !IsParams; + public bool IsFlag { get; } + public bool IsParams { get; } + public string FormattedValueTypeName => "<" + ValueTypeName + ">"; + + public CommandOptionHelpDefinition(string[] options, string description, string valueTypeName, string? defaultValue, int? index, bool isFlag, bool isParams) + { + Options = options; + Description = description; + ValueTypeName = valueTypeName; + DefaultValue = defaultValue; + Index = index; + IsFlag = isFlag; + IsParams = isParams; + } } -} +} \ No newline at end of file diff --git a/src/ConsoleAppFramework/ConsoleApp.cs b/src/ConsoleAppFramework/ConsoleApp.cs deleted file mode 100644 index 00746c3..0000000 --- a/src/ConsoleAppFramework/ConsoleApp.cs +++ /dev/null @@ -1,255 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Threading; -using System.Threading.Tasks; - -namespace ConsoleAppFramework -{ - public class ConsoleApp - { - // Keep this reference as ConsoleApOptions.CommandDescriptors. - readonly CommandDescriptorCollection commands; - readonly ConsoleAppOptions options; - - public IHost Host { get; } - public ILogger Logger { get; } - public IServiceProvider Services => Host.Services; - public IConfiguration Configuration => Host.Services.GetRequiredService(); - public IHostEnvironment Environment => Host.Services.GetRequiredService(); - public IHostApplicationLifetime Lifetime => Host.Services.GetRequiredService(); - - internal ConsoleApp(IHost host) - { - this.Host = host; - this.Logger = host.Services.GetRequiredService>(); - this.options = host.Services.GetRequiredService(); - this.commands = options.CommandDescriptors; - } - - // Statics - - public static ConsoleApp Create(string[] args) - { - return CreateBuilder(args).Build(); - } - - public static ConsoleApp Create(string[] args, Action configureOptions) - { - return CreateBuilder(args, configureOptions).Build(); - } - - public static ConsoleApp Create(string[] args, Action configureOptions) - { - return CreateBuilder(args, configureOptions).Build(); - } - - public static ConsoleAppBuilder CreateBuilder(string[] args) - { - return new ConsoleAppBuilder(args, Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder(args)); - } - - public static ConsoleAppBuilder CreateBuilder(string[] args, Action configureOptions) - { - return new ConsoleAppBuilder(args, Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder(args), configureOptions); - } - - public static ConsoleAppBuilder CreateBuilder(string[] args, Action configureOptions) - { - return new ConsoleAppBuilder(args, Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder(args), configureOptions); - } - - public static ConsoleApp CreateFromHostBuilder(IHostBuilder hostBuilder, string[] args) - { - return new ConsoleAppBuilder(args, hostBuilder).Build(); - } - - public static ConsoleApp CreateFromHostBuilder(IHostBuilder hostBuilder, string[] args, Action configureOptions) - { - return new ConsoleAppBuilder(args, hostBuilder, configureOptions).Build(); - } - - public static ConsoleApp CreateFromHostBuilder(IHostBuilder hostBuilder, string[] args, Action configureOptions) - { - return new ConsoleAppBuilder(args, hostBuilder, configureOptions).Build(); - } - - public static void Run(string[] args, Delegate rootCommand) - { - RunAsync(args, rootCommand).GetAwaiter().GetResult(); - } - - public static Task RunAsync(string[] args, Delegate rootCommand) - { - return Create(args).AddRootCommand(rootCommand).RunAsync(); - } - - public static void Run(string[] args) - where T : ConsoleAppBase - { - Create(args).AddCommands().Run(); - } - - // Add Command - - public ConsoleApp AddRootCommand(Delegate command) - { - var attr = command.Method.GetCustomAttribute(); - commands.AddRootCommand(new CommandDescriptor(CommandType.DefaultCommand, command.Method, command.Target, attr)); - return this; - } - - public ConsoleApp AddRootCommand(string description, Delegate command) - { - var attr = new CommandAttribute("root-command", description); - commands.AddRootCommand(new CommandDescriptor(CommandType.DefaultCommand, command.Method, command.Target, attr)); - return this; - } - - public ConsoleApp AddCommand(string commandName, Delegate command) - { - var attr = new CommandAttribute(commandName); - commands.AddCommand(new CommandDescriptor(CommandType.Command, command.Method, command.Target, attr)); - return this; - } - - public ConsoleApp AddCommand(string commandName, string description, Delegate command) - { - var attr = new CommandAttribute(commandName, description); - commands.AddCommand(new CommandDescriptor(CommandType.Command, command.Method, command.Target, attr)); - return this; - } - - public ConsoleApp AddCommands() - where T : ConsoleAppBase - { - var methods = typeof(T).GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); - foreach (var method in methods) - { - if (method.Name == "Dispose" || method.Name == "DisposeAsync") continue; // ignore IDisposable - - if (method.GetCustomAttribute() != null || (options.NoAttributeCommandAsImplicitlyDefault && method.GetCustomAttribute() == null)) - { - var command = new CommandDescriptor(CommandType.DefaultCommand, method); - commands.AddRootCommand(command); - } - else - { - var command = new CommandDescriptor(CommandType.Command, method); - commands.AddCommand(command); - } - } - return this; - } - - public ConsoleApp AddSubCommand(string parentCommandName, string commandName, Delegate command) - { - var attr = new CommandAttribute(commandName); - commands.AddSubCommand(parentCommandName, new CommandDescriptor(CommandType.SubCommand, command.Method, command.Target, attr, parentCommandName)); - return this; - } - - public ConsoleApp AddSubCommand(string parentCommandName, string commandName, string description, Delegate command) - { - var attr = new CommandAttribute(commandName, description); - commands.AddSubCommand(parentCommandName, new CommandDescriptor(CommandType.SubCommand, command.Method, command.Target, attr, parentCommandName)); - return this; - } - - public ConsoleApp AddSubCommands() - { - var methods = typeof(T).GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); - - var rootName = typeof(T).GetCustomAttribute()?.CommandNames[0] ?? options.NameConverter(typeof(T).Name); - - foreach (var method in methods) - { - if (method.Name == "Dispose" || method.Name == "DisposeAsync") continue; // ignore IDisposable - - if (method.GetCustomAttribute() != null) - { - var command = new CommandDescriptor(CommandType.DefaultCommand, method); - commands.AddRootCommand(command); - } - else - { - var command = new CommandDescriptor(CommandType.SubCommand, method, parentCommand: rootName); - commands.AddSubCommand(rootName, command); - } - } - return this; - } - - public ConsoleApp AddAllCommandType() - { - return AddAllCommandType(AppDomain.CurrentDomain.GetAssemblies()); - } - - public ConsoleApp AddAllCommandType(params Assembly[] searchAssemblies) - { - foreach (var type in GetConsoleAppTypes(searchAssemblies)) - { - var methods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); - var rootName = type.GetCustomAttribute()?.CommandNames[0] ?? options.NameConverter(type.Name); - foreach (var method in methods) - { - if (method.Name == "Dispose" || method.Name == "DisposeAsync") continue; // ignore IDisposable - - commands.AddSubCommand(rootName, new CommandDescriptor(CommandType.SubCommand, method, parentCommand: rootName)); - } - } - return this; - } - - // Run - - public void Run() - { - RunAsync().GetAwaiter().GetResult(); - } - - // Don't use return RunAsync to keep stacktrace. - public async Task RunAsync(CancellationToken cancellationToken = default) - { - commands.TryAddDefaultHelpMethod(); - commands.TryAddDefaultVersionMethod(); - - await Host.RunAsync(cancellationToken); - } - - static List GetConsoleAppTypes(Assembly[] searchAssemblies) - { - List consoleAppBaseTypes = new List(); - - foreach (var asm in searchAssemblies) - { - if (asm.FullName!.StartsWith("System") || asm.FullName.StartsWith("Microsoft.Extensions") || asm.GetName().Name == "ConsoleAppFramework") continue; - - Type?[] types; - try - { - types = asm.GetTypes(); - } - catch (ReflectionTypeLoadException ex) - { - types = ex.Types ?? Array.Empty(); - } - - foreach (var item in types.Where(x => x != null)) - { - if (typeof(ConsoleAppBase).IsAssignableFrom(item) && item != typeof(ConsoleAppBase)) - { - consoleAppBaseTypes.Add(item!); - } - } - } - - return consoleAppBaseTypes; - } - } -} \ No newline at end of file diff --git a/src/ConsoleAppFramework/ConsoleAppBase.cs b/src/ConsoleAppFramework/ConsoleAppBase.cs deleted file mode 100644 index a491735..0000000 --- a/src/ConsoleAppFramework/ConsoleAppBase.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace ConsoleAppFramework -{ - public abstract class ConsoleAppBase - { - // Context will be set non-null value by ConsoleAppEngine, - // but it might be null because it has public setter. - #nullable disable warnings - public ConsoleAppContext Context { get; set; } - #nullable restore warnings - } -} diff --git a/src/ConsoleAppFramework/ConsoleAppBuilder.cs b/src/ConsoleAppFramework/ConsoleAppBuilder.cs deleted file mode 100644 index 5ad8191..0000000 --- a/src/ConsoleAppFramework/ConsoleAppBuilder.cs +++ /dev/null @@ -1,223 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; - -namespace ConsoleAppFramework -{ - public class ConsoleAppBuilder : IHostBuilder - { - readonly IHostBuilder builder; - - internal ConsoleAppBuilder(string[] args, IHostBuilder hostBuilder) - : this(args, hostBuilder, (_, __) => { }) - { - } - - internal ConsoleAppBuilder(string[] args, IHostBuilder hostBuilder, ConsoleAppOptions consoleAppOptions) - { - this.builder = AddConsoleAppFramework(hostBuilder, args, consoleAppOptions, null); - } - - internal ConsoleAppBuilder(string[] args, IHostBuilder hostBuilder, Action configureOptions) - : this(args, hostBuilder, (_, options) => configureOptions(options)) - { - } - - internal ConsoleAppBuilder(string[] args, IHostBuilder hostBuilder, Action configureOptions) - { - this.builder = AddConsoleAppFramework(hostBuilder, args, new ConsoleAppOptions(), configureOptions); - } - - IHostBuilder AddConsoleAppFramework(IHostBuilder builder, string[] args, ConsoleAppOptions options, Action? configureOptions) - { - return builder - .ConfigureServices((ctx, services) => - { - services.AddOptions().Configure(x => x.SuppressStatusMessages = true); - services.AddHostedService(); - configureOptions?.Invoke(ctx, options); - options.CommandLineArguments = args; - services.AddSingleton(options); - services.AddSingleton(); - - if (options.ReplaceToUseSimpleConsoleLogger) - { - services.AddLogging(builder => - { - builder.ReplaceToSimpleConsole(); - }); - } - }) - .UseConsoleLifetime(); - } - - // IHostBuilder implementations:: - - public IDictionary Properties => builder.Properties; - - IHost IHostBuilder.Build() - { - return builder.Build(); - } - - IHostBuilder IHostBuilder.ConfigureAppConfiguration(Action configureDelegate) - { - return builder.ConfigureAppConfiguration(configureDelegate); - } - - IHostBuilder IHostBuilder.ConfigureContainer(Action configureDelegate) - { - return builder.ConfigureContainer(configureDelegate); - } - - IHostBuilder IHostBuilder.ConfigureHostConfiguration(Action configureDelegate) - { - return builder.ConfigureHostConfiguration(configureDelegate); - } - - IHostBuilder IHostBuilder.ConfigureServices(Action configureDelegate) - { - return builder.ConfigureServices(configureDelegate); - } - - IHostBuilder IHostBuilder.UseServiceProviderFactory(IServiceProviderFactory factory) - { - return builder.UseServiceProviderFactory(factory); - } - - IHostBuilder IHostBuilder.UseServiceProviderFactory(Func> factory) - { - return builder.UseServiceProviderFactory(factory); - } - - // override implementations that returns ConsoleAppBuilder - - public ConsoleApp Build() - { - var host = builder.Build(); - return new ConsoleApp(host); - } - - public ConsoleAppBuilder ConfigureAppConfiguration(Action configureDelegate) - { - builder.ConfigureAppConfiguration(configureDelegate); - return this; - } - - public ConsoleAppBuilder ConfigureContainer(Action configureDelegate) - { - builder.ConfigureContainer(configureDelegate); - return this; - } - - public ConsoleAppBuilder ConfigureHostConfiguration(Action configureDelegate) - { - builder.ConfigureHostConfiguration(configureDelegate); - return this; - } - - public ConsoleAppBuilder ConfigureServices(Action configureDelegate) - { - builder.ConfigureServices(configureDelegate); - return this; - } - - public ConsoleAppBuilder UseServiceProviderFactory(IServiceProviderFactory factory) - where TContainerBuilder : notnull - { - builder.UseServiceProviderFactory(factory); - return this; - } - - public ConsoleAppBuilder UseServiceProviderFactory(Func> factory) - where TContainerBuilder : notnull - { - builder.UseServiceProviderFactory(factory); - return this; - } - - // Override Configure methods(Microsoft.Extensions.Hosting.HostingHostBuilderExtensions) tor return ConsoleAppBuilder - - public ConsoleAppBuilder ConfigureLogging(Action configureLogging) - { - (this as IHostBuilder).ConfigureLogging(configureLogging); - return this; - } - - public ConsoleAppBuilder ConfigureLogging(Action configureLogging) - { - (this as IHostBuilder).ConfigureLogging(configureLogging); - return this; - } - - public ConsoleAppBuilder UseEnvironment(string environment) - { - (this as IHostBuilder).UseEnvironment(environment); - return this; - } - - public ConsoleAppBuilder UseContentRoot(string contentRoot) - { - (this as IHostBuilder).UseContentRoot(contentRoot); - return this; - } - - public ConsoleAppBuilder UseDefaultServiceProvider(Action configure) - { - (this as IHostBuilder).UseDefaultServiceProvider(configure); - return this; - } - - public ConsoleAppBuilder UseDefaultServiceProvider(Action configure) - { - (this as IHostBuilder).UseDefaultServiceProvider(configure); - return this; - } - - public ConsoleAppBuilder ConfigureHostOptions(Action configureOptions) - { - (this as IHostBuilder).ConfigureHostOptions(configureOptions); - return this; - } - - public ConsoleAppBuilder ConfigureHostOptions(Action configureOptions) - { - (this as IHostBuilder).ConfigureHostOptions(configureOptions); - return this; - } - - public ConsoleAppBuilder ConfigureAppConfiguration(Action configureDelegate) - { - (this as IHostBuilder).ConfigureAppConfiguration(configureDelegate); - return this; - } - - public ConsoleAppBuilder ConfigureServices(Action configureDelegate) - { - (this as IHostBuilder).ConfigureServices(configureDelegate); - return this; - } - - public ConsoleAppBuilder ConfigureContainer(Action configureDelegate) - { - (this as IHostBuilder).ConfigureContainer(configureDelegate); - return this; - } - } - - public static class HostBuilderExtensions - { - public static ConsoleApp BuildAsConsoleApp(this IHostBuilder hostBuilder) - { - var app = hostBuilder.Build() as ConsoleApp; - if (app == null) - { - throw new InvalidOperationException($"HostBuilder is not ConsoleAppBuilder."); - } - return app; - } - } -} \ No newline at end of file diff --git a/src/ConsoleAppFramework/ConsoleAppContext.cs b/src/ConsoleAppFramework/ConsoleAppContext.cs deleted file mode 100644 index 804df87..0000000 --- a/src/ConsoleAppFramework/ConsoleAppContext.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Reflection; -using System.Threading; - -namespace ConsoleAppFramework -{ - public class ConsoleAppContext - { - readonly CancellationTokenSource cancellationTokenSource; - - public string?[] Arguments { get; } - public DateTime Timestamp { get; } - public CancellationToken CancellationToken { get; } - public ILogger Logger { get; } - public MethodInfo MethodInfo { get; } - public IServiceProvider ServiceProvider { get; } - public IDictionary Items { get; } - - public ConsoleAppContext(string?[] arguments, DateTime timestamp, CancellationTokenSource cancellationTokenSource, ILogger logger, MethodInfo methodInfo, IServiceProvider serviceProvider) - { - this.cancellationTokenSource = cancellationTokenSource; - Arguments = arguments; - Timestamp = timestamp; - CancellationToken = cancellationTokenSource.Token; - Logger = logger; - MethodInfo = methodInfo; - ServiceProvider = serviceProvider; - Items = new Dictionary(); - } - - public void Cancel() - { - cancellationTokenSource.Cancel(); - } - - public void Terminate() - { - cancellationTokenSource.Cancel(); - cancellationTokenSource.Token.ThrowIfCancellationRequested(); - } - } -} diff --git a/src/ConsoleAppFramework/ConsoleAppEngine.cs b/src/ConsoleAppFramework/ConsoleAppEngine.cs deleted file mode 100644 index 0f6b647..0000000 --- a/src/ConsoleAppFramework/ConsoleAppEngine.cs +++ /dev/null @@ -1,518 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Collections.ObjectModel; -using System.ComponentModel.DataAnnotations; -using System.Linq; -using System.Reflection; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -namespace ConsoleAppFramework -{ - internal class ConsoleAppEngine - { - readonly ILogger logger; - readonly IServiceProvider provider; - readonly CancellationTokenSource cancellationTokenSource; - readonly ConsoleAppOptions options; - readonly IServiceProviderIsService isService; - readonly IParamsValidator paramsValidator; - readonly bool isStrict; - - public ConsoleAppEngine(ILogger logger, - IServiceProvider provider, - ConsoleAppOptions options, - IServiceProviderIsService isService, - IParamsValidator paramsValidator, - CancellationTokenSource cancellationTokenSource) - { - this.logger = logger; - this.provider = provider; - this.paramsValidator = paramsValidator; - this.cancellationTokenSource = cancellationTokenSource; - this.options = options; - this.isService = isService; - this.isStrict = options.StrictOption; - } - - public async Task RunAsync() - { - logger.LogTrace("ConsoleAppEngine.Run Start"); - - var args = options.CommandLineArguments; - - if (!options.CommandDescriptors.TryGetDescriptor(args, out var commandDescriptor, out var offset)) - { - if (args.Length == 0) - { - if (options.CommandDescriptors.TryGetHelpMethod(out commandDescriptor)) - { - goto RUN; - } - } - - // TryGet Single help or Version - if (args.Length == 1) - { - switch (args[0].Trim('-')) - { - case "help": - if (options.CommandDescriptors.TryGetHelpMethod(out commandDescriptor)) - { - goto RUN; - } - break; - case "version": - if (options.CommandDescriptors.TryGetVersionMethod(out commandDescriptor)) - { - goto RUN; - } - break; - default: - break; - } - } - - // TryGet SubCommands Help - if (args.Length >= 2 && args[1].Trim('-') == "help") - { - var subCommands = options.CommandDescriptors.GetSubCommands(args[0]); - if (subCommands.Length != 0) - { - var msg = new CommandHelpBuilder(() => args[0], isService, options).BuildHelpMessage(null, subCommands, shortCommandName: true); - Console.WriteLine(msg); - return; - } - } - - await SetFailAsync("Command not found. args: " + string.Join(" ", args)); - return; - } - - // foo --help - // foo bar --help - if (args.Skip(offset).FirstOrDefault()?.Trim('-') == "help") - { - var msg = new CommandHelpBuilder(() => commandDescriptor.GetCommandName(options), isService, options).BuildHelpMessage(commandDescriptor); - Console.WriteLine(msg); - return; - } - - // check can invoke help - if (commandDescriptor.CommandType == CommandType.DefaultCommand && args.Length == 0) - { - var p = commandDescriptor.MethodInfo.GetParameters(); - if (p.Any(x => !(x.ParameterType == typeof(ConsoleAppContext) || isService.IsService(x.ParameterType) || x.HasDefaultValue))) - { - options.CommandDescriptors.TryGetHelpMethod(out commandDescriptor); - } - } - - RUN: - await RunCore(commandDescriptor!.MethodInfo!.DeclaringType!, commandDescriptor.MethodInfo, commandDescriptor.Instance, args, offset); - } - - // Try to invoke method. - async Task RunCore(Type type, MethodInfo methodInfo, object? instance, string?[] args, int argsOffset) - { - object?[] invokeArgs; - ParameterInfo[] originalParameters = methodInfo.GetParameters(); - var isService = provider.GetService(); - try - { - var parameters = originalParameters; - if (isService != null) - { - parameters = parameters.Where(x => !(x.ParameterType == typeof(ConsoleAppContext) || isService.IsService(x.ParameterType))).ToArray(); - } - - if (!TryGetInvokeArguments(parameters, args, argsOffset, out invokeArgs, out var errorMessage)) - { - await SetFailAsync(errorMessage + " args: " + string.Join(" ", args)); - return; - } - - } - catch (Exception ex) - { - await SetFailAsync("Fail to match method parameter on " + type.Name + "." + methodInfo.Name + ". args: " + string.Join(" ", args), ex); - return; - } - - var ctx = new ConsoleAppContext(args, DateTime.UtcNow, cancellationTokenSource, logger, methodInfo, provider); - - // re:create invokeArgs, merge with DI parameter. - if (invokeArgs.Length != originalParameters.Length) - { - var newInvokeArgs = new object?[originalParameters.Length]; - var invokeArgsIndex = 0; - for (int i = 0; i < originalParameters.Length; i++) - { - var p = originalParameters[i].ParameterType; - if (p == typeof(ConsoleAppContext)) - { - newInvokeArgs[i] = ctx; - } - else if (isService!.IsService(p)) - { - try - { - newInvokeArgs[i] = provider.GetService(p); - } - catch (Exception ex) - { - await SetFailAsync("Fail to get service parameter. ParameterType:" + p.FullName, ex); - return; - } - } - else - { - newInvokeArgs[i] = invokeArgs[invokeArgsIndex++]; - } - } - invokeArgs = newInvokeArgs; - } - - var validationResult = paramsValidator.ValidateParameters(originalParameters.Zip(invokeArgs)); - if (validationResult != ValidationResult.Success) - { - await SetFailAsync(validationResult!.ErrorMessage!); - return; - } - - try - { - if (instance == null && !type.IsAbstract && !methodInfo.IsStatic) - { - instance = ActivatorUtilities.CreateInstance(provider, type); - typeof(ConsoleAppBase).GetProperty(nameof(ConsoleAppBase.Context))!.SetValue(instance, ctx); - } - - } - catch (Exception ex) - { - await SetFailAsync("Fail to create ConsoleAppBase instance. Type:" + type.FullName, ex); - return; - } - - try - { - var invoker = new WithFilterInvoker(methodInfo, instance, invokeArgs, provider, options.GlobalFilters ?? Array.Empty(), ctx); - try - { - var result = await invoker.InvokeAsync(); - if (result != null) - { - Environment.ExitCode = result.Value; - } - } - finally - { - if (instance is IAsyncDisposable ad) - { - await ad.DisposeAsync(); - } - else if (instance is IDisposable d) - { - d.Dispose(); - } - } - } - catch (Exception ex) - { - if (ex is TargetInvocationException tex) - { - ex = tex.InnerException ?? tex; - } - - if (ex is OperationCanceledException operationCanceledException && operationCanceledException.CancellationToken == cancellationTokenSource.Token) - { - // NOTE: Do nothing if the exception has thrown by the CancellationToken of ConsoleAppEngine. - // If the user code throws OperationCanceledException, ConsoleAppEngine should not handle that. - return; - } - - await SetFailAsync("Fail in application running on " + type.Name + "." + methodInfo.Name, ex); - return; - } - - logger.LogTrace("ConsoleAppEngine.Run Complete Successfully"); - } - - ValueTask SetFailAsync(string message) - { - Environment.ExitCode = 1; - logger.LogError(message); - return default; - } - - ValueTask SetFailAsync(string message, Exception? ex) - { - Environment.ExitCode = 1; - logger.LogError(ex, message); - return default; - } - - bool TryGetInvokeArguments(ParameterInfo[] parameters, string?[] args, int argsOffset, out object?[] invokeArgs, out string? errorMessage) - { - try - { - var jsonOption = options.JsonSerializerOptions; - - // Collect option types for parsing command-line arguments. - var optionTypeByOptionName = new Dictionary(StringComparer.OrdinalIgnoreCase); - for (int i = 0; i < parameters.Length; i++) - { - var item = parameters[i]; - var option = item.GetCustomAttribute(); - - optionTypeByOptionName[(isStrict ? "--" : "") + options.NameConverter(item.Name!)] = item.ParameterType; - if (!string.IsNullOrWhiteSpace(option?.ShortName)) - { - optionTypeByOptionName[(isStrict ? "-" : "") + option!.ShortName!] = item.ParameterType; - } - } - - var (argumentDictionary, optionByIndex) = ParseArgument(args, argsOffset, optionTypeByOptionName, isStrict); - invokeArgs = new object[parameters.Length]; - - for (int i = 0; i < parameters.Length; i++) - { - var item = parameters[i]; - var itemName = options.NameConverter(item.Name!); - var option = item.GetCustomAttribute(); - if (!string.IsNullOrWhiteSpace(option?.ShortName) && char.IsDigit(option!.ShortName, 0)) throw new InvalidOperationException($"Option '{itemName}' has a short name, but the short name must start with A-Z or a-z."); - - var value = default(OptionParameter); - - // Indexed arguments (e.g. [Option(0)]) - if (option != null && option.Index != -1) - { - if (optionByIndex.Count <= option.Index) - { - if (!item.HasDefaultValue) - { - throw new InvalidOperationException($"Required argument {option.Index} was not found in specified arguments."); - } - } - else - { - value = optionByIndex[option.Index]; - } - } - - // Keyed options (e.g. -foo -bar ) - var longName = (isStrict) ? ("--" + itemName) : itemName; - var shortName = (isStrict) ? ("-" + option?.ShortName?.TrimStart('-')) : option?.ShortName?.TrimStart('-'); - - if (value.Value != null || argumentDictionary.TryGetValue(longName!, out value) || argumentDictionary.TryGetValue(shortName ?? "", out value)) - { - if (parameters[i].ParameterType == typeof(bool) && value.Value == null) - { - invokeArgs[i] = value.BooleanSwitch; - continue; - } - - if (value.Value != null) - { - if (parameters[i].ParameterType == typeof(string)) - { - // when string, invoke directly(avoid JSON escape) - invokeArgs[i] = value.Value; - continue; - } - else if (parameters[i].ParameterType.IsEnum) - { - try - { - invokeArgs[i] = Enum.Parse(parameters[i].ParameterType, value.Value, true); - continue; - } - catch - { - errorMessage = "Parameter \"" + itemName + "\"" + " fail on Enum parsing."; - return false; - } - } - else if (typeof(System.Collections.IEnumerable).IsAssignableFrom(parameters[i].ParameterType) && !typeof(System.Collections.IDictionary).IsAssignableFrom(parameters[i].ParameterType)) - { - var v = value.Value; - if (!(v.StartsWith("[") && v.EndsWith("]"))) - { - var elemType = UnwrapCollectionElementType(parameters[i].ParameterType); - if (elemType == typeof(string)) - { - if (!(v.StartsWith("\"") && v.EndsWith("\""))) - { - v = "[" + string.Join(",", v.Split(' ', ',').Select(x => "\"" + x + "\"")) + "]"; - } - else - { - v = "[" + v + "]"; - } - } - else - { - v = "[" + string.Join(",", v.Trim('\'', '\"').Split(' ', ',')) + "]"; - } - } - try - { - invokeArgs[i] = JsonSerializer.Deserialize(v, parameters[i].ParameterType, jsonOption); - continue; - } - catch - { - errorMessage = "Parameter \"" + itemName + "\"" + " fail on JSON deserialize, please check type or JSON escape or add double-quotation."; - return false; - } - } - else - { - var v = value.Value; - try - { - try - { - invokeArgs[i] = JsonSerializer.Deserialize(v, parameters[i].ParameterType, jsonOption); - continue; - } - catch (JsonException) - { - // retry with double quotations - if (!(v.StartsWith("\"") && v.EndsWith("\""))) - { - v = $"\"{v}\""; - } - invokeArgs[i] = JsonSerializer.Deserialize(v, parameters[i].ParameterType, jsonOption); - continue; - } - } - catch - { - errorMessage = "Parameter \"" + itemName + "\"" + " fail on JSON deserialize, please check type or JSON escape or add double-quotation."; - return false; - } - } - } - } - - if (item.HasDefaultValue) - { - invokeArgs[i] = item.DefaultValue; - } - else if (item.ParameterType == typeof(bool)) - { - // bool without default value should be considered that it has implicit default value of false. - invokeArgs[i] = false; - } - else - { - var name = itemName; - if (option?.ShortName != null) - { - name = itemName + "(" + "-" + option.ShortName + ")"; - } - errorMessage = "Required parameter \"" + name + "\"" + " not found in argument."; - return false; - } - } - - errorMessage = null; - return true; - } - catch (Exception ex) - { - invokeArgs = default!; - errorMessage = ex.Message; - return false; - } - } - - static Type? UnwrapCollectionElementType(Type collectionType) - { - if (collectionType.IsArray) - { - return collectionType.GetElementType(); - } - - foreach (var i in collectionType.GetInterfaces()) - { - if (i.IsGenericType && (i.GetGenericTypeDefinition() == typeof(IEnumerable<>))) - { - return i.GetGenericArguments()[0]; - } - } - - return null; - } - - static (ReadOnlyDictionary OptionByKey, IReadOnlyList OptionByIndex) ParseArgument(string?[] args, int argsOffset, IReadOnlyDictionary optionTypeByName, bool isStrict) - { - var dict = new Dictionary(args.Length, StringComparer.OrdinalIgnoreCase); - var options = new List(); - for (int i = argsOffset; i < args.Length;) - { - var arg = args[i++]; - if (arg is null || !arg.StartsWith("-")) - { - options.Add(new OptionParameter() { Value = arg }); - continue; // not key - } - - var key = (isStrict) ? arg : arg.TrimStart('-'); - - if (optionTypeByName.TryGetValue(key, out var optionType)) - { - if (optionType == typeof(bool)) - { - var boolValue = true; - if (i < args.Length) - { - var isTrue = args[i]?.Equals("true", StringComparison.OrdinalIgnoreCase); - var isFalse = args[i]?.Equals("false", StringComparison.OrdinalIgnoreCase); - if (isTrue != null && isTrue.Value) - { - boolValue = true; - } - else if (isFalse != null && isFalse.Value) - { - boolValue = false; - } - } - - dict.Add(key, new OptionParameter { BooleanSwitch = boolValue }); - } - else - { - if (args.Length <= i) - { - throw new ArgumentException($@"Value for parameter ""{key}"" is not provided."); - } - - var value = args[i]; - dict.Add(key, new OptionParameter { Value = value }); - i++; - } - } - else - { - // not key - options.Add(new OptionParameter() { Value = arg }); - } - } - - return (new ReadOnlyDictionary(dict), options); - } - - struct OptionParameter - { - public string? Value; - public bool BooleanSwitch; - } - } -} \ No newline at end of file diff --git a/src/ConsoleAppFramework/ConsoleAppEngineService.cs b/src/ConsoleAppFramework/ConsoleAppEngineService.cs deleted file mode 100644 index fdf14b0..0000000 --- a/src/ConsoleAppFramework/ConsoleAppEngineService.cs +++ /dev/null @@ -1,132 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace ConsoleAppFramework -{ - // This servcie is called from ConsoleApp.Run - internal sealed class ConsoleAppEngineService : IHostedService - { - IHostApplicationLifetime appLifetime; - ILogger logger; - IServiceProvider provider; - IServiceScope? scope; - Task? runningTask; - CancellationTokenSource? cancellationTokenSource; - - public ConsoleAppEngineService(IHostApplicationLifetime appLifetime, ILogger logger, IServiceProvider provider, IOptionsMonitor hostOptions) - { - this.appLifetime = appLifetime; - this.provider = provider; - this.logger = logger; - } - - public Task StartAsync(CancellationToken ct) - { - cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(ct); - - // raise after all event registered - appLifetime.ApplicationStarted.Register(async state => - { - var self = (ConsoleAppEngineService)state!; - try - { - self.scope = self.provider.CreateScope(); - if (self.cancellationTokenSource == null) self.cancellationTokenSource = new CancellationTokenSource(); - var engine = ActivatorUtilities.CreateInstance(self.scope.ServiceProvider, self.cancellationTokenSource!); - self.runningTask = engine.RunAsync(); - await self.runningTask; - self.runningTask = null; - } - catch - { - // don't do anything. - } - finally - { - self.appLifetime.StopApplication(); - } - }, this); - - // call from Ctrl+C, etc... - appLifetime.ApplicationStopping.Register(state => - { - var cts = (CancellationTokenSource?)state; - cts?.Cancel(); - }, cancellationTokenSource); - - return Task.CompletedTask; - } - - public async Task StopAsync(CancellationToken ct) - { - try - { - cancellationTokenSource?.Cancel(); - - var task = runningTask; - if (task != null) - { - logger.LogTrace("Detect Cancel signal, wait for running console app task canceled."); - try - { - if (ct.CanBeCanceled) - { - var cancelTask = CreateTimeoutTask(ct); - var completedTask = await Task.WhenAny(cancelTask, task); - if (completedTask == cancelTask) - { - logger.LogTrace("ConsoleApp aborted, cancel timeout."); - } - else - { - logger.LogTrace("ConsoleApp cancel completed."); - } - } - else - { - await task; - logger.LogTrace("ConsoleApp cancel completed."); - } - } - catch (OperationCanceledException ex) - { - if (ex.CancellationToken == ct) - { - logger.LogTrace("ConsoleApp aborted, cancel timeout."); - } - else - { - logger.LogTrace("ConsoleApp cancel completed."); - } - } - } - } - finally - { - if (scope is IAsyncDisposable asyncDisposable) - { - await asyncDisposable.DisposeAsync(); - } - else - { - scope?.Dispose(); - } - } - } - - Task CreateTimeoutTask(CancellationToken ct) - { - var tcs = new TaskCompletionSource(); - ct.Register(() => - { - tcs.TrySetCanceled(ct); - }); - return tcs.Task; - } - } -} \ No newline at end of file diff --git a/src/ConsoleAppFramework/ConsoleAppFilter.cs b/src/ConsoleAppFramework/ConsoleAppFilter.cs deleted file mode 100644 index 95c0d9a..0000000 --- a/src/ConsoleAppFramework/ConsoleAppFilter.cs +++ /dev/null @@ -1,122 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Threading.Tasks; - -namespace ConsoleAppFramework -{ - public abstract class ConsoleAppFilter - { - public int Order { get; set; } - public abstract ValueTask Invoke(ConsoleAppContext context, Func next); - } - - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] - public class ConsoleAppFilterAttribute : Attribute - { - public Type Type { get; } - public int Order { get; set; } - - public ConsoleAppFilterAttribute(Type type) - { - this.Type = type; - } - } - - internal class FilterRunner - { - readonly ConsoleAppFilter filter; - readonly Func next; - - public FilterRunner(ConsoleAppFilter filter, Func next) - { - this.filter = filter; - this.next = next; - } - - public Func GetDelegate() => InvokeAsync; - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - ValueTask InvokeAsync(ConsoleAppContext context) - { - return filter.Invoke(context, next); - } - } - - internal class WithFilterInvoker - { - readonly MethodInfo methodInfo; - readonly object? instance; - readonly object?[] invokeArgs; - readonly IServiceProvider serviceProvider; - readonly ConsoleAppFilter[] globalFilters; - readonly ConsoleAppContext context; - - int? invokeResult; - - public WithFilterInvoker(MethodInfo methodInfo, object? instance, object?[] invokeArgs, IServiceProvider serviceProvider, ConsoleAppFilter[] globalFilters, ConsoleAppContext context) - { - this.methodInfo = methodInfo; - this.instance = instance; - this.invokeArgs = invokeArgs; - this.serviceProvider = serviceProvider; - this.globalFilters = globalFilters; - this.context = context; - } - - public async ValueTask InvokeAsync() - { - var list = new List(globalFilters); - - var classFilters = methodInfo.DeclaringType!.GetCustomAttributes(true); - var methodFilters = methodInfo.GetCustomAttributes(true); - foreach (var item in classFilters.Concat(methodFilters)) - { - var filter = (ConsoleAppFilter) ActivatorUtilities.CreateInstance(serviceProvider, item.Type); - filter.Order = item.Order; - list.Add(filter); - } - - var sortedAndReversedFilters = list.OrderBy(x => x.Order).Reverse().ToArray(); - - Func next = RunCore; - foreach (var f in sortedAndReversedFilters) - { - next = new FilterRunner(f, next).GetDelegate(); - } - - await next(context); - return invokeResult; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - async ValueTask RunCore(ConsoleAppContext _) - { - var result = methodInfo.Invoke(instance, invokeArgs); - if (result != null) - { - switch (result) - { - case int exitCode: - invokeResult = exitCode; - break; - case Task taskWithExitCode: - invokeResult = await taskWithExitCode; - break; - case Task task: - await task; - break; - case ValueTask valueTaskWithExitCode: - invokeResult = await valueTaskWithExitCode; - break; - case ValueTask valueTask: - await valueTask; - break; - } - } - } - } -} \ No newline at end of file diff --git a/src/ConsoleAppFramework/ConsoleAppFramework.csproj b/src/ConsoleAppFramework/ConsoleAppFramework.csproj index 33046f7..f5d50dc 100644 --- a/src/ConsoleAppFramework/ConsoleAppFramework.csproj +++ b/src/ConsoleAppFramework/ConsoleAppFramework.csproj @@ -1,33 +1,40 @@  + - netcoreapp3.1;net5.0;net6.0 - 8.0 + netstandard2.0 + 12 + enable enable - true - release.snk - true + ConsoleAppFramework + true + cs + + + false + true + false + true ConsoleAppFramework - Micro-framework for console applications. - true - - - - 1701;1702;1591 + Zero Dependency, Zero Overhead, Zero Reflection, Zero Allocation, AOT Safe CLI Framework powered by C# Source Generator. - - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + - - + + - + + diff --git a/src/ConsoleAppFramework/ConsoleAppFramework.props b/src/ConsoleAppFramework/ConsoleAppFramework.props deleted file mode 100644 index 6fb1fc5..0000000 --- a/src/ConsoleAppFramework/ConsoleAppFramework.props +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/src/ConsoleAppFramework/ConsoleAppGenerator.cs b/src/ConsoleAppFramework/ConsoleAppGenerator.cs new file mode 100644 index 0000000..3c12336 --- /dev/null +++ b/src/ConsoleAppFramework/ConsoleAppGenerator.cs @@ -0,0 +1,717 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Immutable; +using System.ComponentModel.Design; +using System.Reflection; +using System.Xml.Linq; +using static ConsoleAppFramework.Emitter; + +namespace ConsoleAppFramework; + +[Generator(LanguageNames.CSharp)] +public partial class ConsoleAppGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterPostInitializationOutput(EmitConsoleAppTemplateSource); + + // ConsoleApp.Run + var runSource = context.SyntaxProvider.CreateSyntaxProvider((node, ct) => + { + if (node.IsKind(SyntaxKind.InvocationExpression)) + { + var invocationExpression = (node as InvocationExpressionSyntax); + if (invocationExpression == null) return false; + + var expr = invocationExpression.Expression as MemberAccessExpressionSyntax; + if ((expr?.Expression as IdentifierNameSyntax)?.Identifier.Text == "ConsoleApp") + { + var methodName = expr?.Name.Identifier.Text; + if (methodName is "Run" or "RunAsync") + { + return true; + } + } + + return false; + } + + return false; + }, (context, ct) => ((InvocationExpressionSyntax)context.Node, context.SemanticModel)); + + context.RegisterSourceOutput(runSource, EmitConsoleAppRun); + + // ConsoleAppBuilder + var builderSource = context.SyntaxProvider + .CreateSyntaxProvider((node, ct) => + { + if (node.IsKind(SyntaxKind.InvocationExpression)) + { + var invocationExpression = (node as InvocationExpressionSyntax); + if (invocationExpression == null) return false; + + var expr = invocationExpression.Expression as MemberAccessExpressionSyntax; + var methodName = expr?.Name.Identifier.Text; + if (methodName is "Add" or "UseFilter" or "Run" or "RunAsync") + { + return true; + } + + return false; + } + + return false; + }, (context, ct) => ( + (InvocationExpressionSyntax)context.Node, + ((context.Node as InvocationExpressionSyntax)!.Expression as MemberAccessExpressionSyntax)!.Name.Identifier.Text, + context.SemanticModel)) + .Where(x => + { + var model = x.SemanticModel.GetTypeInfo((x.Item1.Expression as MemberAccessExpressionSyntax)!.Expression); + return model.Type?.Name == "ConsoleAppBuilder"; + }); + + context.RegisterSourceOutput(builderSource.Collect(), EmitConsoleAppBuilder); + } + + public const string ConsoleAppBaseCode = """ +// +#nullable enable +namespace ConsoleAppFramework; + +using System; +using System.Text; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using System.Runtime.InteropServices; +using System.Runtime.CompilerServices; +using System.Diagnostics.CodeAnalysis; +using System.ComponentModel.DataAnnotations; + +internal interface IArgumentParser +{ + static abstract bool TryParse(ReadOnlySpan s, out T result); +} + +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)] +internal sealed class FromServicesAttribute : Attribute +{ +} + +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)] +internal sealed class ArgumentAttribute : Attribute +{ +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +internal sealed class CommandAttribute : Attribute +{ + public string Command { get; } + + public CommandAttribute(string command) + { + this.Command = command; + } +} + +internal record class ConsoleAppContext(string CommandName, string[] Arguments, object? State); + +internal abstract class ConsoleAppFilter(ConsoleAppFilter next) +{ + protected readonly ConsoleAppFilter Next = next; + + public abstract Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken); +} + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = false)] +internal sealed class ConsoleAppFilterAttribute : Attribute + where T : ConsoleAppFilter +{ +} + +internal static partial class ConsoleApp +{ + public static IServiceProvider? ServiceProvider { get; set; } + public static TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(5); + + static Action? logAction; + public static Action Log + { + get => logAction ??= Console.WriteLine; + set => logAction = value; + } + + static Action? logErrorAction; + public static Action LogError + { + get => logErrorAction ??= (static msg => Log(msg)); + set => logErrorAction = value; + } + + /// + /// You can pass second argument that generates new Run overload. + /// ConsoleApp.Run(args, (int x, int y) => { });
+ /// ConsoleApp.Run(args, Foo);
+ /// ConsoleApp.Run(args, &Foo);
+ ///
+ public static void Run(string[] args) + { + } + + /// + /// You can pass second argument that generates new RunAsync overload. + /// ConsoleApp.RunAsync(args, (int x, int y) => { });
+ /// ConsoleApp.RunAsync(args, Foo);
+ /// ConsoleApp.RunAsync(args, &Foo);
+ ///
+ public static Task RunAsync(string[] args) + { + return Task.CompletedTask; + } + + public static ConsoleAppBuilder Create() => new ConsoleAppBuilder(); + + static void ThrowArgumentParseFailed(string argumentName, string value) + { + throw new ArgumentException($"Argument '{argumentName}' parse failed. value: {value}"); + } + + static void ThrowRequiredArgumentNotParsed(string name) + { + throw new ArgumentException($"Require argument '{name}' does not parsed."); + } + + static void ThrowArgumentNameNotFound(string argumentName) + { + throw new ArgumentException($"Argument '{argumentName}' does not found in command prameters."); + } + + static bool TryParseParamsArray(ReadOnlySpan args, ref T[] result, ref int i) + where T : IParsable + { + result = new T[args.Length - i]; + var resultIndex = 0; + for (; i < args.Length; i++) + { + if (!T.TryParse(args[i], null, out result[resultIndex++]!)) return false; + } + return true; + } + + static bool TrySplitParse(ReadOnlySpan s, out T[] result) + where T : ISpanParsable + { + if (s.StartsWith("[")) + { + try + { + result = System.Text.Json.JsonSerializer.Deserialize(s)!; + return true; + } + catch + { + result = default!; + return false; + } + } + + var count = s.Count(',') + 1; + result = new T[count]; + + var source = s; + var destination = result.AsSpan(); + Span ranges = stackalloc Range[Math.Min(count, 128)]; + + while (true) + { + var splitCount = source.Split(ranges, ','); + var parseTo = splitCount; + if (splitCount == 128 && source[ranges[^1]].Contains(',')) + { + parseTo = splitCount - 1; + } + + for (int i = 0; i < parseTo; i++) + { + if (!T.TryParse(source[ranges[i]], null, out destination[i]!)) + { + return false; + } + } + destination = destination.Slice(parseTo); + + if (destination.Length != 0) + { + source = source[ranges[^1]]; + continue; + } + else + { + break; + } + } + + return true; + } + + static void ValidateParameter(object? value, ParameterInfo parameter, ValidationContext validationContext, ref StringBuilder? errorMessages) + { + validationContext.DisplayName = parameter.Name ?? ""; + validationContext.Items.Clear(); + + foreach (var validator in parameter.GetCustomAttributes(false)) + { + var result = validator.GetValidationResult(value, validationContext); + if (result != null) + { + if (errorMessages == null) + { + errorMessages = new StringBuilder(); + } + errorMessages.AppendLine(result.ErrorMessage); + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static bool TryShowHelpOrVersion(ReadOnlySpan args, int requiredParameterCount, int helpId) + { + if (args.Length == 0) + { + if (requiredParameterCount == 0) return false; + + ShowHelp(helpId); + return true; + } + + if (args.Length == 1) + { + switch (args[0]) + { + case "--version": + ShowVersion(); + return true; + case "-h": + case "--help": + ShowHelp(helpId); + return true; + default: + break; + } + } + + return false; + } + + static void ShowVersion() + { + var asm = Assembly.GetEntryAssembly(); + var version = "1.0.0"; + var infoVersion = asm!.GetCustomAttribute(); + if (infoVersion != null) + { + version = infoVersion.InformationalVersion; + } + else + { + var asmVersion = asm!.GetCustomAttribute(); + if (asmVersion != null) + { + version = asmVersion.Version; + } + } + Log(version); + } + + static partial void ShowHelp(int helpId); + + static async Task RunWithFilterAsync(string commandName, string[] args, ConsoleAppFilter invoker) + { + using var posixSignalHandler = PosixSignalHandler.Register(Timeout); + try + { + await Task.Run(() => invoker.InvokeAsync(new ConsoleAppContext(commandName, args, null), posixSignalHandler.Token)).WaitAsync(posixSignalHandler.TimeoutToken); + } + catch (Exception ex) + { + if (ex is OperationCanceledException) + { + Environment.ExitCode = 130; + return; + } + + Environment.ExitCode = 1; + if (ex is ValidationException) + { + LogError(ex.Message); + } + else + { + LogError(ex.ToString()); + } + } + } + + sealed class PosixSignalHandler : IDisposable + { + public CancellationToken Token => cancellationTokenSource.Token; + public CancellationToken TimeoutToken => timeoutCancellationTokenSource.Token; + + CancellationTokenSource cancellationTokenSource; + CancellationTokenSource timeoutCancellationTokenSource; + TimeSpan timeout; + + PosixSignalRegistration? sigInt; + PosixSignalRegistration? sigQuit; + PosixSignalRegistration? sigTerm; + + PosixSignalHandler(TimeSpan timeout) + { + this.cancellationTokenSource = new CancellationTokenSource(); + this.timeoutCancellationTokenSource = new CancellationTokenSource(); + this.timeout = timeout; + } + + public static PosixSignalHandler Register(TimeSpan timeout) + { + var handler = new PosixSignalHandler(timeout); + + Action handleSignal = handler.HandlePosixSignal; + + handler.sigInt = PosixSignalRegistration.Create(PosixSignal.SIGINT, handleSignal); + handler.sigQuit = PosixSignalRegistration.Create(PosixSignal.SIGQUIT, handleSignal); + handler.sigTerm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, handleSignal); + + return handler; + } + + void HandlePosixSignal(PosixSignalContext context) + { + context.Cancel = true; + cancellationTokenSource.Cancel(); + timeoutCancellationTokenSource.CancelAfter(timeout); + } + + public void Dispose() + { + sigInt?.Dispose(); + sigQuit?.Dispose(); + sigTerm?.Dispose(); + timeoutCancellationTokenSource.Dispose(); + } + } + + struct SyncAsyncDisposeWrapper(T value) : IDisposable + where T : IAsyncDisposable + { + public readonly T Value => value; + + public void Dispose() + { + value.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + } + + internal partial struct ConsoleAppBuilder + { + public ConsoleAppBuilder() + { + } + + public void Add(string commandName, Delegate command) + { + AddCore(commandName, command); + } + + [System.Diagnostics.Conditional("DEBUG")] + public void Add() { } + + [System.Diagnostics.Conditional("DEBUG")] + public void Add(string commandPath) { } + + [System.Diagnostics.Conditional("DEBUG")] + public void UseFilter() where T : ConsoleAppFilter { } + + public void Run(string[] args) + { + RunCore(args); + } + + public Task RunAsync(string[] args) + { + Task? task = null; + RunAsyncCore(args, ref task!); + return task ?? Task.CompletedTask; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + partial void AddCore(string commandName, Delegate command); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + partial void RunCore(string[] args); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + partial void RunAsyncCore(string[] args, ref Task result); + + static partial void ShowHelp(int helpId); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static bool TryShowHelpOrVersion(ReadOnlySpan args, int requiredParameterCount, int helpId) + { + if (args.Length == 0) + { + if (requiredParameterCount == 0) return false; + + ShowHelp(helpId); + return true; + } + + if (args.Length == 1) + { + switch (args[0]) + { + case "--version": + ShowVersion(); + return true; + case "-h": + case "--help": + ShowHelp(helpId); + return true; + default: + break; + } + } + + return false; + } + } +} +"""; + + static void EmitConsoleAppTemplateSource(IncrementalGeneratorPostInitializationContext context) + { + context.AddSource("ConsoleApp.cs", ConsoleAppBaseCode); + } + + const string GeneratedCodeHeader = """ +// +#nullable enable +#pragma warning disable CS0108 // hides inherited member +#pragma warning disable CS0162 // Unreachable code +#pragma warning disable CS0164 // This label has not been referenced +#pragma warning disable CS0219 // Variable assigned but never used +#pragma warning disable CS8600 // Converting null literal or possible null value to non-nullable type. +#pragma warning disable CS8601 // Possible null reference assignment +#pragma warning disable CS8602 +#pragma warning disable CS8604 // Possible null reference argument for parameter +#pragma warning disable CS8619 +#pragma warning disable CS8620 +#pragma warning disable CS8631 // The type cannot be used as type parameter in the generic type or method +#pragma warning disable CS8765 // Nullability of type of parameter +#pragma warning disable CS9074 // The 'scoped' modifier of parameter doesn't match overridden or implemented member +#pragma warning disable CA1050 // Declare types in namespaces. +#pragma warning disable CS1998 + +namespace ConsoleAppFramework; + +using System; +using System.Text; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using System.Runtime.InteropServices; +using System.Runtime.CompilerServices; +using System.Diagnostics.CodeAnalysis; +using System.ComponentModel.DataAnnotations; + +"""; + + static void EmitConsoleAppRun(SourceProductionContext sourceProductionContext, (InvocationExpressionSyntax, SemanticModel) generatorSyntaxContext) + { + var node = generatorSyntaxContext.Item1; + var model = generatorSyntaxContext.Item2; + + var wellKnownTypes = new WellKnownTypes(model.Compilation); + + var parser = new Parser(sourceProductionContext, node, model, wellKnownTypes, DelegateBuildType.MakeDelegateWhenHasDefaultValue, []); + var command = parser.ParseAndValidate(); + if (command == null) + { + return; + } + if (command.HasFilter) + { + sourceProductionContext.ReportDiagnostic(DiagnosticDescriptors.CommandHasFilter, node.GetLocation()); + return; + } + + var isRunAsync = ((node.Expression as MemberAccessExpressionSyntax)?.Name.Identifier.Text == "RunAsync"); + + var sb = new SourceBuilder(0); + sb.AppendLine(GeneratedCodeHeader); + using (sb.BeginBlock("internal static partial class ConsoleApp")) + { + var emitter = new Emitter(wellKnownTypes); + var withId = new Emitter.CommandWithId(null, command, -1); + emitter.EmitRun(sb, withId, isRunAsync); + } + sourceProductionContext.AddSource("ConsoleApp.Run.g.cs", sb.ToString()); + + var help = new SourceBuilder(0); + help.AppendLine(GeneratedCodeHeader); + using (help.BeginBlock("internal static partial class ConsoleApp")) + { + var emitter = new Emitter(wellKnownTypes); + emitter.EmitHelp(help, command); + } + sourceProductionContext.AddSource("ConsoleApp.Run.Help.g.cs", help.ToString()); + } + + static void EmitConsoleAppBuilder(SourceProductionContext sourceProductionContext, ImmutableArray<(InvocationExpressionSyntax Node, string Name, SemanticModel Model)> generatorSyntaxContexts) + { + if (generatorSyntaxContexts.Length == 0) return; + + var model = generatorSyntaxContexts[0].Model; + + var wellKnownTypes = new WellKnownTypes(model.Compilation); + + // validation, invoke in loop is not allowed. + foreach (var item in generatorSyntaxContexts) + { + if (item.Name is "Run" or "RunAsync") continue; + foreach (var n in item.Node.Ancestors()) + { + if (n.Kind() is SyntaxKind.WhileStatement or SyntaxKind.DoStatement or SyntaxKind.ForStatement or SyntaxKind.ForEachStatement) + { + sourceProductionContext.ReportDiagnostic(DiagnosticDescriptors.AddInLoopIsNotAllowed, item.Node.GetLocation()); + return; + } + } + } + + var methodGroup = generatorSyntaxContexts.ToLookup(x => + { + if (x.Name == "Add" && ((x.Node.Expression as MemberAccessExpressionSyntax)?.Name.IsKind(SyntaxKind.GenericName) ?? false)) + { + return "Add"; + } + + return x.Name; + }); + + var globalFilters = methodGroup["UseFilter"] + .OrderBy(x => x.Node.GetLocation().SourceSpan) // sort by line number + .Select(x => + { + var genericName = (x.Node.Expression as MemberAccessExpressionSyntax)?.Name as GenericNameSyntax; + var genericType = genericName!.TypeArgumentList.Arguments[0]; + var type = model.GetTypeInfo(genericType).Type; + if (type == null) return null!; + + var filter = FilterInfo.Create(type); + + if (filter == null) + { + sourceProductionContext.ReportDiagnostic(DiagnosticDescriptors.FilterMultipleConsturtor, genericType.GetLocation()); + return null!; + } + + return filter!; + }) + .ToArray(); + + // don't emit if exists failure(already reported error) + if (globalFilters.Any(x => x == null)) + { + return; + } + + var names = new HashSet(); + var commands1 = methodGroup["Add"] + .Select(x => + { + var parser = new Parser(sourceProductionContext, x.Node, x.Model, wellKnownTypes, DelegateBuildType.OnlyActionFunc, globalFilters); + var command = parser.ParseAndValidateForBuilderDelegateRegistration(); + + // validation command name duplicate + if (command != null && !names.Add(command.Name)) + { + sourceProductionContext.ReportDiagnostic(DiagnosticDescriptors.DuplicateCommandName, x.Node.ArgumentList.Arguments[0].GetLocation(), command!.Name); + return null; + } + + return command; + }) + .ToArray(); // evaluate first. + + var commands2 = methodGroup["Add"] + .SelectMany(x => + { + var parser = new Parser(sourceProductionContext, x.Node, x.Model, wellKnownTypes, DelegateBuildType.None, globalFilters); + var commands = parser.ParseAndValidateForBuilderClassRegistration(); + + // validation command name duplicate + foreach (var command in commands) + { + if (command != null && !names.Add(command.Name)) + { + sourceProductionContext.ReportDiagnostic(DiagnosticDescriptors.DuplicateCommandName, x.Node.GetLocation(), command!.Name); + return [null]; + } + } + + return commands; + }); + + var commands = commands1.Concat(commands2).ToArray(); + + // don't emit if exists failure(already reported error) + if (commands.Any(x => x == null)) + { + return; + } + + if (commands.Length == 0) return; + + var hasRun = methodGroup["Run"].Any(); + var hasRunAsync = methodGroup["RunAsync"].Any(); + + if (!hasRun && !hasRunAsync) return; + + var sb = new SourceBuilder(0); + sb.AppendLine(GeneratedCodeHeader); + + // with id number + var commandIds = commands + .Select((x, i) => + { + return new CommandWithId( + FieldType: x!.BuildDelegateSignature(out _), // for builder, always generate Action/Func so ok to ignore out var. + Command: x!, + Id: i + ); + }) + .ToArray(); + + using (sb.BeginBlock("internal static partial class ConsoleApp")) + { + var emitter = new Emitter(wellKnownTypes); + emitter.EmitBuilder(sb, commandIds, hasRun, hasRunAsync); + } + sourceProductionContext.AddSource("ConsoleApp.Builder.g.cs", sb.ToString()); + + // Build Help + + var help = new SourceBuilder(0); + help.AppendLine(GeneratedCodeHeader); + using (help.BeginBlock("internal static partial class ConsoleApp")) + using (help.BeginBlock("internal partial struct ConsoleAppBuilder")) + { + var emitter = new Emitter(wellKnownTypes); + emitter.EmitHelp(help, commandIds!); + } + sourceProductionContext.AddSource("ConsoleApp.Builder.Help.g.cs", help.ToString()); + } +} \ No newline at end of file diff --git a/src/ConsoleAppFramework/ConsoleAppOptions.cs b/src/ConsoleAppFramework/ConsoleAppOptions.cs deleted file mode 100644 index 7d8999a..0000000 --- a/src/ConsoleAppFramework/ConsoleAppOptions.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System; -using System.Text; -using System.Text.Json; - -namespace ConsoleAppFramework -{ - public class ConsoleAppOptions - { - /// - /// Argument parser uses strict(-short, --long) option. Default is true. - /// - public bool StrictOption { get; set; } = true; - - /// - /// Show default command(help/version) to help. Default is true. - /// - public bool ShowDefaultCommand { get; set; } = true; - public bool ReplaceToUseSimpleConsoleLogger { get; set; } = true; - public JsonSerializerOptions? JsonSerializerOptions { get; set; } - - public ConsoleAppFilter[]? GlobalFilters { get; set; } - - public bool NoAttributeCommandAsImplicitlyDefault { get; set; } - - public Func NameConverter { get; set; } = KebabCaseConvert; - - public bool HelpSortCommandsByFullName { get; set; } = false; - - public string? ApplicationName { get; set; } = null; - - // internal store values for execute engine. - - internal string[] CommandLineArguments { get; set; } = default!; - internal CommandDescriptorCollection CommandDescriptors { get; } - - public ConsoleAppOptions() - { - CommandDescriptors = new CommandDescriptorCollection(this); - } - - public static ConsoleAppOptions CreateLegacyCompatible() - { - return new ConsoleAppOptions() - { - StrictOption = false, - NoAttributeCommandAsImplicitlyDefault = true, - NameConverter = x => x.ToLower(), - ReplaceToUseSimpleConsoleLogger = false - }; - } - - static string KebabCaseConvert(string name) - { - var sb = new StringBuilder(); - for (int i = 0; i < name.Length; i++) - { - if (!Char.IsUpper(name[i])) - { - sb.Append(name[i]); - continue; - } - - // Abc, abC, AB-c => first or Last or capital continuous, no added. - if (i == 0 || i == name.Length - 1 || Char.IsUpper(name[i + 1])) - { - sb.Append(Char.ToLowerInvariant(name[i])); - continue; - } - - // others, add- - sb.Append('-'); - sb.Append(Char.ToLowerInvariant(name[i])); - } - return sb.ToString(); - } - } -} \ No newline at end of file diff --git a/src/ConsoleAppFramework/DefaultCommands.cs b/src/ConsoleAppFramework/DefaultCommands.cs deleted file mode 100644 index 0f5f5e0..0000000 --- a/src/ConsoleAppFramework/DefaultCommands.cs +++ /dev/null @@ -1,60 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Text; - -namespace ConsoleAppFramework -{ - internal class DefaultCommands : ConsoleAppBase - { - public const string Help = "help"; - public const string Version = "version"; - - public static readonly CommandDescriptor HelpCommand = new CommandDescriptor(CommandType.Command, typeof(DefaultCommands).GetMethod(nameof(ShowHelp), BindingFlags.Public | BindingFlags.Instance)!); - public static readonly CommandDescriptor VersionCommand = new CommandDescriptor(CommandType.Command, typeof(DefaultCommands).GetMethod(nameof(ShowVersion), BindingFlags.Public | BindingFlags.Instance)!); - - readonly ConsoleAppOptions options; - readonly IServiceProviderIsService isService; - - public DefaultCommands(ConsoleAppOptions options, IServiceProviderIsService isService) - { - this.options = options; - this.isService = isService; - } - - [Command("help", "Display help.")] - public void ShowHelp() - { - var descriptors = options.CommandDescriptors.GetAllDescriptors(); - if (!options.ShowDefaultCommand) - { - descriptors = descriptors.Where(x => x != HelpCommand && x != VersionCommand); - } - var message = new CommandHelpBuilder(null,isService, options).BuildHelpMessage(options.CommandDescriptors.GetRootCommandDescriptor(), descriptors, shortCommandName: false); - Console.WriteLine(message); - } - - [Command("version", "Display version.")] - public void ShowVersion() - { - var asm = Assembly.GetEntryAssembly(); - var version = "1.0.0"; - var infoVersion = asm!.GetCustomAttribute(); - if (infoVersion != null) - { - version = infoVersion.InformationalVersion; - } - else - { - var asmVersion = asm!.GetCustomAttribute(); - if (asmVersion != null) - { - version = asmVersion.Version; - } - } - Console.WriteLine(version); - } - } -} \ No newline at end of file diff --git a/src/ConsoleAppFramework/DiagnosticDescriptors.cs b/src/ConsoleAppFramework/DiagnosticDescriptors.cs new file mode 100644 index 0000000..632b821 --- /dev/null +++ b/src/ConsoleAppFramework/DiagnosticDescriptors.cs @@ -0,0 +1,85 @@ +using Microsoft.CodeAnalysis; + +namespace ConsoleAppFramework; + +internal static class DiagnosticDescriptors +{ + const string Category = "GenerateConsoleAppFramework"; + + public static void ReportDiagnostic(this SourceProductionContext context, DiagnosticDescriptor diagnosticDescriptor, Location location, params object?[]? messageArgs) + { + var diagnostic = Diagnostic.Create(diagnosticDescriptor, location, messageArgs); + context.ReportDiagnostic(diagnostic); + } + + public static DiagnosticDescriptor Create(int id, string message) + { + return Create(id, message, message); + } + + public static DiagnosticDescriptor Create(int id, string title, string messageFormat) + { + return new DiagnosticDescriptor( + id: "CAF" + id.ToString("000"), + title: title, + messageFormat: messageFormat, + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + } + + public static DiagnosticDescriptor RequireArgsAndMethod { get; } = Create( + 1, + "ConsoleApp.Run/RunAsync requires string[] args and lambda/method in arguments."); + + public static DiagnosticDescriptor ReturnTypeLambda { get; } = Create( + 2, + "Command lambda expressions return type must be void or int or async Task or async Task.", + "Command lambda expressions return type must be void or int or async Task or async Task but returned '{0}'."); + + public static DiagnosticDescriptor ReturnTypeMethod { get; } = Create( + 3, + "Command method return type must be void or int or async Task or async Task.", + "Command method return type must be void or int or async Task or async Task but returned '{0}'."); + + public static DiagnosticDescriptor SequentialArgument { get; } = Create( + 4, + "All Argument parameters must be sequential from first."); + + public static DiagnosticDescriptor FunctionPointerCanNotHaveValidation { get; } = Create( + 5, + "Function pointer can not have validation."); + + public static DiagnosticDescriptor AddCommandMustBeStringLiteral { get; } = Create( + 6, + "ConsoleAppBuilder.Add string command must be string literal."); + + public static DiagnosticDescriptor DuplicateCommandName { get; } = Create( + 7, + "Command name is duplicated.", + "Command name '{0}' is duplicated."); + + public static DiagnosticDescriptor AddInLoopIsNotAllowed { get; } = Create( + 8, + "ConsoleAppBuilder.Add/UseFilter is not allowed in loop statements(while, do, for, foreach)."); + + public static DiagnosticDescriptor CommandHasFilter { get; } = Create( + 9, + "ConsoleApp.Run does not allow the use of filters, but the function has a filter attribute."); + + public static DiagnosticDescriptor FilterMultipleConsturtor { get; } = Create( + 10, + "ConsoleAppFilter class does not allow multiple constructors."); + + public static DiagnosticDescriptor ClassMultipleConsturtor { get; } = Create( + 11, + "ConsoleAppBuilder.Add class does not allow multiple constructors."); + + public static DiagnosticDescriptor ClassHasNoPublicMethods { get; } = Create( + 12, + "ConsoleAppBuilder.Add class must have at least one public method."); + + public static DiagnosticDescriptor ClassIsStaticOrAbstract { get; } = Create( + 13, + "ConsoleAppBuilder.Add class does not allow static or abstract classes."); +} diff --git a/src/ConsoleAppFramework/Emitter.cs b/src/ConsoleAppFramework/Emitter.cs new file mode 100644 index 0000000..0174458 --- /dev/null +++ b/src/ConsoleAppFramework/Emitter.cs @@ -0,0 +1,600 @@ +using Microsoft.CodeAnalysis; +using System.Reflection.Metadata; + +namespace ConsoleAppFramework; + +internal class Emitter(WellKnownTypes wellKnownTypes) +{ + public void EmitRun(SourceBuilder sb, CommandWithId commandWithId, bool isRunAsync, string? methodName = null) + { + var command = commandWithId.Command; + + var emitForBuilder = methodName != null; + var hasCancellationToken = command.Parameters.Any(x => x.IsCancellationToken); + var hasConsoleAppContext = command.Parameters.Any(x => x.IsConsoleAppContext); + var hasArgument = command.Parameters.Any(x => x.IsArgument); + var hasValidation = command.Parameters.Any(x => x.HasValidation); + var parsableParameterCount = command.Parameters.Count(x => x.IsParsable); + var requiredParsableParameterCount = command.Parameters.Count(x => x.IsParsable && x.RequireCheckArgumentParsed); + + if (command.HasFilter) + { + isRunAsync = true; + hasCancellationToken = false; + hasConsoleAppContext = false; + } + var returnType = isRunAsync ? "async Task" : "void"; + var accessibility = !emitForBuilder ? "public" : "private"; + var argsType = !emitForBuilder ? "string[]" : (isRunAsync ? "string[]" : "ReadOnlySpan"); // NOTE: C# 13 will allow Span in async methods so can change to ReadOnlyMemory(and store .Span in local var) + methodName = methodName ?? (isRunAsync ? "RunAsync" : "Run"); + var unsafeCode = (command.MethodKind == MethodKind.FunctionPointer) ? "unsafe " : ""; + + var commandMethodType = command.BuildDelegateSignature(out var delegateType); + if (commandMethodType != null) + { + commandMethodType = $", {commandMethodType} command"; + } + + // emit custom delegate type + if (delegateType != null && !emitForBuilder) + { + sb.AppendLine($"internal {delegateType}"); + sb.AppendLine(); + } + + var filterCancellationToken = command.HasFilter ? ", ConsoleAppContext context, CancellationToken cancellationToken" : ""; + var rawArgs = !emitForBuilder ? "" : "string[] rawArgs, "; + + if (!emitForBuilder) + { + sb.AppendLine("/// "); + var help = CommandHelpBuilder.BuildCommandHelpMessage(commandWithId.Command); + foreach (var line in help.Split([Environment.NewLine], StringSplitOptions.None)) + { + sb.AppendLine($"/// {line.Replace("<", "<").Replace(">", ">")}
"); + } + sb.AppendLine("///
"); + } + + // method signature + using (sb.BeginBlock($"{accessibility} static {unsafeCode}{returnType} {methodName}({rawArgs}{argsType} args{commandMethodType}{filterCancellationToken})")) + { + sb.AppendLine($"if (TryShowHelpOrVersion(args, {requiredParsableParameterCount}, {commandWithId.Id})) return;"); + sb.AppendLine(); + + // prepare argument variables + if (hasCancellationToken) + { + sb.AppendLine("using var posixSignalHandler = PosixSignalHandler.Register(Timeout);"); + } + if (hasConsoleAppContext) + { + var rawArgsName = !emitForBuilder ? "args" : "rawArgs"; + sb.AppendLine($"var context = new ConsoleAppContext(\"{command.Name}\", {rawArgsName}, null);"); + } + for (var i = 0; i < command.Parameters.Length; i++) + { + var parameter = command.Parameters[i]; + if (parameter.IsParsable) + { + var defaultValue = parameter.IsParams ? $"({parameter.ToTypeDisplayString()})[]" + : parameter.HasDefaultValue ? parameter.DefaultValueToString() + : $"default({parameter.Type.ToFullyQualifiedFormatDisplayString()})"; + sb.AppendLine($"var arg{i} = {defaultValue};"); + if (parameter.RequireCheckArgumentParsed) + { + sb.AppendLine($"var arg{i}Parsed = false;"); + } + } + else if (parameter.IsCancellationToken) + { + if (command.HasFilter) + { + sb.AppendLine($"var arg{i} = cancellationToken;"); + } + else + { + sb.AppendLine($"var arg{i} = posixSignalHandler.Token;"); + } + } + else if (parameter.IsConsoleAppContext) + { + sb.AppendLine($"var arg{i} = context;"); + } + else if (parameter.IsFromServices) + { + var type = parameter.Type.ToFullyQualifiedFormatDisplayString(); + sb.AppendLine($"var arg{i} = ({type})ServiceProvider!.GetService(typeof({type}))!;"); + } + } + sb.AppendLineIfExists(command.Parameters); + + using (command.HasFilter ? sb.Nop : sb.BeginBlock("try")) + { + using (sb.BeginBlock("for (int i = 0; i < args.Length; i++)")) + { + // parse indexed argument([Argument] parameter) + if (hasArgument) + { + for (int i = 0; i < command.Parameters.Length; i++) + { + var parameter = command.Parameters[i]; + if (!parameter.IsArgument) continue; + + sb.AppendLine($"if (i == {parameter.ArgumentIndex})"); + using (sb.BeginBlock()) + { + sb.AppendLine($"{parameter.BuildParseMethod(i, parameter.Name, wellKnownTypes, increment: false)}"); + if (parameter.RequireCheckArgumentParsed) + { + sb.AppendLine($"arg{i}Parsed = true;"); + } + sb.AppendLine("continue;"); + } + } + sb.AppendLine(); + } + + sb.AppendLine("var name = args[i];"); + sb.AppendLine(); + + using (sb.BeginBlock("switch (name)")) + { + // parse argument(fast, switch directly) + for (int i = 0; i < command.Parameters.Length; i++) + { + var parameter = command.Parameters[i]; + if (!parameter.IsParsable) continue; + if (parameter.IsArgument) continue; + + sb.AppendLine($"case \"--{parameter.Name}\":"); + foreach (var alias in parameter.Aliases) + { + sb.AppendLine($"case \"{alias}\":"); + } + using (sb.BeginBlock()) + { + sb.AppendLine($"{parameter.BuildParseMethod(i, parameter.Name, wellKnownTypes, increment: true)}"); + if (parameter.RequireCheckArgumentParsed) + { + sb.AppendLine($"arg{i}Parsed = true;"); + } + sb.AppendLine("break;"); + } + } + + using (sb.BeginIndent("default:")) + { + // parse argument(slow, ignorecase) + for (int i = 0; i < command.Parameters.Length; i++) + { + var parameter = command.Parameters[i]; + if (!parameter.IsParsable) continue; + if (parameter.IsArgument) continue; + + sb.AppendLine($"if (string.Equals(name, \"--{parameter.Name}\", StringComparison.OrdinalIgnoreCase){(parameter.Aliases.Length == 0 ? ")" : "")}"); + for (int j = 0; j < parameter.Aliases.Length; j++) + { + var alias = parameter.Aliases[j]; + sb.AppendLine($" || string.Equals(name, \"{alias}\", StringComparison.OrdinalIgnoreCase){(parameter.Aliases.Length == j + 1 ? ")" : "")}"); + } + using (sb.BeginBlock()) + { + sb.AppendLine($"{parameter.BuildParseMethod(i, parameter.Name, wellKnownTypes, increment: true)}"); + if (parameter.RequireCheckArgumentParsed) + { + sb.AppendLine($"arg{i}Parsed = true;"); + } + sb.AppendLine($"break;"); + } + } + + sb.AppendLine("ThrowArgumentNameNotFound(name);"); + sb.AppendLine("break;"); + } + } + } + + // validate parsed + for (int i = 0; i < command.Parameters.Length; i++) + { + var parameter = command.Parameters[i]; + if (!parameter.IsParsable) continue; + + if (parameter.RequireCheckArgumentParsed) + { + sb.AppendLine($"if (!arg{i}Parsed) ThrowRequiredArgumentNotParsed(\"{parameter.Name}\");"); + } + } + + // attribute validation + if (hasValidation) + { + sb.AppendLine(); + sb.AppendLine("var validationContext = new System.ComponentModel.DataAnnotations.ValidationContext(\"\", null, null);"); + sb.AppendLine("var parameters = command.Method.GetParameters();"); + sb.AppendLine("System.Text.StringBuilder? errorMessages = null;"); + for (int i = 0; i < command.Parameters.Length; i++) + { + var parameter = command.Parameters[i]; + if (!parameter.HasValidation) continue; + + sb.AppendLine($"ValidateParameter(arg{i}, parameters[{i}], validationContext, ref errorMessages);"); + } + sb.AppendLine("if (errorMessages != null)"); + using (sb.BeginBlock()) + { + sb.AppendLine("throw new System.ComponentModel.DataAnnotations.ValidationException(errorMessages.ToString());"); + } + } + + // invoke for sync/async, void/int + sb.AppendLine(); + var methodArguments = string.Join(", ", command.Parameters.Select((x, i) => $"arg{i}!")); + string invokeCommand; + if (command.CommandMethodInfo == null) + { + invokeCommand = $"command({methodArguments})"; + } + else + { + var usingInstance = (isRunAsync, command.CommandMethodInfo.IsIDisposable, command.CommandMethodInfo.IsIAsyncDisposable) switch + { + // awaitable + (true, true, true) => "await using ", + (true, true, false) => "using ", + (true, false, true) => "await using ", + (true, false, false) => "", + // sync + (false, true, true) => "using ", + (false, true, false) => "using ", + (false, false, true) => "__use_wrapper", // IAsyncDisposable but sync, needs special wrapper + (false, false, false) => "" + }; + + if (usingInstance != "__use_wrapper") + { + sb.AppendLine($"{usingInstance}var instance = {command.CommandMethodInfo.BuildNew()};"); + invokeCommand = $"instance.{command.CommandMethodInfo.MethodName}({methodArguments})"; + } + else + { + sb.AppendLine($"using var instance = new SyncAsyncDisposeWrapper<{command.CommandMethodInfo.TypeFullName}>({command.CommandMethodInfo.BuildNew()});"); + invokeCommand = $"instance.Value.{command.CommandMethodInfo.MethodName}({methodArguments})"; + } + } + + if (hasCancellationToken) + { + invokeCommand = $"Task.Run(() => {invokeCommand}).WaitAsync(posixSignalHandler.TimeoutToken)"; + } + if (command.IsAsync || hasCancellationToken) + { + if (isRunAsync) + { + invokeCommand = $"await {invokeCommand}"; + } + else + { + invokeCommand = $"{invokeCommand}.GetAwaiter().GetResult()"; + } + } + + if (command.IsVoid) + { + sb.AppendLine($"{invokeCommand};"); + } + else + { + sb.AppendLine($"Environment.ExitCode = {invokeCommand};"); + } + } + + if (!command.HasFilter) + { + using (sb.BeginBlock("catch (Exception ex)")) + { + if (hasCancellationToken) + { + using (sb.BeginBlock("if (ex is OperationCanceledException)")) + { + sb.AppendLine("Environment.ExitCode = 130;"); + sb.AppendLine("return;"); + } + sb.AppendLine(); + } + + sb.AppendLine("Environment.ExitCode = 1;"); + + using (sb.BeginBlock("if (ex is ValidationException)")) + { + sb.AppendLine("LogError(ex.Message);"); + } + using (sb.BeginBlock("else")) + { + sb.AppendLine("LogError(ex.ToString());"); + } + } + } + } + } + + public void EmitBuilder(SourceBuilder sb, CommandWithId[] commandIds, bool emitSync, bool emitAsync) + { + // grouped by path + var commandGroup = commandIds.ToLookup(x => x.Command.Name.Split(' ')[0]); + var hasRootCommand = commandIds.Any(x => x.Command.IsRootCommand); + + using (sb.BeginBlock("partial struct ConsoleAppBuilder")) + { + // fields: 'Action command0 = default!;' + foreach (var item in commandIds.Where(x => x.FieldType != null)) + { + sb.AppendLine($"{item.FieldType} command{item.Id} = default!;"); + } + + // AddCore + sb.AppendLine(); + using (sb.BeginBlock("partial void AddCore(string commandName, Delegate command)")) + { + using (sb.BeginBlock("switch (commandName)")) + { + foreach (var item in commandIds.Where(x => x.FieldType != null)) + { + using (sb.BeginIndent($"case \"{item.Command.Name}\":")) + { + sb.AppendLine($"this.command{item.Id} = Unsafe.As<{item.FieldType}>(command);"); + sb.AppendLine("break;"); + } + } + using (sb.BeginIndent("default:")) + { + sb.AppendLine("break;"); + } + } + } + + // RunCore + if (emitSync) + { + sb.AppendLine(); + using (sb.BeginBlock("partial void RunCore(string[] args)")) + { + if (hasRootCommand) + { + using (sb.BeginBlock("if (args.Length == 1 && args[0] is \"--help\" or \"-h\")")) + { + sb.AppendLine("ShowHelp(-1);"); + sb.AppendLine("return;"); + } + } + + EmitRunBody(commandGroup, 0, false); + } + } + + // RunAsyncCore + if (emitAsync) + { + sb.AppendLine(); + using (sb.BeginBlock("partial void RunAsyncCore(string[] args, ref Task result)")) + { + if (hasRootCommand) + { + using (sb.BeginBlock("if (args.Length == 1 && args[0] is \"--help\" or \"-h\")")) + { + sb.AppendLine("ShowHelp(-1);"); + sb.AppendLine("return;"); + } + } + + EmitRunBody(commandGroup, 0, true); + } + } + + // static sync command function + HashSet emittedCommand = new(); + if (emitSync) + { + sb.AppendLine(); + foreach (var item in commandIds) + { + if (!emittedCommand.Add(item.Command)) continue; + + if (item.Command.HasFilter) + { + EmitRun(sb, item, true, $"RunCommand{item.Id}Async"); + } + else + { + EmitRun(sb, item, false, $"RunCommand{item.Id}"); + } + } + } + + // static async command function + if (emitAsync) + { + sb.AppendLine(); + foreach (var item in commandIds) + { + if (!emittedCommand.Add(item.Command)) continue; + EmitRun(sb, item, true, $"RunCommand{item.Id}Async"); + } + } + + // filter invoker + foreach (var item in commandIds) + { + if (item.Command.HasFilter) + { + sb.AppendLine(); + EmitFilterInvoker(item); + } + } + } + + void EmitRunBody(ILookup groupedCommands, int depth, bool isRunAsync) + { + var leafCommand = groupedCommands[""].FirstOrDefault(); + IDisposable? ifBlcok = null; + if (!(groupedCommands.Count == 1 && leafCommand != null)) + { + ifBlcok = sb.BeginBlock($"if (args.Length == {depth})"); + } + EmitLeafCommand(leafCommand); + if (ifBlcok != null) + { + sb.AppendLine("return;"); + ifBlcok.Dispose(); + } + else + { + return; + } + + using (sb.BeginBlock($"switch (args[{depth}])")) + { + foreach (var commands in groupedCommands.Where(x => x.Key != "")) + { + using (sb.BeginIndent($"case \"{commands.Key}\":")) + { + var nextDepth = depth + 1; + var nextGroup = commands + .ToLookup(x => + { + var path = x.Command.Name.Split(' '); + if (path.Length > nextDepth) + { + return path[nextDepth]; + } + else + { + return ""; // as leaf command + } + }); + + EmitRunBody(nextGroup, nextDepth, isRunAsync); // recursive + sb.AppendLine("break;"); + } + } + + using (sb.BeginIndent("default:")) + { + var leafCommand2 = groupedCommands[""].FirstOrDefault(); + EmitLeafCommand(leafCommand2); + sb.AppendLine("break;"); + } + } + + void EmitLeafCommand(CommandWithId? command) + { + if (command == null) + { + sb.AppendLine("ShowHelp(-1);"); + } + else + { + string commandArgs = ""; + if (command.Command.DelegateBuildType != DelegateBuildType.None) + { + commandArgs = $", command{command.Id}"; + } + + if (!command.Command.HasFilter) + { + if (!isRunAsync) + { + sb.AppendLine($"RunCommand{command.Id}(args, args.AsSpan({depth}){commandArgs});"); + } + else + { + sb.AppendLine($"result = RunCommand{command.Id}Async(args, args[{depth}..]{commandArgs});"); + } + } + else + { + var invokeCode = $"RunWithFilterAsync(\"{command.Command.Name}\", args, new Command{command.Id}Invoker(args[{depth}..]{commandArgs}).BuildFilter())"; + if (!isRunAsync) + { + sb.AppendLine($"{invokeCode}.GetAwaiter().GetResult();"); + } + else + { + sb.AppendLine($"result = {invokeCode};"); + } + } + } + } + } + + void EmitFilterInvoker(CommandWithId command) + { + var commandType = command.Command.BuildDelegateSignature(out _); + var needsCommand = commandType != null; + if (needsCommand) commandType = $", {commandType} command"; + + using (sb.BeginBlock($"sealed class Command{command.Id}Invoker(string[] args{commandType}) : ConsoleAppFilter(null!)")) + { + using (sb.BeginBlock($"public ConsoleAppFilter BuildFilter()")) + { + var i = -1; + foreach (var filter in command.Command.Filters.Reverse()) + { + var newFilter = filter.BuildNew(i == -1 ? "this" : $"filter{i}"); + sb.AppendLine($"var filter{++i} = {newFilter};"); + } + sb.AppendLine($"return filter{i};"); + } + + sb.AppendLine(); + using (sb.BeginBlock($"public override Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken)")) + { + var cmdArgs = needsCommand ? ", command" : ""; + sb.AppendLine($"return RunCommand{command.Id}Async(context.Arguments, args{cmdArgs}, context, cancellationToken);"); + } + } + } + } + + // for single root command(Run) + public void EmitHelp(SourceBuilder sb, Command command) + { + using (sb.BeginBlock("static partial void ShowHelp(int helpId)")) + { + sb.AppendLine("Log(\"\"\""); + sb.AppendWithoutIndent(CommandHelpBuilder.BuildRootHelpMessage(command)); + sb.AppendLineWithoutIndent("\"\"\");"); + } + } + + public void EmitHelp(SourceBuilder sb, CommandWithId[] commands) + { + using (sb.BeginBlock("static partial void ShowHelp(int helpId)")) + { + using (sb.BeginBlock("switch (helpId)")) + { + foreach (var command in commands) + { + using (sb.BeginIndent($"case {command.Id}:")) + { + sb.AppendLine("Log(\"\"\""); + sb.AppendWithoutIndent(CommandHelpBuilder.BuildCommandHelpMessage(command.Command)); + sb.AppendLineWithoutIndent("\"\"\");"); + sb.AppendLine("break;"); + } + } + + using (sb.BeginIndent("default:")) + { + sb.AppendLine("Log(\"\"\""); + sb.AppendWithoutIndent(CommandHelpBuilder.BuildRootHelpMessage(commands.Select(x => x.Command).ToArray())); + sb.AppendLineWithoutIndent("\"\"\");"); + sb.AppendLine("break;"); + } + } + } + } + + internal record CommandWithId(string? FieldType, Command Command, int Id); +} diff --git a/src/ConsoleAppFramework/LegacyCompatibleExtensions.cs b/src/ConsoleAppFramework/LegacyCompatibleExtensions.cs deleted file mode 100644 index 4617e7e..0000000 --- a/src/ConsoleAppFramework/LegacyCompatibleExtensions.cs +++ /dev/null @@ -1,86 +0,0 @@ -using Microsoft.Extensions.Hosting; -using System; -using System.ComponentModel; -using System.Reflection; -using System.Threading.Tasks; - -namespace ConsoleAppFramework -{ - public static class LegacyCompatibleExtensions - { - /// - /// Run multiple ConsoleApp that are searched from all assemblies. - /// - // [Obsolete] - [EditorBrowsable(EditorBrowsableState.Never)] - public static Task RunConsoleAppFrameworkAsync(this IHostBuilder hostBuilder, string[] args, ConsoleAppOptions? options = null, Assembly[]? searchAssemblies = null) - { - options = ConfigureLegacyCompatible(options); - args = ConfigureLegacyCompatibleArgs(args); - - return new ConsoleAppBuilder(args, hostBuilder, options) - .Build() - .AddAllCommandType(searchAssemblies ?? AppDomain.CurrentDomain.GetAssemblies()) - .RunAsync(); - } - - /// - /// Run a single ConsoleApp type that is targeted by type argument. - /// - // [Obsolete] - [EditorBrowsable(EditorBrowsableState.Never)] - public static Task RunConsoleAppFrameworkAsync(this IHostBuilder hostBuilder, string[] args, ConsoleAppOptions? options = null) - where T : ConsoleAppBase - { - options = ConfigureLegacyCompatible(options); - args = ConfigureLegacyCompatibleArgs(args); - - return new ConsoleAppBuilder(args, hostBuilder, options) - .Build() - .AddCommands() - .RunAsync(); - } - - static ConsoleAppOptions ConfigureLegacyCompatible(ConsoleAppOptions? options) - { - if (options == null) - { - options = new ConsoleAppOptions(); - } - - options.NoAttributeCommandAsImplicitlyDefault = true; - options.StrictOption = false; - options.NameConverter = x => x.ToLower(); - options.ReplaceToUseSimpleConsoleLogger = false; - return options; - } - - static string[] ConfigureLegacyCompatibleArgs(string[] args) - { - if (args.Length >= 1 && args[0].Contains(".")) - { - var spritCommand = args[0].Split('.'); - - var newArgs = new string[args.Length + 1]; - for (int i = 0; i < newArgs.Length; i++) - { - if (i == 0) - { - newArgs[i] = spritCommand[0]; - } - else if (i == 1) - { - newArgs[i] = spritCommand[1]; - } - else - { - newArgs[i] = args[i - 1]; - } - } - return newArgs; - } - - return args; - } - } -} \ No newline at end of file diff --git a/src/ConsoleAppFramework/NameConverter.cs b/src/ConsoleAppFramework/NameConverter.cs new file mode 100644 index 0000000..c5702c0 --- /dev/null +++ b/src/ConsoleAppFramework/NameConverter.cs @@ -0,0 +1,31 @@ +using System.Text; + +namespace ConsoleAppFramework; + +public static class NameConverter +{ + public static string ToKebabCase(string name) + { + var sb = new StringBuilder(); + for (int i = 0; i < name.Length; i++) + { + if (!Char.IsUpper(name[i])) + { + sb.Append(name[i]); + continue; + } + + // Abc, abC, AB-c => first or Last or capital continuous, no added. + if (i == 0 || i == name.Length - 1 || Char.IsUpper(name[i + 1])) + { + sb.Append(Char.ToLowerInvariant(name[i])); + continue; + } + + // others, add- + sb.Append('-'); + sb.Append(Char.ToLowerInvariant(name[i])); + } + return sb.ToString(); + } +} diff --git a/src/ConsoleAppFramework/OptionAttribute.cs b/src/ConsoleAppFramework/OptionAttribute.cs deleted file mode 100644 index 4b5950a..0000000 --- a/src/ConsoleAppFramework/OptionAttribute.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; - -namespace ConsoleAppFramework -{ - [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)] - public class OptionAttribute : Attribute - { - public int Index { get; } - public string? ShortName { get; } - public string? Description { get; } - - /// Override default value on help. - public string? DefaultValue { get; set; } - - public OptionAttribute(int index) - { - this.Index = index; - this.Description = null; - } - - public OptionAttribute(int index, string description) - { - this.Index = index; - this.Description = description; - } - - public OptionAttribute(string shortName) - { - this.Index = -1; - this.ShortName = string.IsNullOrWhiteSpace(shortName) ? null : shortName; - this.Description = null; - } - - public OptionAttribute(string? shortName, string description) - { - this.Index = -1; - this.ShortName = string.IsNullOrWhiteSpace(shortName) ? null : shortName; - this.Description = description; - } - } -} diff --git a/src/ConsoleAppFramework/ParamsValidator.cs b/src/ConsoleAppFramework/ParamsValidator.cs deleted file mode 100644 index 9474cb4..0000000 --- a/src/ConsoleAppFramework/ParamsValidator.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.ComponentModel.DataAnnotations; -using System.Linq; -using System.Reflection; - -namespace ConsoleAppFramework -{ - /// - /// Validator of command parameters. - /// - public interface IParamsValidator - { - /// - /// Validate of command based on validation attributes - /// applied to method's parameters. - /// - ValidationResult? ValidateParameters(IEnumerable<(ParameterInfo Parameter, object? Value)> parameters); - } - - /// - public class ParamsValidator : IParamsValidator - { - private readonly ConsoleAppOptions options; - - public ParamsValidator(ConsoleAppOptions options) => this.options = options; - - /// - ValidationResult? IParamsValidator.ValidateParameters( - IEnumerable<(ParameterInfo Parameter, object? Value)> parameters) - { - var invalidParameters = parameters - .Select(tuple => (tuple.Parameter, tuple.Value, Result: Validate(tuple.Parameter, tuple.Value))) - .Where(tuple => tuple.Result != ValidationResult.Success) - .ToImmutableArray(); - - if (!invalidParameters.Any()) - { - return ValidationResult.Success; - } - - var errorMessage = string.Join(Environment.NewLine, - invalidParameters - .Select(tuple => - $"{options.NameConverter(tuple.Parameter.Name!)} " + - $"({tuple.Value}): " + - $"{tuple.Result!.ErrorMessage}") - ); - - return new ValidationResult($"Some parameters have invalid values:{Environment.NewLine}{errorMessage}"); - } - - private static ValidationResult? Validate(ParameterInfo parameterInfo, object? value) - { - if (value is null) return ValidationResult.Success; - - var validationContext = new ValidationContext(value, null, null); - - var failedResults = GetValidationAttributes(parameterInfo) - .Select(attribute => attribute.GetValidationResult(value, validationContext)) - .Where(result => result != ValidationResult.Success) - .ToImmutableArray(); - - return failedResults.Any() - ? new ValidationResult(string.Join("; ", failedResults.Select(res => res?.ErrorMessage))) - : ValidationResult.Success; - } - - private static IEnumerable GetValidationAttributes(ParameterInfo parameterInfo) - => parameterInfo - .GetCustomAttributes() - .OfType(); - } -} diff --git a/src/ConsoleAppFramework/Parser.cs b/src/ConsoleAppFramework/Parser.cs new file mode 100644 index 0000000..746c565 --- /dev/null +++ b/src/ConsoleAppFramework/Parser.cs @@ -0,0 +1,594 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace ConsoleAppFramework; + +internal class Parser(SourceProductionContext context, InvocationExpressionSyntax node, SemanticModel model, WellKnownTypes wellKnownTypes, DelegateBuildType delegateBuildType, FilterInfo[] globalFilters) +{ + public Command? ParseAndValidate() // for ConsoleApp.Run + { + var args = node.ArgumentList.Arguments; + if (args.Count == 2) // 0 = args, 1 = lambda + { + var command = ExpressionToCommand(args[1].Expression, ""); // rootCommand(commandName = "") + if (command != null) + { + return ValidateCommand(command); + } + return null; + } + + context.ReportDiagnostic(DiagnosticDescriptors.RequireArgsAndMethod, node.GetLocation()); + return null; + } + + public Command? ParseAndValidateForBuilderDelegateRegistration() // for ConsoleAppBuilder.Add + { + // Add(string commandName, Delgate command) + var args = node.ArgumentList.Arguments; + if (args.Count == 2) // 0 = string command, 1 = lambda + { + var commandName = args[0]; + + if (!commandName.Expression.IsKind(SyntaxKind.StringLiteralExpression)) + { + context.ReportDiagnostic(DiagnosticDescriptors.AddCommandMustBeStringLiteral, commandName.GetLocation()); + return null; + } + + var name = (commandName.Expression as LiteralExpressionSyntax)!.Token.ValueText; + var command = ExpressionToCommand(args[1].Expression, name); + if (command != null) + { + return ValidateCommand(command); + } + return null; + } + + return null; + } + + public Command?[] ParseAndValidateForBuilderClassRegistration() + { + // Add + var genericName = (node.Expression as MemberAccessExpressionSyntax)?.Name as GenericNameSyntax; + var genericType = genericName!.TypeArgumentList.Arguments[0]; + + // Add(string commandPath) + string? commandPath = null; + var args = node.ArgumentList.Arguments; + if (node.ArgumentList.Arguments.Count == 1) + { + var commandName = args[0]; + if (!commandName.Expression.IsKind(SyntaxKind.StringLiteralExpression)) + { + context.ReportDiagnostic(DiagnosticDescriptors.AddCommandMustBeStringLiteral, commandName.GetLocation()); + return []; + } + + commandPath = (commandName.Expression as LiteralExpressionSyntax)!.Token.ValueText; + } + + // T + var type = model.GetTypeInfo(genericType).Type!; + + if (type.IsStatic || type.IsAbstract) + { + context.ReportDiagnostic(DiagnosticDescriptors.ClassIsStaticOrAbstract, node.GetLocation()); + return []; + } + + var publicMethods = type.GetMembers() + .Where(x => x.DeclaredAccessibility == Accessibility.Public) + .OfType() + .Where(x => x.DeclaredAccessibility == Accessibility.Public && !x.IsStatic) + .Where(x => x.MethodKind == Microsoft.CodeAnalysis.MethodKind.Ordinary) + .Where(x => !(x.Name is "Dispose" or "DisposeAsync" or "GetHashCode" or "Equals" or "ToString")) + .ToArray(); + + var publicConstructors = type.GetMembers() + .OfType() + .Where(x => x.MethodKind == Microsoft.CodeAnalysis.MethodKind.Constructor && x.DeclaredAccessibility == Accessibility.Public) + .ToArray(); + + if (publicMethods.Length == 0) + { + context.ReportDiagnostic(DiagnosticDescriptors.ClassHasNoPublicMethods, node.GetLocation()); + return []; + } + + if (publicConstructors.Length != 1) + { + context.ReportDiagnostic(DiagnosticDescriptors.ClassMultipleConsturtor, node.GetLocation()); + return []; + } + + var hasIDisposable = type.AllInterfaces.Any(x => SymbolEqualityComparer.Default.Equals(x, wellKnownTypes.IDisposable)); + var hasIAsyncDisposable = type.AllInterfaces.Any(x => SymbolEqualityComparer.Default.Equals(x, wellKnownTypes.IAsyncDisposable)); + + var typeFilters = type.GetAttributes() + .Where(x => x.AttributeClass?.Name == "ConsoleAppFilterAttribute") + .Select(x => + { + var filterType = x.AttributeClass!.TypeArguments[0]; + var filter = FilterInfo.Create(filterType); + + if (filter == null) + { + context.ReportDiagnostic(DiagnosticDescriptors.FilterMultipleConsturtor, x.ApplicationSyntaxReference!.GetSyntax().GetLocation()); + return null!; + } + + return filter; + }) + .ToArray(); + if (typeFilters.Any(x => x == null)) + { + return []; + } + + var methodInfoBase = new CommandMethodInfo + { + TypeFullName = type.ToFullyQualifiedFormatDisplayString(), + IsIDisposable = hasIDisposable, + IsIAsyncDisposable = hasIAsyncDisposable, + ConstructorParameterTypes = publicConstructors[0].Parameters.Select(x => x.Type).ToArray(), + MethodName = "", // without methodname + }; + + return publicMethods + .Select(x => + { + string commandName; + var commandAttribute = x.GetAttributes().FirstOrDefault(x => x.AttributeClass?.Name == "CommandAttribute"); + if (commandAttribute != null) + { + commandName = (x.GetAttributes()[0].ConstructorArguments[0].Value as string)!; + } + else + { + commandName = NameConverter.ToKebabCase(x.Name); + } + + var command = ParseFromMethodSymbol(x, false, (commandPath == null) ? commandName : $"{commandPath.Trim()} {commandName}", typeFilters); + if (command == null) return null; + + command.CommandMethodInfo = methodInfoBase with { MethodName = x.Name }; + return command; + }) + .ToArray(); + } + + Command? ExpressionToCommand(ExpressionSyntax expression, string commandName) + { + var lambda = expression as ParenthesizedLambdaExpressionSyntax; + if (lambda == null) + { + if (expression.IsKind(SyntaxKind.AddressOfExpression)) + { + var operand = (expression as PrefixUnaryExpressionSyntax)!.Operand; + + var methodSymbols = model.GetMemberGroup(operand); + if (methodSymbols.Length > 0 && methodSymbols[0] is IMethodSymbol methodSymbol) + { + return ParseFromMethodSymbol(methodSymbol, addressOf: true, commandName, []); + } + } + else + { + var methodSymbols = model.GetMemberGroup(expression); + if (methodSymbols.Length > 0 && methodSymbols[0] is IMethodSymbol methodSymbol) + { + return ParseFromMethodSymbol(methodSymbol, addressOf: false, commandName, []); + } + } + } + else + { + return ParseFromLambda(lambda, commandName); + } + + return null; + } + + Command? ParseFromLambda(ParenthesizedLambdaExpressionSyntax lambda, string commandName) + { + var isAsync = lambda.AsyncKeyword.IsKind(SyntaxKind.AsyncKeyword); + + var isVoid = lambda.ReturnType == null; + if (!isVoid) + { + if (!isAsync) + { + var keyword = (lambda.ReturnType as PredefinedTypeSyntax)?.Keyword; + if (keyword != null && keyword.Value.IsKind(SyntaxKind.VoidKeyword)) + { + isVoid = true; + } + else if (keyword != null && keyword.Value.IsKind(SyntaxKind.IntKeyword)) + { + isVoid = false; // ok + } + else + { + context.ReportDiagnostic(DiagnosticDescriptors.ReturnTypeLambda, lambda.ReturnType!.GetLocation(), lambda.ReturnType); + return null; + } + } + else + { + if (!(lambda.ReturnType?.ToString() is "Task" or "Task")) + { + context.ReportDiagnostic(DiagnosticDescriptors.ReturnTypeLambda, lambda.ReturnType!.GetLocation(), lambda.ReturnType); + return null; + } + + var firstType = (lambda.ReturnType as GenericNameSyntax)?.TypeArgumentList.Arguments.FirstOrDefault(); + if (firstType == null) + { + isVoid = true; + } + else if ((firstType as PredefinedTypeSyntax)?.Keyword.IsKind(SyntaxKind.IntKeyword) ?? false) + { + isVoid = false; + } + else + { + context.ReportDiagnostic(DiagnosticDescriptors.ReturnTypeLambda, lambda.ReturnType!.GetLocation(), lambda.ReturnType); + return null; + } + } + } + + var parsableIndex = 0; + var parameters = lambda.ParameterList.Parameters + .Where(x => x.Type != null) + .Select(x => + { + var type = model.GetTypeInfo(x.Type!); + + var hasDefault = x.Default != null; + object? defaultValue = null; + if (x.Default?.Value is LiteralExpressionSyntax literal) + { + var token = literal.Token; + if (token.IsKind(SyntaxKind.DefaultKeyword)) + { + defaultValue = null; + } + else + { + defaultValue = token.Value; + } + } + else if (x.Default != null) + { + var value = model.GetConstantValue(x.Default.Value); + if (value.HasValue) + { + defaultValue = value.Value; + } + } + + var hasParams = x.Modifiers.Any(x => x.IsKind(SyntaxKind.ParamsKeyword)); + + var customParserType = x.AttributeLists.SelectMany(x => x.Attributes) + .Select(x => + { + var attr = model.GetTypeInfo(x).Type; + if (attr != null && attr.AllInterfaces.Any(x => x.Name == "IArgumentParser")) + { + return attr; + } + return null; + }) + .FirstOrDefault(x => x != null); + + var hasValidation = x.AttributeLists.SelectMany(x => x.Attributes) + .Any(x => + { + var attr = model.GetTypeInfo(x).Type as INamedTypeSymbol; + if (attr != null && attr.GetBaseTypes().Any(x => x.Name == "ValidationAttribute")) + { + return true; + } + return false; + }); + + var isFromServices = x.AttributeLists.SelectMany(x => x.Attributes) + .Any(x => + { + var name = x.Name; + if (x.Name is QualifiedNameSyntax qns) + { + name = qns.Right; + } + + var identifier = name.ToString(); + return identifier is "FromServices" or "FromServicesAttribute"; + }); + + var hasArgument = x.AttributeLists.SelectMany(x => x.Attributes) + .Any(x => + { + var name = x.Name; + if (x.Name is QualifiedNameSyntax qns) + { + name = qns.Right; + } + + var identifier = name.ToString(); + return identifier is "Argument" or "ArgumentAttribute"; + }); + + var isCancellationToken = SymbolEqualityComparer.Default.Equals(type.Type!, wellKnownTypes.CancellationToken); + var isConsoleAppContext = type.Type!.Name == "ConsoleAppContext"; + + var argumentIndex = -1; + if (!(isFromServices || isCancellationToken || isConsoleAppContext)) + { + if (hasArgument) + { + argumentIndex = parsableIndex++; + } + else + { + parsableIndex++; + } + } + + var isNullableReference = x.Type.IsKind(SyntaxKind.NullableType) && type.Type?.OriginalDefinition.SpecialType != SpecialType.System_Nullable_T; + + return new CommandParameter + { + Name = NameConverter.ToKebabCase(x.Identifier.Text), + OriginalParameterName = x.Identifier.Text, + IsNullableReference = isNullableReference, + IsConsoleAppContext = isConsoleAppContext, + IsParams = hasParams, + Type = type.Type!, + Location = x.GetLocation(), + HasDefaultValue = hasDefault, + DefaultValue = defaultValue, + CustomParserType = customParserType, + HasValidation = hasValidation, + IsCancellationToken = isCancellationToken, + IsFromServices = isFromServices, + Aliases = [], + Description = "", + ArgumentIndex = argumentIndex, + }; + }) + .Where(x => x.Type != null) + .ToArray(); + + var cmd = new Command + { + Name = commandName, + IsAsync = isAsync, + IsVoid = isVoid, + Parameters = parameters, + MethodKind = MethodKind.Lambda, + Description = "", + DelegateBuildType = delegateBuildType, + Filters = globalFilters, + }; + + return cmd; + } + + Command? ParseFromMethodSymbol(IMethodSymbol methodSymbol, bool addressOf, string commandName, FilterInfo[] typeFilters) + { + var docComment = methodSymbol.DeclaringSyntaxReferences[0].GetSyntax().GetDocumentationCommentTriviaSyntax(); + var summary = ""; + Dictionary? parameterDescriptions = null; + if (docComment != null) + { + summary = docComment.GetSummary(); + parameterDescriptions = docComment.GetParams().ToDictionary(x => x.Name, x => x.Description); + } + + // allow returnType = void, int, Task, Task + var isVoid = false; + var isAsync = false; + if (methodSymbol.ReturnType.SpecialType == SpecialType.System_Void) + { + isVoid = true; + } + else if (methodSymbol.ReturnType.SpecialType == SpecialType.System_Int32) + { + isVoid = false; + } + else if ((methodSymbol.ReturnType as INamedTypeSymbol)!.EqualsUnconstructedGenericType(wellKnownTypes.Task)) + { + isVoid = true; + isAsync = true; + } + else if ((methodSymbol.ReturnType as INamedTypeSymbol)!.EqualsUnconstructedGenericType(wellKnownTypes.Task_T)) + { + var typeArg = (methodSymbol.ReturnType as INamedTypeSymbol)!.TypeArguments[0]; + if (typeArg.SpecialType == SpecialType.System_Int32) + { + isVoid = false; + isAsync = true; + } + else + { + var syntax = methodSymbol.DeclaringSyntaxReferences[0].GetSyntax(); + var location = syntax switch + { + MethodDeclarationSyntax x => x.ReturnType.GetLocation(), + LocalFunctionStatementSyntax x => x.ReturnType.GetLocation(), + _ => node.GetLocation() + }; + + context.ReportDiagnostic(DiagnosticDescriptors.ReturnTypeMethod, location, methodSymbol.ReturnType); + return null; + } + } + else + { + var syntax = methodSymbol.DeclaringSyntaxReferences[0].GetSyntax(); + var location = syntax switch + { + MethodDeclarationSyntax x => x.ReturnType.GetLocation(), + LocalFunctionStatementSyntax x => x.ReturnType.GetLocation(), + _ => node.GetLocation() + }; + + context.ReportDiagnostic(DiagnosticDescriptors.ReturnTypeMethod, location, methodSymbol.ReturnType); + return null; + } + + var methodFilters = methodSymbol.GetAttributes() + .Where(x => x.AttributeClass?.Name == "ConsoleAppFilterAttribute") + .Select(x => + { + var filterType = x.AttributeClass!.TypeArguments[0]; + var filter = FilterInfo.Create(filterType); + + if (filter == null) + { + context.ReportDiagnostic(DiagnosticDescriptors.FilterMultipleConsturtor, x.ApplicationSyntaxReference!.GetSyntax().GetLocation()); + return null!; + } + + return filter; + }) + .ToArray(); + if (methodFilters.Any(x => x == null)) + { + return null; + } + + var parsableIndex = 0; + var parameters = methodSymbol.Parameters + .Select(x => + { + var customParserType = x.GetAttributes().FirstOrDefault(x => x.AttributeClass?.AllInterfaces.Any(y => y.Name == "IArgumentParser") ?? false); + var hasFromServices = x.GetAttributes().Any(x => x.AttributeClass?.Name == "FromServicesAttribute"); + var hasArgument = x.GetAttributes().Any(x => x.AttributeClass?.Name == "ArgumentAttribute"); + var hasValidation = x.GetAttributes().Any(x => x.AttributeClass?.GetBaseTypes().Any(y => y.Name == "ValidationAttribute") ?? false); + var isCancellationToken = SymbolEqualityComparer.Default.Equals(x.Type, wellKnownTypes.CancellationToken); + var isConsoleAppContext = x.Type!.Name == "ConsoleAppContext"; + + string description = ""; + string[] aliases = []; + if (parameterDescriptions != null && parameterDescriptions.TryGetValue(x.Name, out var desc)) + { + ParseParameterDescription(desc, out aliases, out description); + } + + var argumentIndex = -1; + if (!(hasFromServices || isCancellationToken)) + { + if (hasArgument) + { + argumentIndex = parsableIndex++; + } + else + { + parsableIndex++; + } + } + + var isNullableReference = x.NullableAnnotation == NullableAnnotation.Annotated && x.Type.OriginalDefinition.SpecialType != SpecialType.System_Nullable_T; + + return new CommandParameter + { + Name = NameConverter.ToKebabCase(x.Name), + OriginalParameterName = x.Name, + IsNullableReference = isNullableReference, + IsConsoleAppContext = isConsoleAppContext, + IsParams = x.IsParams, + Location = x.DeclaringSyntaxReferences[0].GetSyntax().GetLocation(), + Type = x.Type, + HasDefaultValue = x.HasExplicitDefaultValue, + DefaultValue = x.HasExplicitDefaultValue ? x.ExplicitDefaultValue : null, + CustomParserType = null, + IsCancellationToken = isCancellationToken, + IsFromServices = hasFromServices, + HasValidation = hasValidation, + Aliases = aliases, + ArgumentIndex = argumentIndex, + Description = description + }; + }) + .ToArray(); + + var cmd = new Command + { + Name = commandName, + IsAsync = isAsync, + IsVoid = isVoid, + Parameters = parameters, + MethodKind = addressOf ? MethodKind.FunctionPointer : MethodKind.Method, + Description = summary, + DelegateBuildType = delegateBuildType, + Filters = globalFilters.Concat(typeFilters).Concat(methodFilters).ToArray(), + }; + + return cmd; + } + + Command? ValidateCommand(Command command) + { + var hasDiagnostic = false; + + // Sequential Argument + var existsNotArgument = false; + foreach (var parameter in command.Parameters) + { + if (!parameter.IsParsable) continue; + + if (!parameter.IsArgument) + { + existsNotArgument = true; + } + + if (parameter.IsArgument && existsNotArgument) + { + context.ReportDiagnostic(DiagnosticDescriptors.SequentialArgument, parameter.Location); + hasDiagnostic = true; + } + } + + // FunctionPointer can not use validation + if (command.MethodKind == MethodKind.FunctionPointer) + { + foreach (var p in command.Parameters) + { + if (p.HasValidation) + { + context.ReportDiagnostic(DiagnosticDescriptors.FunctionPointerCanNotHaveValidation, p.Location); + hasDiagnostic = true; + } + } + } + + if (hasDiagnostic) return null; + + return command; + } + + void ParseParameterDescription(string originalDescription, out string[] aliases, out string description) + { + // Example: + // -h|--help, This is a help. + + var splitOne = originalDescription.Split(','); + + // has alias + if (splitOne[0].TrimStart().StartsWith("-")) + { + aliases = splitOne[0].Split(['|'], StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()).ToArray(); + description = string.Join("", splitOne.Skip(1)).Trim(); + } + else + { + aliases = []; + description = originalDescription; + } + } +} diff --git a/src/ConsoleAppFramework/Properties/launchSettings.json b/src/ConsoleAppFramework/Properties/launchSettings.json new file mode 100644 index 0000000..7f7a5b0 --- /dev/null +++ b/src/ConsoleAppFramework/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "Roslyn Debug Profile": { + "commandName": "DebugRoslynComponent", + "targetProject": "..\\..\\sandbox\\GeneratorSandbox\\GeneratorSandbox.csproj" + } + } +} \ No newline at end of file diff --git a/src/ConsoleAppFramework/RoslynExtensions.cs b/src/ConsoleAppFramework/RoslynExtensions.cs new file mode 100644 index 0000000..682fc13 --- /dev/null +++ b/src/ConsoleAppFramework/RoslynExtensions.cs @@ -0,0 +1,106 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp; + +namespace ConsoleAppFramework; + +internal static class RoslynExtensions +{ + internal static string ToFullyQualifiedFormatDisplayString(this ITypeSymbol typeSymbol) + { + return typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } + + public static bool EqualsUnconstructedGenericType(this INamedTypeSymbol left, INamedTypeSymbol right) + { + var l = left.IsGenericType ? left.ConstructUnboundGenericType() : left; + var r = right.IsGenericType ? right.ConstructUnboundGenericType() : right; + return SymbolEqualityComparer.Default.Equals(l, r); + } + + public static IEnumerable GetBaseTypes(this INamedTypeSymbol type, bool includeSelf = false) + { + if (includeSelf) yield return type; + var baseType = type.BaseType; + while (baseType != null) + { + yield return baseType; + baseType = baseType.BaseType; + } + } + + public static DocumentationCommentTriviaSyntax? GetDocumentationCommentTriviaSyntax(this SyntaxNode node) + { + // Hack note: + // ISymbol.GetDocumentationCommtentXml requirestrue. + // However, getting the DocumentationCommentTrivia of a SyntaxNode also requires the same condition. + // It can only be obtained when DocumentationMode is Parse or Diagnostic, but whenfalse, + // it becomes None, and the necessary Trivia cannot be obtained. + // Therefore, we will attempt to reparse and retrieve it. + + // About DocumentationMode and Trivia: https://github.com/dotnet/roslyn/issues/58210 + if (node.SyntaxTree.Options.DocumentationMode == DocumentationMode.None) + { + var withDocumentationComment = node.SyntaxTree.Options.WithDocumentationMode(DocumentationMode.Parse); + var code = node.ToFullString(); + var newTree = CSharpSyntaxTree.ParseText(code, (CSharpParseOptions)withDocumentationComment); + node = newTree.GetRoot(); + } + + foreach (var leadingTrivia in node.GetLeadingTrivia()) + { + if (leadingTrivia.GetStructure() is DocumentationCommentTriviaSyntax structure) + { + return structure; + } + } + + return null; + } + + static IEnumerable GetXmlElements(this SyntaxList content, string elementName) + { + foreach (XmlNodeSyntax syntax in content) + { + if (syntax is XmlEmptyElementSyntax emptyElement) + { + if (string.Equals(elementName, emptyElement.Name.ToString(), StringComparison.Ordinal)) + { + yield return emptyElement; + } + + continue; + } + + if (syntax is XmlElementSyntax elementSyntax) + { + if (string.Equals(elementName, elementSyntax.StartTag?.Name?.ToString(), StringComparison.Ordinal)) + { + yield return elementSyntax; + } + + continue; + } + } + } + + public static string GetSummary(this DocumentationCommentTriviaSyntax docComment) + { + var summary = docComment.Content.GetXmlElements("summary").FirstOrDefault() as XmlElementSyntax; + if (summary == null) return ""; + + return summary.Content.ToString().Replace("///", "").Trim(); + } + + public static IEnumerable<(string Name, string Description)> GetParams(this DocumentationCommentTriviaSyntax docComment) + { + foreach (var item in docComment.Content.GetXmlElements("param").OfType()) + { + var name = item.StartTag.Attributes.OfType().FirstOrDefault()?.Identifier.Identifier.ValueText.Replace("///", "").Trim() ?? ""; + var desc = item.Content.ToString().Replace("///", "").Trim() ?? ""; + yield return (name, desc); + } + + yield break; + } +} diff --git a/src/ConsoleAppFramework/SimpleConsoleLogger.cs b/src/ConsoleAppFramework/SimpleConsoleLogger.cs deleted file mode 100644 index 054d5e5..0000000 --- a/src/ConsoleAppFramework/SimpleConsoleLogger.cs +++ /dev/null @@ -1,114 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using System.Linq; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Logging; -using System; -using ConsoleAppFramework.Logging; - -namespace ConsoleAppFramework.Logging -{ - public class SimpleConsoleLoggerProvider : ILoggerProvider - { - readonly SimpleConsoleLogger loggerDefault; - readonly SimpleConsoleLogger loggerHostingInternal; - - public SimpleConsoleLoggerProvider() - { - loggerDefault = new SimpleConsoleLogger(LogLevel.Trace); - loggerHostingInternal = new SimpleConsoleLogger(LogLevel.Information); - } - - public ILogger CreateLogger(string categoryName) - { - // NOTE: It omits unimportant log messages from Microsoft.Extension.Hosting.Internal.* - return categoryName.StartsWith("Microsoft.Extensions.Hosting.Internal") - ? loggerHostingInternal - : loggerDefault; - } - - public void Dispose() - { - } - } - - public class SimpleConsoleLogger : ILogger - { - readonly LogLevel minimumLogLevel; - - public SimpleConsoleLogger(LogLevel minimumLogLevel) - { - this.minimumLogLevel = minimumLogLevel; - } - - public IDisposable BeginScope(TState state) - { - return NullDisposable.Instance; - } - - public bool IsEnabled(LogLevel logLevel) - { - return minimumLogLevel <= logLevel; - } - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) - { - if (formatter == null) throw new ArgumentNullException(nameof(formatter)); - - if (minimumLogLevel > logLevel) return; - - var msg = formatter(state, exception); - - if (!string.IsNullOrEmpty(msg)) - { - Console.WriteLine(msg); - } - - if (exception != null) - { - Console.WriteLine(exception.ToString()); - } - } - - class NullDisposable : IDisposable - { - public static readonly IDisposable Instance = new NullDisposable(); - - public void Dispose() - { - } - } - } -} - -namespace ConsoleAppFramework -{ - public static class SimpleConsoleLoggerExtensions - { - /// - /// use ConsoleAppFramework.Logging.SimpleConsoleLogger. - /// - /// - /// - public static ILoggingBuilder AddSimpleConsole(this ILoggingBuilder builder) - { - builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); - return builder; - } - - /// - /// Remove default ConsoleLoggerProvider and replace to SimpleConsoleLogger. - /// - public static ILoggingBuilder ReplaceToSimpleConsole(this ILoggingBuilder builder) - { - // Use SimpleConsoleLogger instead of the default ConsoleLogger. - var consoleLogger = builder.Services.FirstOrDefault(x => x.ImplementationType?.FullName == "Microsoft.Extensions.Logging.Console.ConsoleLoggerProvider"); - if (consoleLogger != null) - { - builder.Services.Remove(consoleLogger); - } - - builder.AddSimpleConsole(); - return builder; - } - } -} \ No newline at end of file diff --git a/src/ConsoleAppFramework/SourceBuilder.cs b/src/ConsoleAppFramework/SourceBuilder.cs new file mode 100644 index 0000000..d62e318 --- /dev/null +++ b/src/ConsoleAppFramework/SourceBuilder.cs @@ -0,0 +1,111 @@ +using System.Text; + +namespace ConsoleAppFramework; + +// indent-level: 0. using namespace, class, struct declaration +// indent-level: 1. field, property, method declaration +// indent-level: 2. method body +internal class SourceBuilder(int level) +{ + StringBuilder builder = new StringBuilder(); + + public void Indent(int levelIncr = 1) + { + level += levelIncr; + } + + public void Unindent(int levelDecr = 1) + { + level -= levelDecr; + } + + public Scope BeginIndent() + { + Indent(); + return new Scope(this); + } + + public Scope BeginIndent(string code) + { + AppendLine(code); + Indent(); + return new Scope(this); + } + + public Block BeginBlock() + { + AppendLine("{"); + Indent(); + return new Block(this); + } + + public Block BeginBlock(string code) + { + AppendLine(code); + AppendLine("{"); + Indent(); + return new Block(this); + } + + public IDisposable Nop => NullDisposable.Instance; + + public void AppendLine() + { + builder.AppendLine(); + } + + public void AppendLineIfExists(T[] values) + { + if (values.Length != 0) + { + builder.AppendLine(); + } + } + + public void AppendLine(string text) + { + if (level != 0) + { + builder.Append(' ', level * 4); // four spaces + } + builder.AppendLine(text); + } + + public void AppendWithoutIndent(string text) + { + builder.Append(text); + } + + public void AppendLineWithoutIndent(string text) + { + builder.AppendLine(text); + } + + public override string ToString() => builder.ToString(); + + public struct Scope(SourceBuilder parent) : IDisposable + { + public void Dispose() + { + parent.Unindent(); + } + } + + public struct Block(SourceBuilder parent) : IDisposable + { + public void Dispose() + { + parent.Unindent(); + parent.AppendLine("}"); + } + } + + class NullDisposable : IDisposable + { + public static readonly IDisposable Instance = new NullDisposable(); + + public void Dispose() + { + } + } +} \ No newline at end of file diff --git a/src/ConsoleAppFramework/WellKnownTypes.cs b/src/ConsoleAppFramework/WellKnownTypes.cs new file mode 100644 index 0000000..c758e53 --- /dev/null +++ b/src/ConsoleAppFramework/WellKnownTypes.cs @@ -0,0 +1,55 @@ +using Microsoft.CodeAnalysis; + +namespace ConsoleAppFramework; + +public class WellKnownTypes(Compilation compilation) +{ + INamedTypeSymbol? dateTimeOffset; + public INamedTypeSymbol DateTimeOffset => dateTimeOffset ??= GetTypeByMetadataName("System.DateTimeOffset"); + + INamedTypeSymbol? guid; + public INamedTypeSymbol Guid => guid ??= GetTypeByMetadataName("System.Guid"); + + INamedTypeSymbol? version; + public INamedTypeSymbol Version => version ??= GetTypeByMetadataName("System.Version"); + + INamedTypeSymbol? spanParsable; + public INamedTypeSymbol? ISpanParsable => spanParsable ??= compilation.GetTypeByMetadataName("System.ISpanParsable`1"); + + INamedTypeSymbol? cancellationToken; + public INamedTypeSymbol CancellationToken => cancellationToken ??= GetTypeByMetadataName("System.Threading.CancellationToken"); + + INamedTypeSymbol? task; + public INamedTypeSymbol Task => task ??= GetTypeByMetadataName("System.Threading.Tasks.Task"); + + INamedTypeSymbol? task_T; + public INamedTypeSymbol Task_T => task_T ??= GetTypeByMetadataName("System.Threading.Tasks.Task`1"); + + INamedTypeSymbol? disposable; + public INamedTypeSymbol IDisposable => disposable ??= GetTypeByMetadataName("System.IDisposable"); + + INamedTypeSymbol? asyncDisposable; + public INamedTypeSymbol IAsyncDisposable => asyncDisposable ??= GetTypeByMetadataName("System.IAsyncDisposable"); + + public bool HasTryParse(ITypeSymbol type) + { + if (SymbolEqualityComparer.Default.Equals(type, DateTimeOffset) + || SymbolEqualityComparer.Default.Equals(type, Guid) + || SymbolEqualityComparer.Default.Equals(type, Version) + ) + { + return true; + } + return false; + } + + INamedTypeSymbol GetTypeByMetadataName(string metadataName) + { + var symbol = compilation.GetTypeByMetadataName(metadataName); + if (symbol == null) + { + throw new InvalidOperationException($"Type {metadataName} is not found in compilation."); + } + return symbol; + } +} diff --git a/src/ConsoleAppFramework/release.snk b/src/ConsoleAppFramework/release.snk deleted file mode 100644 index 79d3509..0000000 Binary files a/src/ConsoleAppFramework/release.snk and /dev/null differ diff --git a/tests/ConsoleAppFramework.GeneratorTests/ArrayPraseTest.cs b/tests/ConsoleAppFramework.GeneratorTests/ArrayPraseTest.cs new file mode 100644 index 0000000..b5d932e --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/ArrayPraseTest.cs @@ -0,0 +1,68 @@ +using Xunit.Abstractions; + +namespace ConsoleAppFramework.GeneratorTests +{ + public class ArrayPraseTest(ITestOutputHelper output) + { + VerifyHelper verifier = new VerifyHelper(output, "CAF"); + + [Fact] + public void Params() + { + var code = """ +ConsoleApp.Run(args, (params int[] foo) => +{ + Console.Write("[" + string.Join(", ", foo) + "]"); +}); +"""; + verifier.Execute(code, args: "--foo", expected: "[]"); + verifier.Execute(code, args: "--foo 10", expected: "[10]"); + verifier.Execute(code, args: "--foo 10 20 30", expected: "[10, 20, 30]"); + } + + [Fact] + public void ArgumentParams() + { + var code = """ +ConsoleApp.Run(args, ([Argument]string title, [Argument]params int[] foo) => +{ + Console.Write(title + "[" + string.Join(", ", foo) + "]"); +}); +"""; + verifier.Execute(code, args: "aiueo", expected: "aiueo[]"); + verifier.Execute(code, args: "aiueo 10", expected: "aiueo[10]"); + verifier.Execute(code, args: "aiueo 10 20 30", expected: "aiueo[10, 20, 30]"); + } + + [Fact] + public void ParseArray() + { + var code = """ +ConsoleApp.Run(args, (int[] ix, string[] sx) => +{ + Console.Write("[" + string.Join(", ", ix) + "]"); + Console.Write("[" + string.Join(", ", sx) + "]"); +}); +"""; + verifier.Execute(code, args: "--ix 1,2,3,4,5 --sx a,b,c,d,e", expected: "[1, 2, 3, 4, 5][a, b, c, d, e]"); + + var largeIntArray = string.Join(",", Enumerable.Range(0, 1000)); + var expectedIntArray = string.Join(", ", Enumerable.Range(0, 1000)); + verifier.Execute(code, args: $"--ix {largeIntArray} --sx a,b,c,d,e", expected: $"[{expectedIntArray}][a, b, c, d, e]"); + } + + [Fact] + public void JsonArray() + { + var code = """ +ConsoleApp.Run(args, (int[] ix, string[] sx) => +{ + Console.Write("[" + string.Join(", ", ix) + "]"); + Console.Write("[" + string.Join(", ", sx) + "]"); +}); +"""; + verifier.Execute(code, args: "--ix [] --sx []", expected: "[][]"); + verifier.Execute(code, args: "--ix [1,2,3,4,5] --sx [\"a\",\"b\",\"c\",\"d\",\"e\"]", expected: "[1, 2, 3, 4, 5][a, b, c, d, e]"); + } + } +} diff --git a/tests/ConsoleAppFramework.GeneratorTests/CSharpGeneratorRunner.cs b/tests/ConsoleAppFramework.GeneratorTests/CSharpGeneratorRunner.cs new file mode 100644 index 0000000..b523a11 --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/CSharpGeneratorRunner.cs @@ -0,0 +1,176 @@ +using ConsoleAppFramework; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; +using System.Runtime.CompilerServices; +using System.Runtime.Loader; +using Xunit.Abstractions; + +public static class CSharpGeneratorRunner +{ + static Compilation baseCompilation = default!; + + [ModuleInitializer] + public static void InitializeCompilation() + { + var globalUsings = """ +global using System; +global using System.Threading; +global using System.Threading.Tasks; +global using System.ComponentModel.DataAnnotations; +global using ConsoleAppFramework; +"""; + + var references = AppDomain.CurrentDomain.GetAssemblies() + .Where(x => !x.IsDynamic && !string.IsNullOrWhiteSpace(x.Location)) + .Select(x => MetadataReference.CreateFromFile(x.Location)); + + var compilation = CSharpCompilation.Create("generatortest", + references: references, + syntaxTrees: [CSharpSyntaxTree.ParseText(globalUsings)], + options: new CSharpCompilationOptions(OutputKind.ConsoleApplication)); // .exe + + baseCompilation = compilation; + } + + public static (Compilation, ImmutableArray) RunGenerator(string source, string[]? preprocessorSymbols = null, AnalyzerConfigOptionsProvider? options = null) + { + if (preprocessorSymbols == null) + { + preprocessorSymbols = new[] { "NET8_0_OR_GREATER" }; + } + var parseOptions = new CSharpParseOptions(LanguageVersion.CSharp12, preprocessorSymbols: preprocessorSymbols); // 12 + + var driver = CSharpGeneratorDriver.Create(new ConsoleAppGenerator()).WithUpdatedParseOptions(parseOptions); + if (options != null) + { + driver = (Microsoft.CodeAnalysis.CSharp.CSharpGeneratorDriver)driver.WithUpdatedAnalyzerConfigOptions(options); + } + + var compilation = baseCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(source, parseOptions)); + + driver.RunGeneratorsAndUpdateCompilation(compilation, out var newCompilation, out var diagnostics); + return (newCompilation, diagnostics); + } + + public static (Compilation, ImmutableArray, string) CompileAndExecute(string source, string[] args, string[]? preprocessorSymbols = null, AnalyzerConfigOptionsProvider? options = null) + { + var (compilation, diagnostics) = RunGenerator(source, preprocessorSymbols, options); + + using var ms = new MemoryStream(); + var emitResult = compilation.Emit(ms); + if (!emitResult.Success) + { + throw new InvalidOperationException("Emit Failed\r\n" + string.Join("\r\n", emitResult.Diagnostics.Select(x => x.ToString()))); + } + + ms.Position = 0; + + // capture stdout log + // modify global stdout so can't run in parallel unit-test + var originalOut = Console.Out; + try + { + var stringWriter = new StringWriter(); + Console.SetOut(stringWriter); + + // load and invoke Main(args) + var loadContext = new AssemblyLoadContext("source-generator", isCollectible: true); // isCollectible to support Unload + var assembly = loadContext.LoadFromStream(ms); + assembly.EntryPoint!.Invoke(null, new object[] { args }); + loadContext.Unload(); + + return (compilation, diagnostics, stringWriter.ToString()); + } + finally + { + Console.SetOut(originalOut); + } + } +} + +public class VerifyHelper(ITestOutputHelper output, string idPrefix) +{ + // Diagnostics Verify + + public void Ok(string code, [CallerArgumentExpression("code")] string? codeExpr = null) + { + output.WriteLine(codeExpr); + + var (compilation, diagnostics) = CSharpGeneratorRunner.RunGenerator(code); + foreach (var item in diagnostics) + { + output.WriteLine(item.ToString()); + } + OutputGeneratedCode(compilation); + + diagnostics.Length.Should().Be(0); + } + + public void Verify(int id, string code, string diagnosticsCodeSpan, [CallerArgumentExpression("code")] string? codeExpr = null) + { + output.WriteLine(codeExpr); + + var (compilation, diagnostics) = CSharpGeneratorRunner.RunGenerator(code); + foreach (var item in diagnostics) + { + output.WriteLine(item.ToString()); + } + OutputGeneratedCode(compilation); + + diagnostics.Length.Should().Be(1); + diagnostics[0].Id.Should().Be(idPrefix + id.ToString("000")); + var text = GetLocationText(diagnostics[0]); + text.Should().Be(diagnosticsCodeSpan); + } + + public (string, string)[] Verify(string code, [CallerArgumentExpression("code")] string? codeExpr = null) + { + output.WriteLine(codeExpr); + + var (compilation, diagnostics) = CSharpGeneratorRunner.RunGenerator(code); + OutputGeneratedCode(compilation); + return diagnostics.Select(x => (x.Id, GetLocationText(x))).ToArray(); + } + + // Execute and check stdout result + + public void Execute(string code, string args, string expected, [CallerArgumentExpression("code")] string? codeExpr = null) + { + output.WriteLine(codeExpr); + + var (compilation, diagnostics, stdout) = CSharpGeneratorRunner.CompileAndExecute(code, args == "" ? [] : args.Split(' ')); + foreach (var item in diagnostics) + { + output.WriteLine(item.ToString()); + } + OutputGeneratedCode(compilation); + + stdout.Should().Be(expected); + } + + string GetLocationText(Diagnostic diagnostic) + { + var location = diagnostic.Location; + var textSpan = location.SourceSpan; + var sourceTree = location.SourceTree; + if (sourceTree == null) + { + return ""; + } + + var text = sourceTree.GetText().GetSubText(textSpan).ToString(); + return text; + } + + void OutputGeneratedCode(Compilation compilation) + { + foreach (var syntaxTree in compilation.SyntaxTrees) + { + // only shows ConsoleApp.Run/Builder generated code + if (!syntaxTree.FilePath.Contains("g.cs")) continue; + output.WriteLine(syntaxTree.ToString()); + } + } +} \ No newline at end of file diff --git a/tests/ConsoleAppFramework.GeneratorTests/ConsoleAppBuilderTest.cs b/tests/ConsoleAppFramework.GeneratorTests/ConsoleAppBuilderTest.cs new file mode 100644 index 0000000..a6a9bf5 --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/ConsoleAppBuilderTest.cs @@ -0,0 +1,247 @@ +using Microsoft.VisualStudio.TestPlatform.Utilities; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit.Abstractions; + +namespace ConsoleAppFramework.GeneratorTests; + +public class ConsoleAppBuilderTest(ITestOutputHelper output) +{ + VerifyHelper verifier = new VerifyHelper(output, "CAF"); + + [Fact] + public void BuilderRun() + { + var code = """ +var builder = ConsoleApp.Create(); +builder.Add("foo", (int x, int y) => { Console.Write(x + y); }); +builder.Add("bar", (int x, int y = 10) => { Console.Write(x + y); }); +builder.Add("baz", int (int x, string y) => { Console.Write(x + y); return 10; }); +builder.Add("boz", async Task (int x) => { await Task.Yield(); Console.Write(x * 2); }); +builder.Run(args); +"""; + + verifier.Execute(code, "foo --x 10 --y 20", "30"); + verifier.Execute(code, "bar --x 20 --y 30", "50"); + verifier.Execute(code, "bar --x 20", "30"); + Environment.ExitCode.Should().Be(0); + verifier.Execute(code, "baz --x 40 --y takoyaki", "40takoyaki"); + Environment.ExitCode.Should().Be(10); + Environment.ExitCode = 0; + + verifier.Execute(code, "boz --x 40", "80"); + } + + [Fact] + public void BuilderRunAsync() + { + var code = """ +var builder = ConsoleApp.Create(); +builder.Add("foo", (int x, int y) => { Console.Write(x + y); }); +builder.Add("bar", (int x, int y = 10) => { Console.Write(x + y); }); +builder.Add("baz", int (int x, string y) => { Console.Write(x + y); return 10; }); +builder.Add("boz", async Task (int x) => { await Task.Yield(); Console.Write(x * 2); }); +await builder.RunAsync(args); +"""; + + verifier.Execute(code, "foo --x 10 --y 20", "30"); + verifier.Execute(code, "bar --x 20 --y 30", "50"); + verifier.Execute(code, "bar --x 20", "30"); + Environment.ExitCode.Should().Be(0); + verifier.Execute(code, "baz --x 40 --y takoyaki", "40takoyaki"); + Environment.ExitCode.Should().Be(10); + Environment.ExitCode = 0; + + verifier.Execute(code, "boz --x 40", "80"); + } + + [Fact] + public void AddClass() + { + var code = """ +var builder = ConsoleApp.Create(); +builder.Add(); +await builder.RunAsync(args); + +public class MyClass +{ + public void Do() + { + Console.Write("yeah"); + } + + public void Sum(int x, int y) + { + Console.Write(x + y); + } + + public void Echo(string msg) + { + Console.Write(msg); + } + + void Echo() + { + } + + public static void Sum() + { + } +} +"""; + + verifier.Execute(code, "do", "yeah"); + verifier.Execute(code, "sum --x 1 --y 2", "3"); + verifier.Execute(code, "echo --msg takoyaki", "takoyaki"); + } + + [Fact] + public void ClassDispose() + { + verifier.Execute(""" +var builder = ConsoleApp.Create(); +builder.Add(); +builder.Run(args); + +public class MyClass : IDisposable +{ + public void Do() + { + Console.Write("yeah:"); + } + + public void Dispose() + { + Console.Write("disposed!"); + } +} +""", "do", "yeah:disposed!"); + + verifier.Execute(""" +var builder = ConsoleApp.Create(); +builder.Add(); +await builder.RunAsync(args); + +public class MyClass : IDisposable +{ + public void Do() + { + Console.Write("yeah:"); + } + + public void Dispose() + { + Console.Write("disposed!"); + } +} +""", "do", "yeah:disposed!"); + + verifier.Execute(""" +var builder = ConsoleApp.Create(); +builder.Add(); +await builder.RunAsync(args); + +public class MyClass : IAsyncDisposable +{ + public void Do() + { + Console.Write("yeah:"); + } + + public ValueTask DisposeAsync() + { + Console.Write("disposed!"); + return default; + } +} +""", "do", "yeah:disposed!"); + + // DisposeAsync: sync pattern + verifier.Execute(""" +var builder = ConsoleApp.Create(); +builder.Add(); +builder.Run(args); + +public class MyClass : IAsyncDisposable +{ + public void Do() + { + Console.Write("yeah:"); + } + + public ValueTask DisposeAsync() + { + Console.Write("disposed!"); + return default; + } +} +""", "do", "yeah:disposed!"); + } + + [Fact] + public void ClassWithDI() + { + verifier.Execute(""" +var serviceCollection = new MiniDI(); +serviceCollection.Register(typeof(string), "hoge!"); +serviceCollection.Register(typeof(int), 9999); +ConsoleApp.ServiceProvider = serviceCollection; + +var builder = ConsoleApp.Create(); +builder.Add(); +builder.Run(args); + +public class MyClass(string foo, int bar) +{ + public void Do() + { + Console.Write("yeah:"); + Console.Write(foo); + Console.Write(bar); + } +} + +public class MiniDI : IServiceProvider +{ + System.Collections.Generic.Dictionary dict = new(); + + public void Register(Type type, object instance) + { + dict[type] = instance; + } + + public object GetService(Type serviceType) + { + return dict.TryGetValue(serviceType, out var instance) ? instance : null; + } +} +""", "do", "yeah:hoge!9999"); + } + + [Fact] + public void CommandAttr() + { + var code = """ +var builder = ConsoleApp.Create(); +builder.Add(); +builder.Run(args); + +public class MyClass() +{ + [Command("nomunomu")] + public void Do() + { + Console.Write("yeah"); + } +} +"""; + + verifier.Execute(code, "nomunomu", "yeah"); + } +} + + + diff --git a/tests/ConsoleAppFramework.GeneratorTests/ConsoleAppContextTest.cs b/tests/ConsoleAppFramework.GeneratorTests/ConsoleAppContextTest.cs new file mode 100644 index 0000000..674270c --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/ConsoleAppContextTest.cs @@ -0,0 +1,43 @@ +namespace ConsoleAppFramework.GeneratorTests; + +public class ConsoleAppContextTest(ITestOutputHelper output) +{ + VerifyHelper verifier = new VerifyHelper(output, "CAF"); + + [Fact] + public void ForLambda() + { + verifier.Execute(""" +ConsoleApp.Run(args, (ConsoleAppContext ctx) => { Console.Write(ctx.Arguments.Length); }); +""", args: "", expected: "0"); + } + + [Fact] + public void ForMethod() + { + verifier.Execute(""" +var builder = ConsoleApp.Create(); + +builder.UseFilter(); + +builder.Add("", Hello); + +builder.Run(args); + +void Hello(ConsoleAppContext ctx) +{ + Console.Write(ctx.State); +} + +internal class StateFilter(ConsoleAppFilter next) + : ConsoleAppFilter(next) +{ + public override Task InvokeAsync(ConsoleAppContext context,CancellationToken cancellationToken) + { + Console.Write(1); + return Next.InvokeAsync(context with { State = 2 }, cancellationToken); + } +} +""", args: "", expected: "12"); + } +} diff --git a/tests/ConsoleAppFramework.GeneratorTests/ConsoleAppFramework.GeneratorTests.csproj b/tests/ConsoleAppFramework.GeneratorTests/ConsoleAppFramework.GeneratorTests.csproj new file mode 100644 index 0000000..6d6be79 --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/ConsoleAppFramework.GeneratorTests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/tests/ConsoleAppFramework.GeneratorTests/DITest.cs b/tests/ConsoleAppFramework.GeneratorTests/DITest.cs new file mode 100644 index 0000000..76ab453 --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/DITest.cs @@ -0,0 +1,41 @@ +namespace ConsoleAppFramework.GeneratorTests; + +public class DITest(ITestOutputHelper output) +{ + VerifyHelper verifier = new VerifyHelper(output, "CAF"); + + [Fact] + public void ServiceProvider() + { + verifier.Execute(""" +#nullable enable + +var di = new MiniDI(); +di.Register(typeof(MyClass), new MyClass("foo")); +ConsoleApp.ServiceProvider = di; + +ConsoleApp.Run(args, ([FromServices] MyClass mc, int x, int y) => { Console.Write(mc.Name + ":" + x + ":" + y); }); + + +class MiniDI : IServiceProvider +{ + System.Collections.Generic.Dictionary dict = new(); + + public void Register(Type type, object instance) + { + dict[type] = instance; + } + + public object? GetService(Type serviceType) + { + return dict.TryGetValue(serviceType, out var instance) ? instance : null; + } +} + +class MyClass(string name) +{ + public string Name => name; +} +""", args: "--x 10 --y 20", expected: "foo:10:20"); + } +} \ No newline at end of file diff --git a/tests/ConsoleAppFramework.GeneratorTests/DiagnosticsTest.cs b/tests/ConsoleAppFramework.GeneratorTests/DiagnosticsTest.cs new file mode 100644 index 0000000..7d59683 --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/DiagnosticsTest.cs @@ -0,0 +1,437 @@ +using Xunit.Abstractions; + +namespace ConsoleAppFramework.GeneratorTests; + +public class DiagnosticsTest(ITestOutputHelper output) +{ + VerifyHelper verifier = new VerifyHelper(output, "CAF"); + + [Fact] + public void ArgumentCount() + { + verifier.Verify(1, "ConsoleApp.Run(args);", "ConsoleApp.Run(args)"); + verifier.Verify(1, "ConsoleApp.Run();", "ConsoleApp.Run()"); + verifier.Verify(1, "ConsoleApp.Run(args, (int x, int y) => { }, 1000);", "ConsoleApp.Run(args, (int x, int y) => { }, 1000)"); + } + + [Fact] + public void InvalidReturnTypeFromLambda() + { + verifier.Verify(2, "ConsoleApp.Run(args, string (int x, int y) => { return \"foo\"; })", "string"); + verifier.Verify(2, "ConsoleApp.Run(args, int? (int x, int y) => { return -1; })", "int?"); + verifier.Verify(2, "ConsoleApp.Run(args, Task (int x, int y) => { return Task.CompletedTask; })", "Task"); + verifier.Verify(2, "ConsoleApp.Run(args, Task (int x, int y) => { return Task.FromResult(0); })", "Task"); + verifier.Verify(2, "ConsoleApp.Run(args, async Task (int x, int y) => { return \"foo\"; })", "Task"); + verifier.Verify(2, "ConsoleApp.Run(args, async ValueTask (int x, int y) => { })", "ValueTask"); + verifier.Verify(2, "ConsoleApp.Run(args, async ValueTask (int x, int y) => { return -1; })", "ValueTask"); + verifier.Ok("ConsoleApp.Run(args, (int x, int y) => { })"); + verifier.Ok("ConsoleApp.Run(args, void (int x, int y) => { })"); + verifier.Ok("ConsoleApp.Run(args, int (int x, int y) => { })"); + verifier.Ok("ConsoleApp.Run(args, async Task (int x, int y) => { })"); + verifier.Ok("ConsoleApp.Run(args, async Task (int x, int y) => { })"); + } + + [Fact] + public void InvalidReturnTypeFromMethodReference() + { + verifier.Verify(3, "ConsoleApp.Run(args, Invoke); float Invoke(int x, int y) => 0.3f;", "float"); + verifier.Verify(3, "ConsoleApp.Run(args, InvokeAsync); async Task InvokeAsync(int x, int y) => 0.3f;", "Task"); + verifier.Ok("ConsoleApp.Run(args, Run); void Run(int x, int y) { };"); + verifier.Ok("ConsoleApp.Run(args, Run); static void Run(int x, int y) { };"); + verifier.Ok("ConsoleApp.Run(args, Run); int Run(int x, int y) => -1;"); + verifier.Ok("ConsoleApp.Run(args, Run); async Task Run(int x, int y) { };"); + verifier.Ok("ConsoleApp.Run(args, Run); async Task Run(int x, int y) => -1;"); + } + + [Fact] + public void RunAsyncValidation() + { + verifier.Verify(2, "ConsoleApp.RunAsync(args, string (int x, int y) => { return \"foo\"; })", "string"); + verifier.Verify(2, "ConsoleApp.RunAsync(args, int? (int x, int y) => { return -1; })", "int?"); + verifier.Verify(2, "ConsoleApp.RunAsync(args, Task (int x, int y) => { return Task.CompletedTask; })", "Task"); + verifier.Verify(2, "ConsoleApp.RunAsync(args, Task (int x, int y) => { return Task.FromResult(0); })", "Task"); + verifier.Verify(2, "ConsoleApp.RunAsync(args, async Task (int x, int y) => { return \"foo\"; })", "Task"); + verifier.Verify(2, "ConsoleApp.RunAsync(args, async ValueTask (int x, int y) => { })", "ValueTask"); + verifier.Verify(2, "ConsoleApp.RunAsync(args, async ValueTask (int x, int y) => { return -1; })", "ValueTask"); + verifier.Ok("ConsoleApp.RunAsync(args, (int x, int y) => { })"); + verifier.Ok("ConsoleApp.RunAsync(args, void (int x, int y) => { })"); + verifier.Ok("ConsoleApp.RunAsync(args, int (int x, int y) => { })"); + verifier.Ok("ConsoleApp.RunAsync(args, async Task (int x, int y) => { })"); + verifier.Ok("ConsoleApp.RunAsync(args, async Task (int x, int y) => { })"); + + verifier.Verify(3, "ConsoleApp.RunAsync(args, Invoke); float Invoke(int x, int y) => 0.3f;", "float"); + verifier.Verify(3, "ConsoleApp.RunAsync(args, InvokeAsync); async Task InvokeAsync(int x, int y) => 0.3f;", "Task"); + verifier.Ok("ConsoleApp.RunAsync(args, Run); void Run(int x, int y) { };"); + verifier.Ok("ConsoleApp.RunAsync(args, Run); static void Run(int x, int y) { };"); + verifier.Ok("ConsoleApp.RunAsync(args, Run); int Run(int x, int y) => -1;"); + verifier.Ok("ConsoleApp.RunAsync(args, Run); async Task Run(int x, int y) { };"); + verifier.Ok("ConsoleApp.RunAsync(args, Run); async Task Run(int x, int y) => -1;"); + } + + [Fact] + public void Argument() + { + verifier.Verify(4, "ConsoleApp.Run(args, (int x, [Argument]int y) => { })", "[Argument]int y"); + verifier.Verify(4, "ConsoleApp.Run(args, ([Argument]int x, int y, [Argument]int z) => { })", "[Argument]int z"); + verifier.Verify(4, "ConsoleApp.Run(args, Run); void Run(int x, [Argument]int y) { };", "[Argument]int y"); + + verifier.Ok("ConsoleApp.Run(args, ([Argument]int x, [Argument]int y) => { })"); + verifier.Ok("ConsoleApp.Run(args, Run); void Run([Argument]int x, [Argument]int y) { };"); + } + + [Fact] + public void FunctionPointerValidation() + { + verifier.Verify(5, "unsafe { ConsoleApp.Run(args, &Run2); static void Run2([Range(1, 10)]int x, int y) { }; }", "[Range(1, 10)]int x"); + + verifier.Ok("unsafe { ConsoleApp.Run(args, &Run2); static void Run2(int x, int y) { }; }"); + } + + [Fact] + public void BuilderAddConstCommandName() + { + verifier.Verify(6, """ +var builder = ConsoleApp.Create(); +var baz = "foo"; +builder.Add(baz, (int x, int y) => { } ); +""", "baz"); + + verifier.Ok(""" +var builder = ConsoleApp.Create(); +builder.Add("foo", (int x, int y) => { } ); +builder.Run(args); +"""); + } + + [Fact] + public void DuplicateCommandName() + { + verifier.Verify(7, """ +var builder = ConsoleApp.Create(); +builder.Add("foo", (int x, int y) => { } ); +builder.Add("foo", (int x, int y) => { } ); +""", "\"foo\""); + } + + [Fact] + public void DuplicateCommandNameClass() + { + verifier.Verify(7, """ +var builder = ConsoleApp.Create(); +builder.Add(); + +public class MyClass +{ + public void Do() + { + Console.Write("yeah:"); + } + + public void Do(int i) + { + Console.Write("yeah:"); + } +} +""", "builder.Add()"); + + verifier.Verify(7, """ +var builder = ConsoleApp.Create(); +builder.Add("do", (int x, int y) => { } ); +builder.Add(); +builder.Run(args); + +public class MyClass +{ + public void Do() + { + Console.Write("yeah:"); + } +} +""", "builder.Add()"); + } + + [Fact] + public void AddInLoop() + { + var myClass = """ +public class MyClass +{ + public void Do() + { + Console.Write("yeah:"); + } +} +"""; + verifier.Verify(8, $$""" +var builder = ConsoleApp.Create(); +while (true) +{ + builder.Add(); +} + +{{myClass}} +""", "builder.Add()"); + + verifier.Verify(8, $$""" +var builder = ConsoleApp.Create(); +for (int i = 0; i < 10; i++) +{ + builder.Add(); +} + +{{myClass}} +""", "builder.Add()"); + + verifier.Verify(8, $$""" +var builder = ConsoleApp.Create(); +do +{ + builder.Add(); +} while(true); + +{{myClass}} +""", "builder.Add()"); + + verifier.Verify(8, $$""" +var builder = ConsoleApp.Create(); +foreach (var item in new[]{1,2,3}) +{ + builder.Add(); +} + +{{myClass}} +""", "builder.Add()"); + } + + [Fact] + public void ErrorInBuilderAPI() + { + verifier.Verify(3, $$""" +var builder = ConsoleApp.Create(); +builder.Add(); + +public class MyClass +{ + public string Do() + { + Console.Write("yeah:"); + return "foo"; + } +} +""", "string"); + + verifier.Verify(3, $$""" +var builder = ConsoleApp.Create(); +builder.Add(); + +public class MyClass +{ + public async Task Do() + { + Console.Write("yeah:"); + return "foo"; + } +} +""", "Task"); + + verifier.Verify(2, $$""" +var builder = ConsoleApp.Create(); +builder.Add("foo", string (int x, int y) => { return "foo"; }); +""", "string"); + + verifier.Verify(2, $$""" +var builder = ConsoleApp.Create(); +builder.Add("foo", async Task (int x, int y) => { return "foo"; }); +""", "Task"); + } + + + + [Fact] + public void RunAndFilter() + { + verifier.Verify(9, """ +ConsoleApp.Run(args, Hello); + +[ConsoleAppFilter] +void Hello() +{ +} + +public class NopFilter(ConsoleAppFilter next) + : ConsoleAppFilter(next) +{ + public override Task InvokeAsync(CancellationToken cancellationToken) + { + return Next.InvokeAsync(cancellationToken); + } +} +""", "ConsoleApp.Run(args, Hello)"); + } + + [Fact] + public void MultiConstructorFilter() + { + verifier.Verify(10, """ +var app = ConsoleApp.Create(); +app.UseFilter(); +app.Add("", Hello); +app.Run(args); + +void Hello() +{ +} + +internal class NopFilter : ConsoleAppFilter +{ + public NopFilter(ConsoleAppFilter next) + :base(next) + { + } + + public NopFilter(string x, ConsoleAppFilter next) + :base(next) + { + } + + public override Task InvokeAsync(CancellationToken cancellationToken) + { + return Next.InvokeAsync(cancellationToken); + } +} +""", "NopFilter"); + + verifier.Verify(10, """ +var app = ConsoleApp.Create(); +app.Add(); +app.Run(args); + +[ConsoleAppFilter] +public class Foo +{ + public void Hello() + { + } +} + +internal class NopFilter : ConsoleAppFilter +{ + public NopFilter(ConsoleAppFilter next) + :base(next) + { + } + + public NopFilter(string x, ConsoleAppFilter next) + :base(next) + { + } + + public override Task InvokeAsync(CancellationToken cancellationToken) + { + return Next.InvokeAsync(cancellationToken); + } +} +""", "ConsoleAppFilter"); + + verifier.Verify(10, """ +var app = ConsoleApp.Create(); +app.Add(); +app.Run(args); + +public class Foo +{ + [ConsoleAppFilter] + public void Hello() + { + } +} + +internal class NopFilter : ConsoleAppFilter +{ + public NopFilter(ConsoleAppFilter next) + :base(next) + { + } + + public NopFilter(string x, ConsoleAppFilter next) + :base(next) + { + } + + public override Task InvokeAsync(CancellationToken cancellationToken) + { + return Next.InvokeAsync(cancellationToken); + } +} +""", "ConsoleAppFilter"); + } + + + [Fact] + public void MultipleCtorClass() + { + verifier.Verify(11, """ +var app = ConsoleApp.Create(); +app.Add(); +app.Run(args); + +public class Foo +{ + public Foo() { } + public Foo(int x) { } + + public void Hello() + { + } +} +""", "app.Add()"); + } + + [Fact] + public void PublicMethods() + { + verifier.Verify(12, """ +var app = ConsoleApp.Create(); +app.Add(); +app.Run(args); + +public class Foo +{ + public Foo() { } + public Foo(int x) { } + + private void Hello() + { + } +} +""", "app.Add()"); + } + + [Fact] + public void AbstractNotAllow() + { + verifier.Verify(13, """ +var app = ConsoleApp.Create(); +app.Add(); +app.Run(args); + +public abstract class Foo +{ + public void Hello() + { + } +} +""", "app.Add()"); + + verifier.Verify(13, """ +var app = ConsoleApp.Create(); +app.Add(); +app.Run(args); + +public interface IFoo +{ + void Hello(); +} +""", "app.Add()"); + } +} diff --git a/tests/ConsoleAppFramework.GeneratorTests/FilterTest.cs b/tests/ConsoleAppFramework.GeneratorTests/FilterTest.cs new file mode 100644 index 0000000..46cbcfa --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/FilterTest.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit.Abstractions; + +namespace ConsoleAppFramework.GeneratorTests; + +public class FilterTest(ITestOutputHelper output) +{ + VerifyHelper verifier = new VerifyHelper(output, "CAF"); + + [Fact] + public void ForLambda() + { + verifier.Execute(""" +var builder = ConsoleApp.Create(); + +builder.UseFilter(); +builder.UseFilter(); + +builder.Add("", Hello); + +builder.Run(args); + +[ConsoleAppFilter] +[ConsoleAppFilter] +void Hello() +{ + Console.Write("abcde"); +} + +internal class NopFilter1(ConsoleAppFilter next) + : ConsoleAppFilter(next) +{ + public override Task InvokeAsync(ConsoleAppContext context,CancellationToken cancellationToken) + { + Console.Write(1); + return Next.InvokeAsync(context, cancellationToken); + } +} + +internal class NopFilter2(ConsoleAppFilter next) + : ConsoleAppFilter(next) +{ + public override Task InvokeAsync(ConsoleAppContext context,CancellationToken cancellationToken) + { + Console.Write(2); + return Next.InvokeAsync(context, cancellationToken); + } +} + +internal class NopFilter3(ConsoleAppFilter next) + : ConsoleAppFilter(next) +{ + public override Task InvokeAsync(ConsoleAppContext context,CancellationToken cancellationToken) + { + Console.Write(3); + return Next.InvokeAsync(context, cancellationToken); + } +} + +internal class NopFilter4(ConsoleAppFilter next) + : ConsoleAppFilter(next) +{ + public override Task InvokeAsync(ConsoleAppContext context,CancellationToken cancellationToken) + { + Console.Write(4); + return Next.InvokeAsync(context, cancellationToken); + } +} +""", args: "", expected: "1234abcde"); + } + + [Fact] + public void ForClass() + { + verifier.Execute(""" +var builder = ConsoleApp.Create(); + +builder.UseFilter(); +builder.UseFilter(); + +builder.Add(); + +await builder.RunAsync(args); + +[ConsoleAppFilter] +[ConsoleAppFilter] +public class MyClass +{ + [ConsoleAppFilter] + [ConsoleAppFilter] + public void Hello() + { + Console.Write("abcde"); + } +} + +internal class NopFilter1(ConsoleAppFilter next) + : ConsoleAppFilter(next) +{ + public override Task InvokeAsync(ConsoleAppContext context,CancellationToken cancellationToken) + { + Console.Write(1); + return Next.InvokeAsync(context, cancellationToken); + } +} + +internal class NopFilter2(ConsoleAppFilter next) + : ConsoleAppFilter(next) +{ + public override Task InvokeAsync(ConsoleAppContext context,CancellationToken cancellationToken) + { + Console.Write(2); + return Next.InvokeAsync(context, cancellationToken); + } +} + +internal class NopFilter3(ConsoleAppFilter next) + : ConsoleAppFilter(next) +{ + public override Task InvokeAsync(ConsoleAppContext context,CancellationToken cancellationToken) + { + Console.Write(3); + return Next.InvokeAsync(context, cancellationToken); + } +} + +internal class NopFilter4(ConsoleAppFilter next) + : ConsoleAppFilter(next) +{ + public override Task InvokeAsync(ConsoleAppContext context,CancellationToken cancellationToken) + { + Console.Write(4); + return Next.InvokeAsync(context, cancellationToken); + } +} + +internal class NopFilter5(ConsoleAppFilter next) + : ConsoleAppFilter(next) +{ + public override Task InvokeAsync(ConsoleAppContext context,CancellationToken cancellationToken) + { + Console.Write(5); + return Next.InvokeAsync(context, cancellationToken); + } +} + +internal class NopFilter6(ConsoleAppFilter next) + : ConsoleAppFilter(next) +{ + public override Task InvokeAsync(ConsoleAppContext context,CancellationToken cancellationToken) + { + Console.Write(6); + return Next.InvokeAsync(context, cancellationToken); + } +} +""", args: "hello", expected: "123456abcde"); + } + + + [Fact] + public void DI() + { + verifier.Execute(""" +var serviceCollection = new MiniDI(); +serviceCollection.Register(typeof(string), "hoge!"); +serviceCollection.Register(typeof(int), 9999); +ConsoleApp.ServiceProvider = serviceCollection; + +var builder = ConsoleApp.Create(); + +builder.UseFilter(); + +builder.Add("", () => Console.Write("do")); + +builder.Run(args); + +internal class DIFilter(string foo, int bar, ConsoleAppFilter next) + : ConsoleAppFilter(next) +{ + public override Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) + { + Console.Write("invoke:"); + Console.Write(foo); + Console.Write(bar); + return Next.InvokeAsync(context, cancellationToken); + } +} + +public class MiniDI : IServiceProvider +{ + System.Collections.Generic.Dictionary dict = new(); + + public void Register(Type type, object instance) + { + dict[type] = instance; + } + + public object? GetService(Type serviceType) + { + return dict.TryGetValue(serviceType, out var instance) ? instance : null; + } +} +""", args: "", expected: "invoke:hoge!9999do"); + } +} diff --git a/tests/ConsoleAppFramework.GeneratorTests/GlobalUsings.cs b/tests/ConsoleAppFramework.GeneratorTests/GlobalUsings.cs new file mode 100644 index 0000000..9cb7279 --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/GlobalUsings.cs @@ -0,0 +1,7 @@ +global using Xunit; +global using Xunit.Abstractions; +global using FluentAssertions; + +// CSharpGeneratorRunner.CompileAndExecute uses stdout hook(replace Console.Out) +// so can not work in parallel test +[assembly: CollectionBehavior(DisableTestParallelization = true)] \ No newline at end of file diff --git a/tests/ConsoleAppFramework.GeneratorTests/HelpTest.cs b/tests/ConsoleAppFramework.GeneratorTests/HelpTest.cs new file mode 100644 index 0000000..55fe21f --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/HelpTest.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit.Abstractions; + +namespace ConsoleAppFramework.GeneratorTests; + +public class HelpTest(ITestOutputHelper output) +{ + VerifyHelper verifier = new VerifyHelper(output, "CAF"); + + [Fact] + public void Run() + { + verifier.Execute(code: """ +ConsoleApp.Run(args, (int x, int y) => { }); +""", + args: "--help", + expected: """ +Usage: [options...] [-h|--help] [--version] + +Options: + --x (Required) + --y (Required) + +"""); + } + + [Fact] + public void RunVoid() + { + verifier.Execute(code: """ +ConsoleApp.Run(args, () => { }); +""", + args: "--help", + expected: """ +Usage: [-h|--help] [--version] + +"""); + } + + [Fact] + public void RootOnly() + { + verifier.Execute(code: """ +var app = ConsoleApp.Create(); +app.Add("", (int x, int y) => { }); +app.Run(args); +""", + args: "--help", + expected: """ +Usage: [options...] [-h|--help] [--version] + +Options: + --x (Required) + --y (Required) + +"""); + } + + [Fact] + public void ListWithoutRoot() + { + var code = """ +var app = ConsoleApp.Create(); +app.Add("a", (int x, int y) => { }); +app.Add("ab", (int x, int y) => { }); +app.Add("a b c", (int x, int y) => { }); +app.Run(args); +"""; + verifier.Execute(code, args: "--help", expected: """ +Usage: [command] [-h|--help] [--version] + +Commands: + a + a b c + ab + +"""); + } + + [Fact] + public void ListWithRoot() + { + var code = """ +var app = ConsoleApp.Create(); +app.Add("", (int x, int y) => { }); +app.Add("a", (int x, int y) => { }); +app.Add("ab", (int x, int y) => { }); +app.Add("a b c", (int x, int y) => { }); +app.Run(args); +"""; + verifier.Execute(code, args: "--help", expected: """ +Usage: [command] [options...] [-h|--help] [--version] + +Options: + --x (Required) + --y (Required) + +Commands: + a + a b c + ab + +"""); + } + + [Fact] + public void SelectLeafHelp() + { + var code = """ +var app = ConsoleApp.Create(); +app.Add("", (int x, int y) => { }); +app.Add("a", (int x, int y) => { }); +app.Add("ab", (int x, int y) => { }); +app.Add("a b c", (int x, int y) => { }); +app.Run(args); +"""; + verifier.Execute(code, args: "a b c --help", expected: """ +Usage: a b c [options...] [-h|--help] [--version] + +Options: + --x (Required) + --y (Required) + +"""); + } + + [Fact] + public void Summary() + { + var code = """ +var app = ConsoleApp.Create(); +app.Add(); +app.Run(args); + +public class MyClass +{ + /// + /// hello my world. + /// + /// -f|-fb, my foo is not bar. + public void HelloWorld(string fooBar) + { + Console.Write("Hello World! " + fooBar); + } +} +"""; + verifier.Execute(code, args: "--help", expected: """ +Usage: [command] [-h|--help] [--version] + +Commands: + hello-world hello my world. + +"""); + + verifier.Execute(code, args: "hello-world --help", expected: """ +Usage: hello-world [options...] [-h|--help] [--version] + +hello my world. + +Options: + -f|-fb|--foo-bar my foo is not bar. (Required) + +"""); + } + + [Fact] + public void ArgumentOnly() + { + verifier.Execute(code: """ +ConsoleApp.Run(args, ([Argument]int x, [Argument]int y) => { }); +""", + args: "--help", + expected: """ +Usage: [arguments...] [-h|--help] [--version] + +Arguments: + [0] + [1] + +"""); + } + + [Fact] + public void ArgumentWithParams() + { + verifier.Execute(code: """ +ConsoleApp.Run(args, ([Argument]int x, [Argument]int y, params string[] yyy) => { }); +""", + args: "--help", + expected: """ +Usage: [arguments...] [options...] [-h|--help] [--version] + +Arguments: + [0] + [1] + +Options: + --yyy ... + +"""); + } + + // Params + + [Fact] + public void Nullable() + { + verifier.Execute(code: """ +ConsoleApp.Run(args, (int? x = null, string? y = null) => { }); +""", + args: "--help", + expected: """ +Usage: [options...] [-h|--help] [--version] + +Options: + --x (Default: null) + --y (Default: null) + +"""); + } + + [Fact] + public void EnumTest() + { + verifier.Execute(code: """ +ConsoleApp.Run(args, (Fruit myFruit = Fruit.Apple, Fruit? moreFruit = null) => { }); + +enum Fruit +{ + Orange, Grape, Apple +} +""", + args: "--help", + expected: """ +Usage: [options...] [-h|--help] [--version] + +Options: + --my-fruit (Default: Apple) + --more-fruit (Default: null) + +"""); + } + + [Fact] + public void Summary2() + { + var code = """ +var app = ConsoleApp.Create(); +app.Add(); +app.Run(args); + +public class MyClass +{ + /// + /// hello my world. + /// + /// -b, my boo is not boo. + /// -f|-fb, my foo is not bar. + public void HelloWorld([Argument]int boo, string fooBar) + { + Console.Write("Hello World! " + fooBar); + } +} +"""; + verifier.Execute(code, args: "hello-world --help", expected: """ +Usage: hello-world [arguments...] [options...] [-h|--help] [--version] + +hello my world. + +Arguments: + [0] my boo is not boo. + +Options: + -f|-fb|--foo-bar my foo is not bar. (Required) + +"""); + } +} diff --git a/tests/ConsoleAppFramework.GeneratorTests/NameConverterTest.cs b/tests/ConsoleAppFramework.GeneratorTests/NameConverterTest.cs new file mode 100644 index 0000000..b388f9f --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/NameConverterTest.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit.Abstractions; + +namespace ConsoleAppFramework.GeneratorTests; + +public class NameConverterTest(ITestOutputHelper output) +{ + VerifyHelper verifier = new VerifyHelper(output, "CAF"); + + [Fact] + public void KebabCase() + { + NameConverter.ToKebabCase("").Should().Be(""); + NameConverter.ToKebabCase("HelloWorld").Should().Be("hello-world"); + NameConverter.ToKebabCase("HelloWorldMyHome").Should().Be("hello-world-my-home"); + NameConverter.ToKebabCase("helloWorld").Should().Be("hello-world"); + NameConverter.ToKebabCase("hello-world").Should().Be("hello-world"); + NameConverter.ToKebabCase("A").Should().Be("a"); + NameConverter.ToKebabCase("AB").Should().Be("ab"); + NameConverter.ToKebabCase("ABC").Should().Be("abc"); + NameConverter.ToKebabCase("ABCD").Should().Be("abcd"); + NameConverter.ToKebabCase("ABCDeF").Should().Be("abc-def"); + NameConverter.ToKebabCase("XmlReader").Should().Be("xml-reader"); + NameConverter.ToKebabCase("XMLReader").Should().Be("xml-reader"); + NameConverter.ToKebabCase("MLLibrary").Should().Be("ml-library"); + } + + [Fact] + public void CommmandName() + { + verifier.Execute(""" +var builder = ConsoleApp.Create(); +builder.Add(); +builder.Run(args); + +public class MyClass +{ + public void HelloWorld() + { + Console.Write("Hello World!"); + } +} +""", args: "hello-world", expected: "Hello World!"); + } + + [Fact] + public void OptionName() + { + verifier.Execute(""" +var builder = ConsoleApp.Create(); +builder.Add(); +builder.Run(args); + +public class MyClass +{ + public void HelloWorld(string fooBar) + { + Console.Write("Hello World! " + fooBar); + } +} +""", args: "hello-world --foo-bar aiueo", expected: "Hello World! aiueo"); + + + verifier.Execute(""" +var builder = ConsoleApp.Create(); +var mc = new MyClass(); +builder.Add("hello-world", mc.HelloWorld); +builder.Run(args); + +public class MyClass +{ + public void HelloWorld(string fooBar) + { + Console.Write("Hello World! " + fooBar); + } +} +""", args: "hello-world --foo-bar aiueo", expected: "Hello World! aiueo"); + } +} diff --git a/tests/ConsoleAppFramework.GeneratorTests/RunTest.cs b/tests/ConsoleAppFramework.GeneratorTests/RunTest.cs new file mode 100644 index 0000000..53860ac --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/RunTest.cs @@ -0,0 +1,86 @@ +using FluentAssertions; +using Microsoft.CodeAnalysis; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit.Abstractions; + +namespace ConsoleAppFramework.GeneratorTests; + +public class Test(ITestOutputHelper output) +{ + VerifyHelper verifier = new VerifyHelper(output, "CAF"); + + [Fact] + public void SyncRun() + { + verifier.Execute("ConsoleApp.Run(args, (int x, int y) => { Console.Write((x + y)); });", "--x 10 --y 20", "30"); + } + + [Fact] + public void ValidateOne() + { + var expected = """ +The field x must be between 1 and 10. + + +"""; + + verifier.Execute(""" +ConsoleApp.Run(args, ([Range(1, 10)]int x, [Range(100, 200)]int y) => { Console.Write((x + y)); }); +""", "--x 100 --y 140", expected); + + Environment.ExitCode.Should().Be(1); + Environment.ExitCode = 0; + } + + [Fact] + public void ValidateTwo() + { + var expected = """ +The field x must be between 1 and 10. +The field y must be between 100 and 200. + + +"""; + + verifier.Execute(""" +ConsoleApp.Run(args, ([Range(1, 10)]int x, [Range(100, 200)]int y) => { Console.Write((x + y)); }); +""", "--x 100 --y 240", expected); + + Environment.ExitCode.Should().Be(1); + Environment.ExitCode = 0; + } + [Fact] + public void Parameters() + { + verifier.Execute(""" +ConsoleApp.Run(args, (int foo, string bar, Fruit ft, bool flag, Half half, int? itt, Takoyaki.Obj obj) => +{ + Console.Write(foo); + Console.Write(bar); + Console.Write(ft); + Console.Write(flag); + Console.Write(half); + Console.Write(itt); + Console.Write(obj.Foo); +}); + +enum Fruit +{ + Orange, Grape, Apple +} + +namespace Takoyaki +{ + public class Obj + { + public int Foo { get; set; } + } +} +""", "--foo 10 --bar aiueo --ft Grape --flag --half 1.3 --itt 99 --obj {\"Foo\":1999}", "10aiueoGrapeTrue1.3991999"); + } +} \ No newline at end of file diff --git a/tests/ConsoleAppFramework.GeneratorTests/SubCommandTest.cs b/tests/ConsoleAppFramework.GeneratorTests/SubCommandTest.cs new file mode 100644 index 0000000..13acb27 --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/SubCommandTest.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit.Abstractions; + +namespace ConsoleAppFramework.GeneratorTests; + +public class SubCommandTest(ITestOutputHelper output) +{ + VerifyHelper verifier = new VerifyHelper(output, "CAF"); + + [Fact] + public void Zeroargs() + { + var code = """ +var builder = ConsoleApp.Create(); + +builder.Add("", () => { Console.Write("root"); }); +builder.Add("a", () => { Console.Write("a"); }); +builder.Add("a b1", () => { Console.Write("a b1"); }); +builder.Add("a b2", () => { Console.Write("a b2"); }); +builder.Add("a b2 c", () => { Console.Write("a b2 c"); }); +builder.Add("a b2 d", () => { Console.Write("a b2 d"); }); +builder.Add("a b2 d e", () => { Console.Write("a b2 d e"); }); +builder.Add("a b c d e f", () => { Console.Write("a b c d e f"); }); + +builder.Run(args); +"""; + + verifier.Execute(code, "", "root"); + verifier.Execute(code, "a", "a"); + verifier.Execute(code, "a b1", "a b1"); + verifier.Execute(code, "a b2", "a b2"); + verifier.Execute(code, "a b2 c", "a b2 c"); + verifier.Execute(code, "a b2 d", "a b2 d"); + verifier.Execute(code, "a b2 d e", "a b2 d e"); + verifier.Execute(code, "a b c d e f", "a b c d e f"); + } + + [Fact] + public void Withargs() + { + var code = """ +var builder = ConsoleApp.Create(); + +builder.Add("", (int x, int y) => { Console.Write($"root {x} {y}"); }); +builder.Add("a", (int x, int y) => { Console.Write($"a {x} {y}"); }); +builder.Add("a b1", (int x, int y) => { Console.Write($"a b1 {x} {y}"); }); +builder.Add("a b2", (int x, int y) => { Console.Write($"a b2 {x} {y}"); }); +builder.Add("a b2 c", (int x, int y) => { Console.Write($"a b2 c {x} {y}"); }); +builder.Add("a b2 d", (int x, int y) => { Console.Write($"a b2 d {x} {y}"); }); +builder.Add("a b2 d e", (int x, int y) => { Console.Write($"a b2 d e {x} {y}"); }); +builder.Add("a b c d e f", (int x, int y) => { Console.Write($"a b c d e f {x} {y}"); }); + +builder.Run(args); +"""; + + verifier.Execute(code, "--x 10 --y 20", "root 10 20"); + verifier.Execute(code, "a --x 10 --y 20", "a 10 20"); + verifier.Execute(code, "a b1 --x 10 --y 20", "a b1 10 20"); + verifier.Execute(code, "a b2 --x 10 --y 20", "a b2 10 20"); + verifier.Execute(code, "a b2 c --x 10 --y 20", "a b2 c 10 20"); + verifier.Execute(code, "a b2 d --x 10 --y 20", "a b2 d 10 20"); + verifier.Execute(code, "a b2 d e --x 10 --y 20", "a b2 d e 10 20"); + verifier.Execute(code, "a b c d e f --x 10 --y 20", "a b c d e f 10 20"); + } + + [Fact] + public void ZeroargsAsync() + { + var code = """ +var builder = ConsoleApp.Create(); + +builder.Add("", () => { Console.Write("root"); }); +builder.Add("a", () => { Console.Write("a"); }); +builder.Add("a b1", () => { Console.Write("a b1"); }); +builder.Add("a b2", () => { Console.Write("a b2"); }); +builder.Add("a b2 c", () => { Console.Write("a b2 c"); }); +builder.Add("a b2 d", () => { Console.Write("a b2 d"); }); +builder.Add("a b2 d e", () => { Console.Write("a b2 d e"); }); +builder.Add("a b c d e f", () => { Console.Write("a b c d e f"); }); + +await builder.RunAsync(args); +"""; + + verifier.Execute(code, "", "root"); + verifier.Execute(code, "a", "a"); + verifier.Execute(code, "a b1", "a b1"); + verifier.Execute(code, "a b2", "a b2"); + verifier.Execute(code, "a b2 c", "a b2 c"); + verifier.Execute(code, "a b2 d", "a b2 d"); + verifier.Execute(code, "a b2 d e", "a b2 d e"); + verifier.Execute(code, "a b c d e f", "a b c d e f"); + } + + [Fact] + public void WithargsAsync() + { + var code = """ +var builder = ConsoleApp.Create(); + +builder.Add("", (int x, int y) => { Console.Write($"root {x} {y}"); }); +builder.Add("a", (int x, int y) => { Console.Write($"a {x} {y}"); }); +builder.Add("a b1", (int x, int y) => { Console.Write($"a b1 {x} {y}"); }); +builder.Add("a b2", (int x, int y) => { Console.Write($"a b2 {x} {y}"); }); +builder.Add("a b2 c", (int x, int y) => { Console.Write($"a b2 c {x} {y}"); }); +builder.Add("a b2 d", (int x, int y) => { Console.Write($"a b2 d {x} {y}"); }); +builder.Add("a b2 d e", (int x, int y) => { Console.Write($"a b2 d e {x} {y}"); }); +builder.Add("a b c d e f", (int x, int y) => { Console.Write($"a b c d e f {x} {y}"); }); + +await builder.RunAsync(args); +"""; + + verifier.Execute(code, "--x 10 --y 20", "root 10 20"); + verifier.Execute(code, "a --x 10 --y 20", "a 10 20"); + verifier.Execute(code, "a b1 --x 10 --y 20", "a b1 10 20"); + verifier.Execute(code, "a b2 --x 10 --y 20", "a b2 10 20"); + verifier.Execute(code, "a b2 c --x 10 --y 20", "a b2 c 10 20"); + verifier.Execute(code, "a b2 d --x 10 --y 20", "a b2 d 10 20"); + verifier.Execute(code, "a b2 d e --x 10 --y 20", "a b2 d e 10 20"); + verifier.Execute(code, "a b c d e f --x 10 --y 20", "a b c d e f 10 20"); + } +} diff --git a/tests/ConsoleAppFramework.Tests/AssemblyInfo.cs b/tests/ConsoleAppFramework.Tests/AssemblyInfo.cs deleted file mode 100644 index 7db8497..0000000 --- a/tests/ConsoleAppFramework.Tests/AssemblyInfo.cs +++ /dev/null @@ -1,3 +0,0 @@ -using Xunit; - -[assembly: CollectionBehavior(DisableTestParallelization = true)] \ No newline at end of file diff --git a/tests/ConsoleAppFramework.Tests/ConsoleAppFramework.Tests.csproj b/tests/ConsoleAppFramework.Tests/ConsoleAppFramework.Tests.csproj deleted file mode 100644 index 42e1567..0000000 --- a/tests/ConsoleAppFramework.Tests/ConsoleAppFramework.Tests.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - - net6.0 - false - true - ..\..\src\ConsoleAppFramework\release.snk - - - - - - - - - - - - - - - - - - - - diff --git a/tests/ConsoleAppFramework.Tests/Integration/CaptureConsoleOutput.cs b/tests/ConsoleAppFramework.Tests/Integration/CaptureConsoleOutput.cs deleted file mode 100644 index 3816e8a..0000000 --- a/tests/ConsoleAppFramework.Tests/Integration/CaptureConsoleOutput.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using System.IO; - -namespace ConsoleAppFramework.Integration.Test -{ - public class CaptureConsoleOutput : IDisposable - { - private readonly TextWriter _originalWriter; - private readonly StringWriter _stringWriter; - - public CaptureConsoleOutput() - { - _originalWriter = Console.Out; - _stringWriter = new StringWriter(); - Console.SetOut(_stringWriter); - } - - public string Output => _stringWriter.ToString(); - - public void Dispose() - { - Console.SetOut(_originalWriter); - } - } -} \ No newline at end of file diff --git a/tests/ConsoleAppFramework.Tests/Integration/CommandFilterTest.cs b/tests/ConsoleAppFramework.Tests/Integration/CommandFilterTest.cs deleted file mode 100644 index faa8105..0000000 --- a/tests/ConsoleAppFramework.Tests/Integration/CommandFilterTest.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.Threading.Tasks; -using FluentAssertions; -using Microsoft.Extensions.Hosting; -using Xunit; -// ReSharper disable UnusedMember.Local -// ReSharper disable ClassNeverInstantiated.Local - -namespace ConsoleAppFramework.Integration.Test; - -public class FilterTest -{ - [Fact] - public void ApplyAttributeFilterTest() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "test-argument-name" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("[in filter] before"); - console.Output.Should().Contain(args[0]); - console.Output.Should().Contain("[in filter] after"); - } - - /// - private class TestConsoleApp : ConsoleAppBase - { - [RootCommand] - [ConsoleAppFilter(typeof(TestFilter))] - public void RootCommand([Option(index: 0)]string someArgument) => Console.WriteLine(someArgument); - } - - /// - private class TestFilter : ConsoleAppFilter - { - /// - public override async ValueTask Invoke(ConsoleAppContext context, Func next) - { - Console.WriteLine("[in filter] before"); - await next(context); - Console.WriteLine("[in filter] after"); - } - } -} \ No newline at end of file diff --git a/tests/ConsoleAppFramework.Tests/Integration/HelpUsageTest.cs b/tests/ConsoleAppFramework.Tests/Integration/HelpUsageTest.cs deleted file mode 100644 index 773af0b..0000000 --- a/tests/ConsoleAppFramework.Tests/Integration/HelpUsageTest.cs +++ /dev/null @@ -1,37 +0,0 @@ -using ConsoleAppFramework.Integration.Test; -using FluentAssertions; -using Microsoft.Extensions.Hosting; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Xunit; - -namespace ConsoleAppFramework.Tests.Integration -{ - public class HelpUsageTest - { - [Fact] - public async Task ConfigureApplicationName() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { }; - await Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args, new ConsoleAppOptions - { - ApplicationName = "foo" - }); - - var output = console.Output; - - output.Should().Contain("Usage: foo"); - } - - - public class Runner : ConsoleAppBase - { - [Command("hello")] - public void Hello() => Console.WriteLine("Hello"); - } - } -} diff --git a/tests/ConsoleAppFramework.Tests/Integration/InterceptorTest.cs b/tests/ConsoleAppFramework.Tests/Integration/InterceptorTest.cs deleted file mode 100644 index e2d1d3a..0000000 --- a/tests/ConsoleAppFramework.Tests/Integration/InterceptorTest.cs +++ /dev/null @@ -1,105 +0,0 @@ -//using System; -//using System.Collections.Generic; -//using System.Threading.Tasks; -//using FluentAssertions; -//using Microsoft.Extensions.Hosting; -//using Microsoft.Extensions.Logging; -//using Xunit; - -//// ReSharper disable InconsistentNaming - -//namespace ConsoleAppFramework.Integration.Test -//{ -// public partial class InterceptorTest -// { -// [Fact] -// public void Single() -// { -// using var console = new CaptureConsoleOutput(); -// var args = new[] { "Cysharp" }; -// var interceptor = new TestInterceptor(); -// Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args, interceptor); - -// interceptor.Outputs.Should().Equal("OnEngineBeginAsync", "OnMethodBeginAsync", "OnMethodEndAsync", "OnEngineCompleteAsync"); -// console.Output.Should().Contain("Hello Cysharp"); -// } - -// //[Fact] -// //public void Single_Insufficient_Arguments() -// //{ -// // using var console = new CaptureConsoleOutput(); -// // var args = new string[] { }; -// // var interceptor = new TestInterceptor(); -// // Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args, interceptor); - -// // interceptor.Outputs.Should().Equal("OnEngineBeginAsync", "OnMethodBeginAsync", "OnMethodEndAsync", "OnEngineCompleteAsync"); -// // console.Output.Should().Contain("Usage"); -// //} - -// [Fact] -// public void Multi() -// { -// using var console = new CaptureConsoleOutput(); -// var args = new[] { "hello", "Cysharp" }; -// var interceptor = new TestInterceptor(); -// Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args, interceptor); - -// interceptor.Outputs.Should().Equal("OnEngineBeginAsync", "OnMethodBeginAsync", "OnMethodEndAsync", "OnEngineCompleteAsync"); -// console.Output.Should().Contain("Hello Cysharp"); -// } - -// [Fact] -// public void Multi_Insufficient_Arguments() -// { -// using var console = new CaptureConsoleOutput(); -// var args = new[] { "Cysharp" }; -// var interceptor = new TestInterceptor(); -// Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args, interceptor); - -// interceptor.Outputs.Should().Equal("OnEngineBeginAsync", "OnMethodBeginAsync", "OnMethodEndAsync", "OnEngineCompleteAsync"); -// console.Output.Should().Contain("Usage"); -// } - -// public class TestInterceptor : IConsoleAppInterceptor -// { -// public List Outputs { get; } = new List(); - -// public ValueTask OnEngineBeginAsync(IServiceProvider serviceProvider, ILogger logger) -// { -// Outputs.Add("OnEngineBeginAsync"); -// return default; -// } - -// public ValueTask OnMethodBeginAsync(ConsoleAppContext context) -// { -// Outputs.Add("OnMethodBeginAsync"); -// return default; -// } - -// public ValueTask OnMethodEndAsync(ConsoleAppContext context, string? errorMessageIfFailed, Exception? exceptionIfExists) -// { -// Outputs.Add("OnMethodEndAsync"); -// return default; -// } - -// public ValueTask OnEngineCompleteAsync(IServiceProvider serviceProvider, ILogger logger) -// { -// Outputs.Add("OnEngineCompleteAsync"); -// return default; -// } -// } - -// public class InterceptorTest_Single : ConsoleAppBase -// { -// public void Hello([Option(0)]string name) => Console.WriteLine($"Hello {name}"); -// } - -// public class InterceptorTest_Multi : ConsoleAppBase -// { -// [Command("hello")] -// public void Hello([Option(0)]string name) => Console.WriteLine($"Hello {name}"); -// [Command("hello2")] -// public void Hello2([Option(0)]string name) => Console.WriteLine($"Hello {name}"); -// } -// } -//} diff --git a/tests/ConsoleAppFramework.Tests/Integration/MultipleCommandTest.cs b/tests/ConsoleAppFramework.Tests/Integration/MultipleCommandTest.cs deleted file mode 100644 index 00ecff3..0000000 --- a/tests/ConsoleAppFramework.Tests/Integration/MultipleCommandTest.cs +++ /dev/null @@ -1,225 +0,0 @@ -using System; -using System.Threading.Tasks; -using FluentAssertions; -using Microsoft.Extensions.Hosting; -using Xunit; - -// ReSharper disable InconsistentNaming - -namespace ConsoleAppFramework.Integration.Test -{ - public partial class MultipleCommandTest - { - [Fact] - public async Task NoCommandAttribute() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { }; - (await Assert.ThrowsAsync(()=> Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args))) - .Message.Should().Contain("Found more than one root command."); - } - - public class CommandTests_Multiple_NoCommandAttribute : ConsoleAppBase - { - public void Hello() => Console.WriteLine("Hello"); - public void Konnichiwa() => Console.WriteLine("Konnichiwa"); - } - - [Fact] - public void Commands() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Usage:"); - console.Output.Should().Contain("Commands:"); - console.Output.Should().Contain("hello"); - console.Output.Should().Contain("konnichiwa"); - } - - //[Fact] - //public void Commands_UnknownCommand() - //{ - // using var console = new CaptureConsoleOutput(); - // var args = new string[] { "unknown-command" }; - // Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - // console.Output.Should().Contain("Usage:"); - // console.Output.Should().Contain("Commands:"); - // console.Output.Should().Contain("hello"); - // console.Output.Should().Contain("konnichiwa"); - //} - - [Fact] - public void Commands_UnknownCommand_Help() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { "help", "-foo", "-bar" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Usage:"); - console.Output.Should().Contain("Commands:"); - console.Output.Should().Contain("hello"); - console.Output.Should().Contain("konnichiwa"); - } - - public class CommandTests_Multiple_Commands : ConsoleAppBase - { - [Command("hello")] - public void Hello() => Console.WriteLine("Hello"); - [Command("konnichiwa")] - public void Konnichiwa() => Console.WriteLine("Konnichiwa"); - } - - [Fact] - public void OptionAndArg() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { "hello", "Cysharp" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Hello Cysharp (18)"); - } - - [Fact] - public void OptionAndArg_Option() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { "hello", "Cysharp", "-age", "-128" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Hello Cysharp (-128)"); - } - - [Fact] - public void OptionAndArg_Option_ReverseOrdered() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { "hello", "-age", "-128", "Cysharp" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Hello Cysharp (-128)"); - } - - [Fact] - public void OptionAndArg_Help() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { "hello", "help" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - // console.Output.Should().Contain("Hello help (18)"); - } - - [Fact] - public void OptionAndArg_HelpAndOtherArgs() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { "hello", "--help", "-age", "-128" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - - console.Output.Should().Contain("Usage: hello"); - } - - [Fact] - public void OptionAndArg_HelpOptionLike() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { "hello", "-help" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Usage:"); - console.Output.Should().Contain("Arguments:"); - - // NOTE: Currently, ConsoleAppFramework treats the first argument as special. If the argument is '-help', it is same as '-help' option. - //console.Output.Should().Contain("Hello -help (-128)"); - } - - [Fact] - public void OptionAndArg_HelpOptionLikeAndOtherOptions() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { "hello", "--help", "-age", "-128" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - - console.Output.Should().Contain("Usage: hello"); - } - - [Fact] - public void CommandHelp_OptionAndArg() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { "hello", "--help" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Usage:"); - console.Output.Should().Contain("Arguments:"); - } - - public class CommandTests_Multiple_OptionAndArg : ConsoleAppBase - { - [Command("hello")] - public void Hello([Option(0)]string name, int age = 18) => Console.WriteLine($"Hello {name} ({age})"); - [Command("konnichiwa")] - public void Konnichiwa() => Console.WriteLine("Konnichiwa"); - } - - [Fact] - public void OptionAndArg_Option_MixedOrdered_Default() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { "hello", "-age", "18", "Hello", "Cysharp" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Hello Cysharp (18)"); - } - - [Fact] - public void OptionAndArg_Option_MixedOrdered_2() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { "hello", "Hello", "-age", "18", "Cysharp" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Hello Cysharp (18)"); - } - - [Fact] - public void OptionAndArg_Option_MixedOrdered_3() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { "hello", "Hello", "Cysharp", "-age", "18" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Hello Cysharp (18)"); - } - - [Fact] - public void OptionAndArg_Option_Mixed_Optional() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { "greet" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Konnichiwa Anonymous"); - } - - public class CommandTests_Multiple_OptionAndArg_MixedOrdered : ConsoleAppBase - { - [Command("hello")] - public void Hello([Option(1)]string name, int age, [Option(0)]string greeting) => Console.WriteLine($"{greeting} {name} ({age})"); - [Command("greet")] - public void Greet([Option(0)]string greeting = "Konnichiwa", [Option(0)]string name = "Anonymous") => Console.WriteLine($"{greeting} {name}"); - } - - [Fact] - public void OptionHelp() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { "--help" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Usage:"); - console.Output.Should().Contain("Commands:"); - console.Output.Should().Contain("hello"); - console.Output.Should().Contain("konnichiwa"); - } - - [Fact] - public void OptionVersion() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { "--version" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().MatchRegex(@"\d.\d.\d"); // NOTE: When running with unit test runner, it returns a version of the runner. - } - - } -} diff --git a/tests/ConsoleAppFramework.Tests/Integration/NamedSingleCommandTest.cs b/tests/ConsoleAppFramework.Tests/Integration/NamedSingleCommandTest.cs deleted file mode 100644 index 698f8ce..0000000 --- a/tests/ConsoleAppFramework.Tests/Integration/NamedSingleCommandTest.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System; -using FluentAssertions; -using Microsoft.Extensions.Hosting; -using Xunit; - -// ReSharper disable InconsistentNaming - -namespace ConsoleAppFramework.Integration.Test -{ - public partial class NamedSingleCommandTest - { - [Fact] - public void NamedCommand_NoArgs_CommandIsNotSpecified() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Usage:"); - console.Output.Should().Contain("Commands:"); - } - - [Fact] - public void NamedCommand_NoArgs_Invoke() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { "hello" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Hello"); - } - - [Fact] - public void NamedCommand_NoArgs_CommandHelp() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { "help", "hello" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Usage:"); - console.Output.Should().Contain(" hello"); - } - - public class CommandTests_Single_Named_NoArgs : ConsoleAppBase - { - [Command("hello")] - public void Hello() => Console.WriteLine("Hello"); - } - - [Fact] - public void NamedCommand_OneArg_CommandIsNotSpecified() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Usage:"); - console.Output.Should().Contain("Commands:"); - } - - [Fact] - public void NamedCommand_OneArg_Invoke() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { "hello", "Cysharp" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Hello Cysharp"); - } - - //[Fact] - //public void NamedCommand_OneArg_CommandHelp() - //{ - // using var console = new CaptureConsoleOutput(); - // var args = new string[] { "help", "hello" }; - // Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - // console.Output.Should().Contain("Usage:"); - // console.Output.Should().Contain("Arguments:"); - //} - - public class CommandTests_Single_Named_OneArg : ConsoleAppBase - { - [Command("hello")] - public void Hello([Option(0)]string name) => Console.WriteLine($"Hello {name}"); - } - - } -} diff --git a/tests/ConsoleAppFramework.Tests/Integration/SingleCommandTest.Arguments.cs b/tests/ConsoleAppFramework.Tests/Integration/SingleCommandTest.Arguments.cs deleted file mode 100644 index e955a02..0000000 --- a/tests/ConsoleAppFramework.Tests/Integration/SingleCommandTest.Arguments.cs +++ /dev/null @@ -1,154 +0,0 @@ -using System; -using FluentAssertions; -using Microsoft.Extensions.Hosting; -using Xunit; - -// ReSharper disable InconsistentNaming - -namespace ConsoleAppFramework.Integration.Test -{ - public partial class SingleCommandTest - { - [Fact] - public void NoOptions_OneRequiredArg() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "Cysharp" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Hello Cysharp"); - } - - [Fact] - public void NoOptions_OneRequiredArg_ArgHelp() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "help" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - - // can not execute #shoganai - // console.Output.Should().Contain("Hello help"); - } - - [Fact] - public void NoOptions_OneRequiredArg_Insufficient() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Usage:"); - console.Output.Should().Contain("Arguments:"); - } - - [Fact] - public void NoOptions_OneRequiredArg_Help() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "-help" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Usage:"); - console.Output.Should().Contain("Arguments:"); - - // NOTE: Currently, ConsoleAppFramework treats the first argument as special. If the argument is '-help', it is same as '-help' option. - //console.Output.Should().Contain("Hello -version"); - } - - [Fact] - public void NoOptions_OneRequiredArg_Version() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "version" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().MatchRegex(@"\d.\d.\d"); // NOTE: When running with unit test runner, it returns a version of the runner. - - // NOTE: Currently, ConsoleAppFramework treats the first argument as special. If the argument is '-help', it is same as '-help' option. - //console.Output.Should().Contain("Hello -version"); - } - - public class CommandTests_Single_NoOptions_OneRequiredArg : ConsoleAppBase - { - public void Hello([Option(0)]string name) => Console.WriteLine($"Hello {name}"); - } - - [Fact] - public void NoOptions_OneOptionalArg() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "Cysharp" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Hello Cysharp"); - } - - [Fact] - public void NoOptions_OneOptionalArg_ArgHelp() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "help" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - - // can not execute #shoganai - // console.Output.Should().Contain("Hello help"); - } - - [Fact] - public void NoOptions_OneOptionalArg_NoInputArg() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Hello Anonymous"); - } - - [Fact] - public void NoOptions_OneOptionalArg_Help() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "help" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Usage:"); - console.Output.Should().Contain("Arguments:"); - - // NOTE: Currently, ConsoleAppFramework treats the first argument as special. If the argument is '-help', it is same as '-help' option. - //console.Output.Should().Contain("Hello -help"); - } - - [Fact] - public void NoOptions_OneOptionalArg_Version() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "version" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().MatchRegex(@"\d.\d.\d"); // NOTE: When running with unit test runner, it returns a version of the runner. - - // NOTE: Currently, ConsoleAppFramework treats the first argument as special. If the argument is '-help', it is same as '-help' option. - //console.Output.Should().Contain("Hello -version"); - } - - public class CommandTests_Single_NoOptions_OneOptionalArgs : ConsoleAppBase - { - public void Hello([Option(0)]string name = "Anonymous") => Console.WriteLine($"Hello {name}"); - } - - [Fact] - public void CommandTests_Single_DateTimeOption_WithDoubleQuote() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "\"2022-07-01\"" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Trim().Should().Be(@"2022-07-01"); - } - - [Fact] - public void CommandTests_Single_DateTimeOption_WithoutDoubleQuote() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "2022-07-01" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Trim().Should().Be(@"2022-07-01"); - } - - public class CommandTests_Single_DateTimeOption: ConsoleAppBase - { - public void Hello([Option(0)]DateTime dt) => Console.WriteLine($"{dt:yyyy-MM-dd}"); - } - } -} diff --git a/tests/ConsoleAppFramework.Tests/Integration/SingleCommandTest.Options.cs b/tests/ConsoleAppFramework.Tests/Integration/SingleCommandTest.Options.cs deleted file mode 100644 index 430dbda..0000000 --- a/tests/ConsoleAppFramework.Tests/Integration/SingleCommandTest.Options.cs +++ /dev/null @@ -1,216 +0,0 @@ -using System; -using FluentAssertions; -using Microsoft.Extensions.Hosting; -using Xunit; - -// ReSharper disable InconsistentNaming - -namespace ConsoleAppFramework.Integration.Test -{ - public partial class SingleCommandTest - { - [Fact] - public void OneRequiredOption_NoArgs() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "-name", "Cysharp" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Hello Cysharp"); - } - - [Fact] - public void OneRequiredOption_NoArgs_OptionLikeValue() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "-name", "-help" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Hello -help"); - } - - [Fact] - public void OneRequiredOption_NoArgs_Insufficient() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Usage:"); - console.Output.Should().Contain("Options:"); - } - - [Fact] - public void OneRequiredOption_NoArgs_Help() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "-help" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Usage:"); - console.Output.Should().Contain("Options:"); - } - - public class CommandTests_Single_OneRequiredOption_NoArgs : ConsoleAppBase - { - public void Hello(string name) => Console.WriteLine($"Hello {name}"); - } - - [Fact] - public void OneRequiredOneOptionalOptions_NoArgs_0() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "-name", "Cysharp" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Hello Cysharp (17)"); - } - - [Fact] - public void OneRequiredOneOptionalOptions_NoArgs_1() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "-name", "Cysharp", "-age", "256" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Hello Cysharp (256)"); - } - - [Fact] - public void OneRequiredOneOptionalOptions_NoArgs_OptionLikeValue() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "-name", "-help", "-age", "256" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Hello -help (256)"); - } - - [Fact] - public void OneRequiredOneOptionalOptions_NoArgs_Insufficient() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Usage:"); - console.Output.Should().Contain("Options:"); - } - - [Fact] - public void OneRequiredOneOptionalOptions_NoArgs_Help() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "-help" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Usage:"); - console.Output.Should().Contain("Options:"); - } - - public class CommandTests_Single_OneRequiredOneOptionalOptions_NoArgs : ConsoleAppBase - { - public void Hello(string name, int age = 17) => Console.WriteLine($"Hello {name} ({age})"); - } - - - [Fact] - public void TwoOptionalOptions_NoArgs_0() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "-name", "Cysharp" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Hello Cysharp (17)"); - } - - [Fact] - public void TwoOptionalOptions_NoArgs_1() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "-name", "Cysharp", "-age", "256" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Hello Cysharp (256)"); - } - - [Fact] - public void TwoOptionalOptions_NoArgs_2() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "-age", "-256" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Hello Anonymous (-256)"); - } - - [Fact] - public void TwoOptionalOptions_NoArgs_Ambiguous() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "-name", "-help", "-age", "256" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Hello -help (256)"); - // console.GetOutputText().Should().Contain("Usage:"); - // console.GetOutputText().Should().Contain("Options:"); - } - - [Fact] - public void TwoOptionalOptions_NoArgs_Help() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "-help" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Usage:"); - console.Output.Should().Contain("Options:"); - } - - [Fact] - public void TwoOptionalOptions_NoArgs_AllDefaultValue() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Hello Anonymous (17)"); - } - - public class CommandTests_Single_TwoOptionalOptions_NoArgs : ConsoleAppBase - { - public void Hello(string name = "Anonymous", int age = 17) => Console.WriteLine($"Hello {name} ({age})"); - } - - [Fact] - public void RequiredBoolAndOtherOption_NoArgs() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { "-hello", "-name", "Cysharp" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Hello Cysharp"); - } - - public class CommandTests_Single_RequiredBoolAndOtherOption_NoArgs : ConsoleAppBase - { - public void Hello(bool hello, string name) => Console.WriteLine($"{(hello ? "Hello" : "Konnichiwa")} {name}"); - } - - [Fact] - public void OptionalBoolAndRequiredOtherOption_NoArgs() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { "-name", "Cysharp" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Konnichiwa Cysharp"); - } - - [Fact] - public void OptionalBoolAndRequiredOtherOption_NoArgs_1() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { "-hello", "-name", "Cysharp" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Hello Cysharp"); - } - - [Fact] - public void Attempt_To_Call_Without_Parameter_Value() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "--name" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain(@"Value for parameter ""name"" is not provided."); - } - - public class CommandTests_Single_OptionalBoolAndRequiredOtherOption_NoArgs : ConsoleAppBase - { - public void Hello(string name, bool hello = false) => Console.WriteLine($"{(hello ? "Hello" : "Konnichiwa")} {name}"); - } - } -} diff --git a/tests/ConsoleAppFramework.Tests/Integration/SingleCommandTest.OptionsAndArguments.cs b/tests/ConsoleAppFramework.Tests/Integration/SingleCommandTest.OptionsAndArguments.cs deleted file mode 100644 index 822463c..0000000 --- a/tests/ConsoleAppFramework.Tests/Integration/SingleCommandTest.OptionsAndArguments.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System; -using FluentAssertions; -using Microsoft.Extensions.Hosting; -using Xunit; - -// ReSharper disable InconsistentNaming - -namespace ConsoleAppFramework.Integration.Test -{ - public partial class SingleCommandTest - { - [Fact] - public void OneRequiredOption_OneRequiredArg() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "Cysharp", "-age", "18" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Cysharp (18)"); - } - - [Fact] - public void OneRequiredOption_OneRequiredArg_OptionLikeValueArg() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "--C--", "-age", "18" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("--C-- (18)"); - } - - [Fact] - public void OneRequiredOption_OneRequiredArg_Insufficient() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Usage:"); - console.Output.Should().Contain("Options:"); - } - - [Fact] - public void OneRequiredOption_OneRequiredArg_Insufficient_Options() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { "Cysharp" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Required parameter \"age\""); - } - - [Fact] - public void OneRequiredOption_OneRequiredArg_Help() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "-help" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Usage:"); - console.Output.Should().Contain("Options:"); - } - - public class CommandTests_OneRequiredOption_OneRequiredArg : ConsoleAppBase - { - public void Hello([Option(0)]string name, int age) => Console.WriteLine($"{name} ({age})"); - } - - [Fact] - public void OneOptionalOption_OneRequiredArg() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "Cysharp" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Cysharp (17)"); - } - - [Fact] - public void OneOptionalOption_OneRequiredArg_Option() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "Cysharp", "-age", "18" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Cysharp (18)"); - } - - [Fact] - public void OneOptionalOption_OneRequiredArg_Help() - { - using var console = new CaptureConsoleOutput(); - var args = new[] { "-help" }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("Usage:"); - console.Output.Should().Contain("Options:"); - } - - public class CommandTests_OneOptionalOption_OneRequiredArg : ConsoleAppBase - { - public void Hello([Option(0)]string name, int age = 17) => Console.WriteLine($"{name} ({age})"); - } - } -} diff --git a/tests/ConsoleAppFramework.Tests/Integration/SingleCommandTest.cs b/tests/ConsoleAppFramework.Tests/Integration/SingleCommandTest.cs deleted file mode 100644 index 4bc0e27..0000000 --- a/tests/ConsoleAppFramework.Tests/Integration/SingleCommandTest.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System; -using FluentAssertions; -using Microsoft.Extensions.Hosting; -using Xunit; - -// ReSharper disable InconsistentNaming - -namespace ConsoleAppFramework.Integration.Test -{ - public partial class SingleCommandTest - { - [Fact] - public void NoOptions_NoArgs() - { - using var console = new CaptureConsoleOutput(); - var args = new string[] { }; - Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync(args); - console.Output.Should().Contain("HelloMyWorld"); - } - - [Fact] - public void IntArguments() - { - using var console = new CaptureConsoleOutput(); - - var args = "--foo 1,2,3".Split(' '); - - ConsoleApp.RunAsync(args, (int[] foo) => - { - foreach (var item in foo) - { - Console.WriteLine(item); - } - }); - - console.Output.Should().Be(@"1 -2 -3 -"); - } - - [Fact] - public void StringArguments() - { - using var console = new CaptureConsoleOutput(); - - var args = "--foo a,b,c".Split(' '); - - ConsoleApp.RunAsync(args, (string[] foo) => - { - foreach (var item in foo) - { - Console.WriteLine(item); - } - }); - - console.Output.Should().Be(@"a -b -c -"); - } - - public class CommandTests_Single_NoOptions_NoArgs : ConsoleAppBase - { - public void Hello() => Console.WriteLine("HelloMyWorld"); - } - } -} diff --git a/tests/ConsoleAppFramework.Tests/Integration/ValidationAttributeTests.cs b/tests/ConsoleAppFramework.Tests/Integration/ValidationAttributeTests.cs deleted file mode 100644 index 692f323..0000000 --- a/tests/ConsoleAppFramework.Tests/Integration/ValidationAttributeTests.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using System.ComponentModel.DataAnnotations; -using FluentAssertions; -using Xunit; - -// ReSharper disable UnusedMember.Global -// ReSharper disable UnusedParameter.Global - -namespace ConsoleAppFramework.Integration.Test; - -public class ValidationAttributeTests -{ - /// - /// Try to execute command with invalid option value. - /// - [Fact] - public void Validate_String_Length_Test() - { - using var console = new CaptureConsoleOutput(); - - const string optionName = "arg"; - const string optionValue = "too-large-string-value"; - - var args = new[] { nameof(AppWithValidationAttributes.StrLength), $"--{optionName}", optionValue }; - ConsoleApp.Run(args); - - // Validation should fail, so StrLength command should not be executed. - console.Output.Should().NotContain(AppWithValidationAttributes.Output); - - console.Output.Should().Contain(optionName); - console.Output.Should().Contain(optionValue); - } - - [Fact] - public void Command_With_Multiple_Params() - { - using var console = new CaptureConsoleOutput(); - - var args = new[] - { - nameof(AppWithValidationAttributes.MultipleParams), - "--second-arg", "10", - "--first-arg", "invalid-email-address" - }; - - ConsoleApp.Run(args); - - // Validation should fail, so StrLength command should not be executed. - console.Output.Should().NotContain(AppWithValidationAttributes.Output); - } - - /// - internal class AppWithValidationAttributes : ConsoleAppBase - { - public const string Output = $"hello from {nameof(AppWithValidationAttributes)}"; - - [Command(nameof(StrLength))] - public void StrLength([StringLength(maximumLength: 8)] string arg) => Console.WriteLine(Output); - - [Command(nameof(MultipleParams))] - public void MultipleParams( - [EmailAddress] string firstArg, - [Range(0, 2)] int secondArg) => Console.WriteLine(Output); - } -} \ No newline at end of file diff --git a/tests/ConsoleAppFramework.Tests/Legacy/CommandAttributeTest.cs b/tests/ConsoleAppFramework.Tests/Legacy/CommandAttributeTest.cs deleted file mode 100644 index 86d4949..0000000 --- a/tests/ConsoleAppFramework.Tests/Legacy/CommandAttributeTest.cs +++ /dev/null @@ -1,44 +0,0 @@ -using FluentAssertions; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using System.Threading.Tasks; -using Xunit; - -namespace ConsoleAppFramework.Tests -{ - public class CommandAttributeTest - { - class CommandAttributeTestCommand : ConsoleAppBase - { - ResultContainer _Result; - public CommandAttributeTestCommand(ResultContainer r) - { - _Result = r; - } - [Command("test")] - public void TestCommand(int value) - { - _Result.X = value; - } - } - class ResultContainer - { - public int X; - } - //[Fact] - //public async Task TestCommandName() - //{ - // var host = Host.CreateDefaultBuilder() - // .ConfigureServices((c, services) => - // { - // services.AddSingleton(); - // }) - // .UseConsoleAppFramework(new string[]{ "test", "-value", "1" }) - // .Build(); - // var result = host.Services.GetService(); - // await host.RunAsync(); - // result.X.Should().Be(1); - //} - - } -} \ No newline at end of file diff --git a/tests/ConsoleAppFramework.Tests/Legacy/CommandHelpTest.cs b/tests/ConsoleAppFramework.Tests/Legacy/CommandHelpTest.cs deleted file mode 100644 index d3e2ea1..0000000 --- a/tests/ConsoleAppFramework.Tests/Legacy/CommandHelpTest.cs +++ /dev/null @@ -1,337 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using FluentAssertions; -using FluentAssertions.Common; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace ConsoleAppFramework.Tests -{ - public class CommandHelpTest - { - private CommandHelpBuilder CreateCommandHelpBuilder() => new CommandHelpBuilder(() => "Nantoka", null, new ConsoleAppOptions() { NameConverter = x => x.ToLower() }); - private CommandHelpBuilder CreateCommandHelpBuilder2() => new CommandHelpBuilder(() => "Nantoka", null, new ConsoleAppOptions() { NameConverter = x => x.ToLower() }); - private CommandHelpBuilder CreateCommandHelpBuilder3() => new CommandHelpBuilder(() => "Nantoka", null, new ConsoleAppOptions() { NameConverter = x => x.ToLower() }); - - [Fact] - public void BuildMethodListMessage() - { - var builder = CreateCommandHelpBuilder(); - var expected = @$" -Commands: - list-message-batch hello - list-message-batch YetAnotherHello - list-message-batch HelloWithAliasWithDescription Description of command -".TrimStart(); - - var app = ConsoleApp.CreateBuilder(new string[0]).Build(); - app.AddSubCommands(); - var descs = app.Host.Services.GetRequiredService().CommandDescriptors.GetSubCommands("list-message-batch"); - - var msg = builder.BuildMethodListMessage(descs, false, out _); - msg.Should().Be(expected); - } - - [Fact] - public void BuildUsageMessage_Types() - { - var builder = CreateCommandHelpBuilder(); - var expected = @"Usage: Nantoka - -Commands: - list-message-batch hello - list-message-batch HelloWithAliasWithDescription Description of command - list-message-batch YetAnotherHello -"; - - var app = ConsoleApp.CreateBuilder(new string[0]).Build(); - app.AddSubCommands(); - var descs = app.Host.Services.GetRequiredService().CommandDescriptors.GetSubCommands("list-message-batch"); - - builder.BuildHelpMessage(null, descs, false).Should().Be(expected); - } - - [Fact] - public void BuildUsageMessage_Type() - { - var app = ConsoleApp.CreateBuilder(new string[0]).Build(); - app.AddSubCommand("commandhelptestbatch", "Complex2", new CommandHelpTestBatch().Complex); - app.Host.Services.GetRequiredService().CommandDescriptors.TryGetDescriptor(new[] { "commandhelptestbatch", "Complex2" }, out var desc, out _); - - var builder = CreateCommandHelpBuilder(); - var def = builder.CreateCommandHelpDefinition(desc, false); - var expected = @"Usage: Nantoka commandhelptestbatch Complex2 <1st> <2nd> <3rd> [options...]"; - - builder.BuildUsageMessage(def, showCommandName: true, fromMultiCommand: true).Should().Be(expected); - } - - [Fact] - public void BuildUsageMessage_Single() - { - var app = ConsoleApp.CreateBuilder(new string[0]).Build(); - app.AddSubCommand("commandhelptestbatch", "Complex2", new CommandHelpTestBatch().Complex); - app.Host.Services.GetRequiredService().CommandDescriptors.TryGetDescriptor(new[] { "commandhelptestbatch", "Complex2" }, out var desc, out _); - - var builder = CreateCommandHelpBuilder(); - var def = builder.CreateCommandHelpDefinition(desc, true); - var expected = @"Usage: Nantoka <1st> <2nd> <3rd> [options...]"; - - builder.BuildUsageMessage(def, showCommandName: false, fromMultiCommand: false).Should().Be(expected); - } - - //[Fact] - //public void BuildUsageMessage_Single_IndexedOptionsOnly() - //{ - // var builder = CreateCommandHelpBuilder(); - // var def = builder.CreateCommandHelpDefinition(typeof(CommandHelpTestBatch).GetMethod(nameof(CommandHelpTestBatch.ComplexIndexedOnly))); - // var expected = @"Usage: Nantoka <1st> <2nd> <3rd>"; - - // builder.BuildUsageMessage(def, showCommandName: false, fromMultiCommand: false).Should().Be(expected); - //} - - - // [Fact] - // public void CreateCommandHelp_Single_NoDescription() - // { - // var builder = CreateCommandHelpBuilder(); - // var def = builder.CreateCommandHelpDefinition(typeof(CommandHelpTestBatch).GetMethod(nameof(CommandHelpTestBatch.ComplexIndexedOnlyNoDescription))); - // var expected = @" - //Usage: Nantoka <1st> <2nd> <3rd> - - //Arguments: - // [0] 1st - // [1] 2nd - // [2] 3rd - - //".TrimStart(); - - // builder.BuildHelpMessage(def, showCommandName: false, fromMultiCommand: false).Should().Be(expected); - // } - - // [Fact] - // public void CreateCommandHelp_Single_IndexedOptionsOnly() - // { - // var builder = CreateCommandHelpBuilder(); - // var def = builder.CreateCommandHelpDefinition(typeof(CommandHelpTestBatch).GetMethod(nameof(CommandHelpTestBatch.ComplexIndexedOnly))); - // var expected = @" - //Usage: Nantoka <1st> <2nd> <3rd> - - //Description of complex command2 - - //Arguments: - // [0] 1st - // [1] 2nd - // [2] 3rd - - //".TrimStart(); - - // builder.BuildHelpMessage(def, showCommandName: false, fromMultiCommand: false).Should().Be(expected); - // } - - // [Fact] - // public void CreateCommandHelp_Single() - // { - // { - // var builder = CreateCommandHelpBuilder(); - // var def = builder.CreateCommandHelpDefinition(typeof(CommandHelpTestBatch).GetMethod(nameof(CommandHelpTestBatch.Complex))); - // var expected = @" - //Usage: Nantoka <1st> <2nd> <3rd> [options...] - - //Description of complex command - - //Arguments: - // [0] 1st - // [1] 2nd - // [2] 3rd - - //Options: - // -anonArg0 (Required) - // -optA, -shortNameArg0 Option has short name (Required) - - //".TrimStart(); - - // builder.BuildHelpMessage(def, showCommandName: false, fromMultiCommand: false).Should().Be(expected); - // } - // { - // var builder = CreateCommandHelpBuilder2(); - // var def = builder.CreateCommandHelpDefinition(typeof(CommandHelpTestBatch).GetMethod(nameof(CommandHelpTestBatch.Complex))); - // var expected = @" - //Usage: Nantoka <1st> <2nd> <3rd> [options...] - - //Description of complex command - - //Arguments: - // [0] 1st - // [1] 2nd - // [2] 3rd - - //Options: - // --anonArg0 (Required) - // -optA, --shortNameArg0 Option has short name (Required) - - //".TrimStart(); - - // builder.BuildHelpMessage(def, showCommandName: false, fromMultiCommand: false).Should().Be(expected); - // } - // } - - // [Fact] - // public void CreateCommandHelp_RequiredOrNotRequired() - // { - // var builder = CreateCommandHelpBuilder(); - // var def = builder.CreateCommandHelpDefinition(typeof(CommandHelpTestBatch).GetMethod(nameof(CommandHelpTestBatch.OptionRequiredAndNotRequired))); - // var expected = @" - //Options: - // -foo desc1 (Required) - // -bar desc2 (Default: 999) - - //".TrimStart(); - - // builder.BuildOptionsMessage(def).Should().Be(expected); - // } - - // [Fact] - // public void CreateCommandHelp_BooleanWithoutDefault_ShownWithoutValue() - // { - // var builder = CreateCommandHelpBuilder(); - // var def = builder.CreateCommandHelpDefinition(typeof(CommandHelpTestBatch).GetMethod(nameof(CommandHelpTestBatch.OptionBooleanSwitchWithoutDefault))); - // var expected = @" - //Options: - // -f, -flag desc (Optional) - - //".TrimStart(); - - // builder.BuildOptionsMessage(def).Should().Be(expected); - // } - - // [Fact] - // public void CreateCommandHelp_BooleanWithTrueDefault_ShownWithValue() - // { - // var builder = CreateCommandHelpBuilder(); - // var def = builder.CreateCommandHelpDefinition(typeof(CommandHelpTestBatch).GetMethod(nameof(CommandHelpTestBatch.OptionBooleanSwitchWithTrueDefault))); - // var expected = @" - //Options: - // -f, -flag desc (Default: True) - - //".TrimStart(); - - // builder.BuildOptionsMessage(def).Should().Be(expected); - // } - - // [Fact] - // public void CreateCommandHelp_BooleanWithTrueDefault_ShownWithoutValue() - // { - // var builder = CreateCommandHelpBuilder(); - // var def = builder.CreateCommandHelpDefinition(typeof(CommandHelpTestBatch).GetMethod(nameof(CommandHelpTestBatch.OptionBooleanSwitchWithFalseDefault))); - // var expected = @" - //Options: - // -f, -flag desc (Optional) - - //".TrimStart(); - - // builder.BuildOptionsMessage(def).Should().Be(expected); - // } - } - - [Command("list-message-batch")] - public class CommandHelpTestListMessageBatch : ConsoleAppBase - { - public void Hello() - { - } - - [Command("YetAnotherHello")] - public void HelloWithAlias() - { - } - - [Command("HelloWithAliasWithDescription", "Description of command")] - public void HelloWithAliasWithDescription() - { - } - } - - public class CommandHelpTestBatch : ConsoleAppBase - { - public void Hello() - { - } - - [Command("YetAnotherHello")] - public void HelloWithAlias() - { - } - - [Command("HelloWithAliasWithDescription", "Description of command")] - public void HelloWithAliasWithDescription() - { - } - - [Command(new[] { "YetAnotherHello2", "HokanoHello" })] - public void HelloWithAliases() - { - } - - public void OptionalParameters([Option("x")] int xxx, [Option("y", "Option y")] int yyy) - { - } - - public void OptionalParametersSameShortName([Option("xxx")] int xxx, [Option("yyy", "Option y")] int yyy) - { - } - - public void OptionDefaultValue(int nano = 999) - { - } - - public void OptionRequiredAndNotRequired([Option("foo", "desc1")] string foo, [Option("bar", "desc2")] int bar = 999) - { - } - - public void OptionIndex([Option(2, "3rd")] int arg0, [Option(1, "2nd")] string arg1, [Option(0, "1st")] bool arg2) - { - } - - public void OptionBooleanSwitchWithoutDefault([Option("f", "desc")] bool flag) - { - } - - public void OptionBooleanSwitchWithTrueDefault([Option("f", "desc")] bool flag = true) - { - } - - public void OptionBooleanSwitchWithFalseDefault([Option("f", "desc")] bool flag = false) - { - } - - [Command(new[] { "Complex2", "cpx" }, "Description of complex command")] - public void Complex( - int anonArg0, - [Option("optA", "Option has short name")] - string shortNameArg0, - [Option(2, "3rd")] int arg0, - [Option(1, "2nd")] string arg1, - [Option(0, "1st")] bool arg2 - ) - { - } - - [Command("ComplexIndexedOnly", "Description of complex command2")] - public void ComplexIndexedOnly( - [Option(2, "3rd")] int arg0, - [Option(1, "2nd")] string arg1, - [Option(0, "1st")] bool arg2 - ) - { - } - - public void ComplexIndexedOnlyNoDescription( - [Option(2, "3rd")] int arg0, - [Option(1, "2nd")] string arg1, - [Option(0, "1st")] bool arg2 - ) - { - } - } -} diff --git a/tests/ConsoleAppFramework.Tests/Legacy/ExitCodeTest.cs b/tests/ConsoleAppFramework.Tests/Legacy/ExitCodeTest.cs deleted file mode 100644 index 1744003..0000000 --- a/tests/ConsoleAppFramework.Tests/Legacy/ExitCodeTest.cs +++ /dev/null @@ -1,111 +0,0 @@ -using Microsoft.Extensions.Hosting; -using System; -using System.Threading.Tasks; -using Xunit; - -namespace ConsoleAppFramework.Tests -{ - public class ExitCodeTest - { - public class ExitCodeTestBatch : ConsoleAppBase - { - [Command(nameof(NoExitCode))] - public void NoExitCode() - { - } - - [Command(nameof(NoExitCodeException))] - public void NoExitCodeException() - { - throw new Exception(); - } - - [Command(nameof(NoExitCodeWithTask))] - public Task NoExitCodeWithTask() - { - return Task.CompletedTask; - } - - [Command(nameof(ExitCode))] - public int ExitCode() - { - return 12345; - } - - [Command(nameof(ExitCodeException))] - public int ExitCodeException() - { - throw new Exception(); - } - - [Command(nameof(ExitCodeWithTask))] - public Task ExitCodeWithTask() - { - return Task.FromResult(54321); - } - - [Command(nameof(ExitCodeWithTaskException))] - public Task ExitCodeWithTaskException() - { - throw new Exception(); - } - } - - [Fact] - public async Task NoExitCode() - { - Environment.ExitCode = 0; - await new HostBuilder().RunConsoleAppFrameworkAsync(new[] { nameof(NoExitCode) }); - Assert.Equal(0, Environment.ExitCode); - } - - [Fact] - public async Task NoExitCodeWithTask() - { - Environment.ExitCode = 0; - await new HostBuilder().RunConsoleAppFrameworkAsync(new[] { nameof(NoExitCodeWithTask) }); - Assert.Equal(0, Environment.ExitCode); - } - - [Fact] - public async Task NoExitCodeException() - { - Environment.ExitCode = 0; - await new HostBuilder().RunConsoleAppFrameworkAsync(new[] { nameof(NoExitCodeException) }); - Assert.Equal(1, Environment.ExitCode); - } - - [Fact] - public async Task ExitCode() - { - Environment.ExitCode = 0; - await new HostBuilder().RunConsoleAppFrameworkAsync(new[] { nameof(ExitCode) }); - Assert.Equal(12345, Environment.ExitCode); - } - - [Fact] - public async Task ExitCodeException() - { - Environment.ExitCode = 0; - await new HostBuilder().RunConsoleAppFrameworkAsync(new[] { nameof(ExitCodeException) }); - Assert.Equal(1, Environment.ExitCode); - } - - [Fact] - public async Task ExitCodeWithTask() - { - Environment.ExitCode = 0; - await new HostBuilder().RunConsoleAppFrameworkAsync(new[] { nameof(ExitCodeWithTask) }); - Assert.Equal(54321, Environment.ExitCode); - } - - [Fact] - public async Task ExitCodeWithTaskException() - { - Environment.ExitCode = 0; - await new HostBuilder().RunConsoleAppFrameworkAsync(new[] { nameof(ExitCodeWithTaskException) }); - Assert.Equal(1, Environment.ExitCode); - } - - } -} diff --git a/tests/ConsoleAppFramework.Tests/Legacy/MultiContainedTest.cs b/tests/ConsoleAppFramework.Tests/Legacy/MultiContainedTest.cs deleted file mode 100644 index 31ddf81..0000000 --- a/tests/ConsoleAppFramework.Tests/Legacy/MultiContainedTest.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using System.Threading.Tasks; -using Xunit; -using Xunit.Abstractions; - -namespace ConsoleAppFramework.Tests -{ - public class Multi1 : ConsoleAppBase - { - public void Hello1() - { - Context.Logger.LogInformation("ok"); - } - - public void Hello2(string input) - { - Context.Logger.LogInformation(input); - } - } - - public class Multi2 : ConsoleAppBase - { - public void Hello1([Option("x")]int xxx, [Option("y")]int yyy) - { - Context.Logger.LogInformation($"{xxx}:{yyy}"); - } - - public void Hello2(bool x, bool y, string foo, int nano = 999) - { - Context.Logger.LogInformation($"{x}:{y}:{foo}:{nano}"); - } - } - - public class MultiContainedTest - { - ITestOutputHelper testOutput; - - public MultiContainedTest(ITestOutputHelper testOutput) - { - this.testOutput = testOutput; - } - - [Fact] - public async Task MultiContained() - { - { - var args = "Multi1.Hello1".Split(' '); - var log = new LogStack(); - await new HostBuilder() - .ConfigureTestLogging(testOutput, log, true) - .RunConsoleAppFrameworkAsync(args); - log.InfoLogShouldBe(0, "ok"); - } - { - var args = "Multi1.Hello2 -input yeah".Split(' '); - var log = new LogStack(); - await new HostBuilder() - .ConfigureTestLogging(testOutput, log, true) - .RunConsoleAppFrameworkAsync(args); - log.InfoLogShouldBe(0, "yeah"); - } - { - var args = "Multi2.Hello1 -x 20 -y 30".Split(' '); - var log = new LogStack(); - await new HostBuilder() - .ConfigureTestLogging(testOutput, log, true) - .RunConsoleAppFrameworkAsync(args); - log.InfoLogShouldBe(0, "20:30"); - } - { - var args = "Multi2.Hello2 -x -y -foo yeah".Split(' '); - var log = new LogStack(); - await new HostBuilder() - .ConfigureTestLogging(testOutput, log, true) - .RunConsoleAppFrameworkAsync(args); - log.InfoLogShouldBe(0, "True:True:yeah:999"); - } - } - } -} diff --git a/tests/ConsoleAppFramework.Tests/Legacy/ParameterCheckTest.cs b/tests/ConsoleAppFramework.Tests/Legacy/ParameterCheckTest.cs deleted file mode 100644 index 909a38d..0000000 --- a/tests/ConsoleAppFramework.Tests/Legacy/ParameterCheckTest.cs +++ /dev/null @@ -1,46 +0,0 @@ -using FluentAssertions; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Xunit; -using Xunit.Abstractions; - -namespace ConsoleAppFramework.Tests -{ - public class ParameterCheckTest - { - ITestOutputHelper testOutput; - - public ParameterCheckTest(ITestOutputHelper testOutput) - { - this.testOutput = testOutput; - } - - public class DictionaryCheck : ConsoleAppBase - { - public void Hello(Dictionary q) - { - - foreach (var item in q) - { - Context.Logger.LogInformation($"{item.Key}:{item.Value}"); - } - } - } - - [Fact] - public async Task DictParse() - { - var args = @"-q {""Key1"":""Value1*""}".Split(' '); - - var log = new LogStack(); - await new HostBuilder() - .ConfigureTestLogging(testOutput, log, true) - .RunConsoleAppFrameworkAsync(args); - - log.InfoLogShouldBe(0, "Key1:Value1*"); - } - } -} diff --git a/tests/ConsoleAppFramework.Tests/Legacy/SingleContainedTest.cs b/tests/ConsoleAppFramework.Tests/Legacy/SingleContainedTest.cs deleted file mode 100644 index a3c60ba..0000000 --- a/tests/ConsoleAppFramework.Tests/Legacy/SingleContainedTest.cs +++ /dev/null @@ -1,256 +0,0 @@ -using FluentAssertions; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using System; -using System.Threading.Tasks; -using Xunit; -using Xunit.Abstractions; - -namespace ConsoleAppFramework.Tests -{ - public class SingleContainedTest - { - ITestOutputHelper testOutput; - - public SingleContainedTest(ITestOutputHelper testOutput) - { - this.testOutput = testOutput; - } - - public class SimpleZeroArgs : ConsoleAppBase - { - public void Hello() - { - Context.Logger.LogInformation($"ok"); - } - } - - [Fact] - public async Task SimpleZeroArgsTest() - { - var log = new LogStack(); - await new HostBuilder() - .ConfigureTestLogging(testOutput, log, true) - .RunConsoleAppFrameworkAsync(new string[0]); - log.InfoLogShouldBe(0, "ok"); - } - - public class SimpleTwoArgs : ConsoleAppBase - { - public void Hello( - string name, - int repeat) - { - Context.Logger.LogInformation($"name:{name}"); - Context.Logger.LogInformation($"repeat:{repeat}"); - } - } - - [Fact] - public async Task SimpleTwoArgsTest() - { - { - var args = "-name foo -repeat 3".Split(' '); - var log = new LogStack(); - await new HostBuilder() - .ConfigureTestLogging(testOutput, log, true) - .RunConsoleAppFrameworkAsync(args); - log.InfoLogShouldBe(0, "name:foo"); - log.InfoLogShouldBe(1, "repeat:3"); - } - { - //var args = "-repeat 3".Split(' '); - //var log = new LogStack(); - //using (TextWriterBridge.BeginSetConsoleOut(testOutput, log)) - //{ - // { - // await new HostBuilder() - // .ConfigureTestLogging(testOutput, log, true) - // .RunConsoleAppFrameworkAsync(args); - // } - - // log.ToStringInfo().Should().Contain("Required parameter \"name\" not found in argument"); - //} - - } - { - var log = new LogStack(); - using (TextWriterBridge.BeginSetConsoleOut(testOutput, log)) - { - var args = new string[0]; - await new HostBuilder().RunConsoleAppFrameworkAsync(args); - log.ToStringInfo().Should().Contain("Options:"); // ok to show help - } - } - } - - public class SimpleComplexArgs : ConsoleAppBase - { - public void Hello( - ComplexStructure person, - int repeat) - { - Context.Logger.LogInformation($"person.Age:{person.Age} person.Name:{person.Name}"); - Context.Logger.LogInformation($"repeat:{repeat}"); - } - } - - public class ComplexStructure - { - public int Age { get; set; } - public string Name { get; set; } - } - - [Fact] - public async Task SimpleComplexArgsTest() - { - { - var args = "-person {\"Age\":10,\"Name\":\"foo\"} -repeat 3".Split(' '); - var log = new LogStack(); - await new HostBuilder() - .ConfigureTestLogging(testOutput, log, true) - .RunConsoleAppFrameworkAsync(args); - log.InfoLogShouldBe(0, "person.Age:10 person.Name:foo"); - log.InfoLogShouldBe(1, "repeat:3"); - } - } - - public class TwoArgsWithOption : ConsoleAppBase - { - public void Hello( - [Option("n", "name of this")]string name, - [Option("r", "repeat msg")]int repeat) - { - Context.Logger.LogInformation($"name:{name}"); - Context.Logger.LogInformation($"repeat:{repeat}"); - } - } - - [Fact] - public async Task TwoArgsWithOptionTest() - { - { - var args = "-n foo -r 3".Split(' '); - var log = new LogStack(); - await new HostBuilder() - .ConfigureTestLogging(testOutput, log, true) - .RunConsoleAppFrameworkAsync(args); - log.InfoLogShouldBe(0, "name:foo"); - log.InfoLogShouldBe(1, "repeat:3"); - } - { - var log = new LogStack(); - using (TextWriterBridge.BeginSetConsoleOut(testOutput, log)) - { - var args = new string[0]; - await new HostBuilder().RunConsoleAppFrameworkAsync(args); - var strAssertion = log.ToStringInfo().Should(); - strAssertion.Contain("Options:"); // ok to show help - strAssertion.Contain("-n"); - strAssertion.Contain("name of this"); - strAssertion.Contain("-r"); - strAssertion.Contain("repeat msg"); - } - } - } - - public class TwoArgsWithDefault : ConsoleAppBase - { - public void Hello(string name, int repeat = 100, string hoo = null) - { - Context.Logger.LogInformation($"name:{name}"); - Context.Logger.LogInformation($"repeat:{repeat}"); - Context.Logger.LogInformation($"hoo:{hoo}"); - } - } - - [Fact] - public async Task TwoArgsWithDefaultTest() - { - { - var args = "-name foo".Split(' '); - var log = new LogStack(); - await new HostBuilder() - .ConfigureTestLogging(testOutput, log, true) - .RunConsoleAppFrameworkAsync(args); - log.InfoLogShouldBe(0, "name:foo"); - log.InfoLogShouldBe(1, "repeat:100"); - log.InfoLogShouldBe(2, "hoo:"); - } - } - - public class AllDefaultParameters : ConsoleAppBase - { - public void Hello(string name = "aaa", int repeat = 100, string hoo = null) - { - Context.Logger.LogInformation($"name:{name}"); - Context.Logger.LogInformation($"repeat:{repeat}"); - Context.Logger.LogInformation($"hoo:{hoo}"); - } - } - - [Fact] - public async Task AllDefaultParametersTest() - { - { - var args = new string[0]; - var log = new LogStack(); - await new HostBuilder() - .ConfigureTestLogging(testOutput, log, true) - .RunConsoleAppFrameworkAsync(args); - log.InfoLogShouldBe(0, "name:aaa"); - log.InfoLogShouldBe(1, "repeat:100"); - log.InfoLogShouldBe(2, "hoo:"); - } - } - - public class BooleanSwitch : ConsoleAppBase - { - public void Hello(string x, bool bar, bool foo = false, bool yeah = false) - { - Context.Logger.LogInformation($"x:{x}"); - Context.Logger.LogInformation($"bar:{bar}"); - Context.Logger.LogInformation($"foo:{foo}"); - Context.Logger.LogInformation($"yeah:{yeah}"); - } - } - - [Fact] - public async Task BooleanSwitchTest() - { - { - var log = new LogStack(); - var args = "-x foo -bar -foo -yeah".Split(' '); - await new HostBuilder() - .ConfigureTestLogging(testOutput, log, true) - .RunConsoleAppFrameworkAsync(args); - log.InfoLogShouldBe(0, "x:foo"); - log.InfoLogShouldBe(1, "bar:True"); - log.InfoLogShouldBe(2, "foo:True"); - log.InfoLogShouldBe(3, "yeah:True"); - } - { - var log = new LogStack(); - var args = "-x foo -bar -foo".Split(' '); - await new HostBuilder() - .ConfigureTestLogging(testOutput, log, true) - .RunConsoleAppFrameworkAsync(args); - log.InfoLogShouldBe(0, "x:foo"); - log.InfoLogShouldBe(1, "bar:True"); - log.InfoLogShouldBe(2, "foo:True"); - log.InfoLogShouldBe(3, "yeah:False"); - } - { - var log = new LogStack(); - var args = "-x foo -foo -yeah".Split(' '); - await new HostBuilder() - .ConfigureTestLogging(testOutput, log, true) - .RunConsoleAppFrameworkAsync(args); - log.InfoLogShouldBe(0, "x:foo"); - log.InfoLogShouldBe(1, "bar:False"); - log.InfoLogShouldBe(2, "foo:True"); - log.InfoLogShouldBe(3, "yeah:True"); - } - } - } -} diff --git a/tests/ConsoleAppFramework.Tests/Legacy/SubCommandTest.cs b/tests/ConsoleAppFramework.Tests/Legacy/SubCommandTest.cs deleted file mode 100644 index ccd69ac..0000000 --- a/tests/ConsoleAppFramework.Tests/Legacy/SubCommandTest.cs +++ /dev/null @@ -1,216 +0,0 @@ -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using System; -using System.Threading.Tasks; -using Xunit; -using Xunit.Abstractions; - -namespace ConsoleAppFramework.Tests -{ - - public class SubCommandTest - { - readonly ITestOutputHelper testOutput; - - public SubCommandTest(ITestOutputHelper testOutput) - { - this.testOutput = testOutput; - } - - public class TwoSubCommand : ConsoleAppBase - { - public void Main(double d) - { - Context.Logger.LogInformation($"d:{d}"); - } - - [Command("run")] - public void Run(string path, string pfx) - { - Context.Logger.LogInformation($"path:{path}"); - Context.Logger.LogInformation($"pfx:{pfx}"); - } - - [Command("sum")] - public void Sum([Option(0)]int x, [Option(1)]int y) - { - Context.Logger.LogInformation($"x:{x}"); - Context.Logger.LogInformation($"y:{y}"); - } - - [Command("opt")] - public void Option([Option(0)]string input, [Option("x")]int xxx, [Option("y")]int yyy) - { - Context.Logger.LogInformation($"input:{input}"); - Context.Logger.LogInformation($"x:{xxx}"); - Context.Logger.LogInformation($"y:{yyy}"); - } - } - - [Fact] - public async Task TwoSubCommandTest() - { - { - var args = "-d 12345.12345".Split(' '); - var log = new LogStack(); - await new HostBuilder() - .ConfigureTestLogging(testOutput, log, true) - .RunConsoleAppFrameworkAsync(args); - log.InfoLogShouldBe(0, "d:12345.12345"); - } - { - var args = "run -path foo -pfx bar".Split(' '); - var log = new LogStack(); - await new HostBuilder() - .ConfigureTestLogging(testOutput, log, true) - .RunConsoleAppFrameworkAsync(args); - log.InfoLogShouldBe(0, "path:foo"); - log.InfoLogShouldBe(1, "pfx:bar"); - } - { - var args = "sum 10 20".Split(' '); - var log = new LogStack(); - await new HostBuilder() - .ConfigureTestLogging(testOutput, log, true) - .RunConsoleAppFrameworkAsync(args); - log.InfoLogShouldBe(0, "x:10"); - log.InfoLogShouldBe(1, "y:20"); - } - { - var args = "opt foobarbaz -x 10 -y 20".Split(' '); - var log = new LogStack(); - await new HostBuilder() - .ConfigureTestLogging(testOutput, log, true) - .RunConsoleAppFrameworkAsync(args); - log.InfoLogShouldBe(0, "input:foobarbaz"); - log.InfoLogShouldBe(1, "x:10"); - log.InfoLogShouldBe(2, "y:20"); - } - } - - public class AliasCommand : ConsoleAppBase - { - [Command(new[] { "run", "r" })] - public void Run(string path, string pfx) - { - Context.Logger.LogInformation($"path:{path}"); - Context.Logger.LogInformation($"pfx:{pfx}"); - } - - [Command(new[] { "su", "summmm" })] - public void Sum([Option(0)]int x, [Option(1)]int y) - { - Context.Logger.LogInformation($"{x + y}"); - } - } - - [Fact] - public async Task AliasCommandTest() - { - { - var collection = new[]{ - "r -path foo -pfx bar".Split(' '), - "run -path foo -pfx bar".Split(' '), - }; - foreach (var args in collection) - { - var log = new LogStack(); - await new HostBuilder() - .ConfigureTestLogging(testOutput, log, true) - .RunConsoleAppFrameworkAsync(args); - log.InfoLogShouldBe(0, "path:foo"); - log.InfoLogShouldBe(1, "pfx:bar"); - } - } - { - { - var args = "su 10 20".Split(' '); - var log = new LogStack(); - await new HostBuilder() - .ConfigureTestLogging(testOutput, log, true) - .RunConsoleAppFrameworkAsync(args); - log.InfoLogShouldBe(0, "30"); - } - { - var args = "summmm 99 100".Split(' '); - var log = new LogStack(); - await new HostBuilder() - .ConfigureTestLogging(testOutput, log, true) - .RunConsoleAppFrameworkAsync(args); - log.InfoLogShouldBe(0, "199"); - } - } - } - - public class OverrideDefaultCommand : ConsoleAppBase - { - [Command("list")] - public void List() - { - Context.Logger.LogInformation($"lst"); - } - - [Command(new[] { "help", "h" })] - public void Help() - { - Context.Logger.LogInformation($"hlp"); - } - } - - [Fact] - public async Task OverrideDefaultCommandTest() - { - { - var args = "list".Split(' '); - var log = new LogStack(); - await new HostBuilder() - .ConfigureTestLogging(testOutput, log, true) - .RunConsoleAppFrameworkAsync(args); - log.InfoLogShouldBe(0, "lst"); - } - { - var args = "help".Split(' '); - var log = new LogStack(); - await new HostBuilder() - .ConfigureTestLogging(testOutput, log, true) - .RunConsoleAppFrameworkAsync(args); - log.InfoLogShouldBe(0, "hlp"); - } - { - var args = "h".Split(' '); - var log = new LogStack(); - await new HostBuilder() - .ConfigureTestLogging(testOutput, log, true) - .RunConsoleAppFrameworkAsync(args); - log.InfoLogShouldBe(0, "hlp"); - } - } - - public class NotFoundPath : ConsoleAppBase - { - [Command("run")] - public void Run(string path, string pfx, string thumbnail, string output, bool allowoverwrite = false) - { - Context.Logger.LogInformation($"path:{path}"); - Context.Logger.LogInformation($"pfx:{pfx}"); - Context.Logger.LogInformation($"thumbnail:{thumbnail}"); - Context.Logger.LogInformation($"output:{output}"); - Context.Logger.LogInformation($"allowoverwrite:{allowoverwrite}"); - } - } - - //[Fact] - //public async Task NotFoundPathTest() - //{ - // var args = "run -path -pfx test.pfx -thumbnail 123456 -output output.csproj -allowoverwrite".Split(' '); - // var log = new LogStack(); - - // await Assert.ThrowsAnyAsync(async () => - // { - // await new HostBuilder() - // .ConfigureTestLogging(testOutput, log, true) - // .RunConsoleAppFrameworkAsync(args); - // }); - //} - } -} diff --git a/tests/ConsoleAppFramework.Tests/XUnitLogger.cs b/tests/ConsoleAppFramework.Tests/XUnitLogger.cs deleted file mode 100644 index fc92f78..0000000 --- a/tests/ConsoleAppFramework.Tests/XUnitLogger.cs +++ /dev/null @@ -1,201 +0,0 @@ -using FluentAssertions; -using FluentAssertions.Primitives; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.IO; -using System.Text; -using Xunit.Abstractions; - -namespace ConsoleAppFramework.Tests -{ - public static class LoggingExtensions - { - public static IHostBuilder ConfigureTestLogging(this IHostBuilder builder, ITestOutputHelper testOutputHelper, LogStack logStack, bool throwExceptionOnError) - { - return builder - .ConfigureServices(x => x.AddSingleton(logStack)) - .ConfigureLogging(x => x.SetMinimumLevel(LogLevel.Trace).AddProvider(new XUnitLoggerProvider(testOutputHelper, logStack, throwExceptionOnError))); - } - } - - public class TextWriterBridge : TextWriter - { - readonly ITestOutputHelper helper; - readonly LogStack logStack; - - public TextWriterBridge(ITestOutputHelper helper, LogStack logStack) - { - this.helper = helper; - this.logStack = logStack; - } - - public override Encoding Encoding => Encoding.UTF8; - - public override void Write(string value) - { - logStack.Add(LogLevel.Information, value); - helper.WriteLine(value); - } - - public static IDisposable BeginSetConsoleOut(ITestOutputHelper helper, LogStack logStack) - { - var current = Console.Out; - Console.SetOut(new TextWriterBridge(helper, logStack)); - return new Scope(current); - } - - public struct Scope : IDisposable - { - TextWriter writer; - - public Scope(TextWriter writer) - { - this.writer = writer; - } - - public void Dispose() - { - Console.SetOut(writer); - } - } - } - - public class LogStack - { - readonly Dictionary> logs = new Dictionary>(6); - - public LogStack() - { - logs.Add(LogLevel.Trace, new List()); - logs.Add(LogLevel.Warning, new List()); - logs.Add(LogLevel.Information, new List()); - logs.Add(LogLevel.Debug, new List()); - logs.Add(LogLevel.Critical, new List()); - logs.Add(LogLevel.Error, new List()); - } - - public void Add(LogLevel level, string msg) - { - logs[level].Add(msg); - } - - public List InfoLog => logs[LogLevel.Information]; - public List ErrorLog => logs[LogLevel.Error]; - - public StringAssertions InfoLogShould(int index) => logs[LogLevel.Information][index].Should(); - public AndConstraint InfoLogShouldBe(int index, string expected) => InfoLogShould(index).Be(expected); - - public List GetLogs(LogLevel level) - { - return logs[level]; - } - - public void ClearAll() - { - foreach (var item in logs) - { - item.Value.Clear(); - } - } - - public string ToStringInfo() => ToString(LogLevel.Information); - - public string ToString(LogLevel level) - { - var sb = new StringBuilder(); - foreach (var item in GetLogs(level)) - { - sb.AppendLine(item); - } - return sb.ToString(); - } - } - - public class TestLogException : Exception - { - public TestLogException(string message) : base(message) - { - } - - public TestLogException(string message, Exception innerException) : base(message, innerException) - { - } - } - - public class XUnitLoggerProvider : ILoggerProvider - { - readonly XUnitLogger logger; - - public XUnitLoggerProvider(ITestOutputHelper testOutput, LogStack logStack, bool throwExceptionOnError) - { - logger = new XUnitLogger(testOutput, logStack, throwExceptionOnError); - } - - public ILogger CreateLogger(string categoryName) - { - return logger; - } - - public void Dispose() - { - } - } - - public class XUnitLogger : ILogger - { - readonly ITestOutputHelper testOutput; - readonly LogStack logStack; - readonly bool throwExceptionOnError; - - public XUnitLogger(ITestOutputHelper testOutput, LogStack logStack, bool throwExceptionOnError) - { - this.testOutput = testOutput; - this.logStack = logStack; - this.throwExceptionOnError = throwExceptionOnError; - } - - public IDisposable BeginScope(TState state) - { - return NullDisposable.Instance; - } - - public bool IsEnabled(LogLevel logLevel) - { - return true; - } - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) - { - var msg = formatter(state, exception); - - if (logLevel == LogLevel.Error && throwExceptionOnError) - { - throw (exception != null ? new TestLogException(msg, exception) : new TestLogException(msg)); - } - - if (!string.IsNullOrEmpty(msg)) - { - logStack.Add(logLevel, msg); - testOutput.WriteLine(msg); - } - - if (exception != null) - { - logStack.Add(logLevel, exception.ToString()); - testOutput.WriteLine(exception.ToString()); - } - } - - class NullDisposable : IDisposable - { - public static readonly IDisposable Instance = new NullDisposable(); - - public void Dispose() - { - } - } - } -}