From d28d5db3666d0178e5a36d062604f97d1a59542d Mon Sep 17 00:00:00 2001 From: neuecc Date: Mon, 16 Dec 2024 20:01:39 +0900 Subject: [PATCH] ReadMe and other features --- .gitignore | 3 - ReadMe.md | 214 +++++++++++++----- .../CliFrameworkBenchmark.csproj | 1 + sandbox/GeneratorSandbox/Filters.cs | 4 + .../GeneratorSandbox/GeneratorSandbox.csproj | 7 +- sandbox/GeneratorSandbox/Program.cs | 135 +++++++---- sandbox/GeneratorSandbox/Temp.cs | 83 +++++++ src/ConsoleAppFramework/Command.cs | 1 + src/ConsoleAppFramework/ConsoleAppBaseCode.cs | 37 ++- .../ConsoleAppGenerator.cs | 54 ++++- src/ConsoleAppFramework/Emitter.cs | 132 +++++++++-- src/ConsoleAppFramework/Parser.cs | 3 +- src/ConsoleAppFramework/SourceBuilder.cs | 7 + .../SourceGeneratorContexts.cs | 2 +- .../CSharpGeneratorRunner.cs | 2 + .../ConfigureTest.cs | 12 + .../RegisterCommandsTest.cs | 91 ++++++++ 17 files changed, 647 insertions(+), 141 deletions(-) create mode 100644 sandbox/GeneratorSandbox/Temp.cs create mode 100644 tests/ConsoleAppFramework.GeneratorTests/ConfigureTest.cs create mode 100644 tests/ConsoleAppFramework.GeneratorTests/RegisterCommandsTest.cs diff --git a/.gitignore b/.gitignore index 58edf79..641cafe 100644 --- a/.gitignore +++ b/.gitignore @@ -140,9 +140,6 @@ publish/ # Publish Web Output *.[Pp]ublish.xml *.azurePubxml -# TODO: Comment the next line if you want to checkin your web deploy settings -# but database connection strings (with potential passwords) will be unencrypted -#*.pubxml *.publishproj # Microsoft Azure Web App publish settings. Comment the next line if you want to diff --git a/ReadMe.md b/ReadMe.md index 433b7eb..3160584 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -168,6 +168,8 @@ using ConsoleAppFramework; ConsoleApp.Run(args, (string name) => Console.WriteLine($"Hello {name}")); ``` +> The latest Visual Studio changed the execution timing of Source Generators to either during save or at compile time. If you encounter unexpected behavior, try compiling once or change the option to "Automatic" under TextEditor -> C# -> Advanced -> Source Generators. + You can execute command like `sampletool --name "foo"`. * The return value can be `void`, `int`, `Task`, or `Task` @@ -257,7 +259,7 @@ To add aliases to parameters, list the aliases separated by `|` before the comma 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. -In addition to `-h|--help`, there is another special built-in option: `--version`. In default, it displays the `AssemblyInformationalVersion` or `AssemblyVersion`. You can configure version string by `ConsoleApp.Version`, for example `ConsoleApp.Version = "2001.9.3f14-preview2";`. +In addition to `-h|--help`, there is another special built-in option: `--version`. In default, it displays the `AssemblyInformationalVersion` without source revision or `AssemblyVersion`. You can configure version string by `ConsoleApp.Version`, for example `ConsoleApp.Version = "2001.9.3f14-preview2";`. Command --- @@ -355,6 +357,43 @@ app.Add("foo"); app.Run(args); ``` +### Register from attribute + +Instead of using `Add`, you can automatically add commands by applying the `[RegisterCommands]` attribute to a class. + +```csharp +[RegisterCommands] +public class Foo +{ + public void Baz(int x) + { + Console.Write(x); + } +} + +[RegisterCommands("bar")] +public class Bar +{ + public void Baz(int x) + { + Console.Write(x); + } +} +``` + +These are automatically added when using `ConsoleApp.Create()`. + +```csharp +var app = ConsoleApp.Create(); + +// Commands: +// baz +// bar baz +app.Run(args); +``` + +You can also combine this with `Add` or `Add` to add more commands. + ### 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: @@ -454,6 +493,30 @@ partial void RunCore(string[] args) 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. +Disable Naming Conversion +--- +Command names and option names are automatically converted to kebab-case by default. While this follows standard command-line tool naming conventions, you might find this conversion inconvenient when creating batch files for internal applications. Therefore, it's possible to disable this conversion at the assembly level. + +```csharp +using ConsoleAppFramework; + +[assembly: ConsoleAppFrameworkGeneratorOptions(DisableNamingConversion = true)] + +var app = ConsoleApp.Create(); +app.Add(); +app.Run(args); + +public class MyProjectCommand +{ + public void ExecuteCommand(string fooBarBaz) + { + Console.WriteLine(fooBarBaz); + } +} +``` + +You can disable automatic conversion by using `[assembly: ConsoleAppFrameworkGeneratorOptions(DisableNamingConversion = true)]`. In this case, the command would be `ExecuteCommand --fooBarBaz`. + Parse and Value Binding --- 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. @@ -824,49 +887,41 @@ Dependency Injection(Logging, Configuration, etc...) --- 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. +If you are referencing `Microsoft.Extensions.DependencyInjection`, you can call the `ConfigureServices` method from `ConsoleApp.ConsoleAppBuilder` (ConsoleAppFramework adds methods based on your project's reference status). ```csharp -// Microsoft.Extensions.DependencyInjection -var services = new ServiceCollection(); -services.AddTransient(); +var app = ConsoleApp.Create() + .ConfigureServices(service => + { + service.AddTransient(); + }); -using var serviceProvider = services.BuildServiceProvider(); +app.Add("", ([FromServices] MyService service, int x, int y) => Console.WriteLine(x + y)); -// Any DI library can be used as long as it can create an IServiceProvider -ConsoleApp.ServiceProvider = serviceProvider; - -// 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)); +app.Run(args); ``` 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). - +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). If you are referencing `Microsoft.Extensions.Logging`, you can call `ConfigureLogging` from `ConsoleAppBuilder`. ```csharp // Package Import: ZLogger -var services = new ServiceCollection(); -services.AddLogging(x => -{ - x.ClearProviders(); - x.SetMinimumLevel(LogLevel.Trace); - x.AddZLoggerConsole(); - x.AddZLoggerFile("log.txt"); -}); - -using var serviceProvider = services.BuildServiceProvider(); // using for logger flush(important!) -ConsoleApp.ServiceProvider = serviceProvider; +var app = ConsoleApp.Create() + .ConfigureLogging(x => + { + x.ClearProviders(); + x.SetMinimumLevel(LogLevel.Trace); + x.AddZLoggerConsole(); + x.AddZLoggerFile("log.txt"); + }); -var app = ConsoleApp.Create(); app.Add(); app.Run(args); // inject logger to constructor public class MyCommand(ILogger logger) { - [Command("")] public void Echo(string msg) { logger.ZLogInformation($"Message is {msg}"); @@ -874,16 +929,39 @@ public class MyCommand(ILogger logger) } ``` -`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. +For building an `IServiceProvider`, `ConfigureServices/ConfigureLogging` uses `Microsoft.Extensions.DependencyInjection.ServiceCollection`. If you want to set a custom ServiceProvider or a ServiceProvider built from Host, or if you want to execute DI with `ConsoleApp.Run`, set it to `ConsoleApp.ServiceProvider`. ```csharp -using var serviceProvider = services.BuildServiceProvider(); // using for cleanup(important) +// Microsoft.Extensions.DependencyInjection +var services = new ServiceCollection(); +services.AddTransient(); + +using var serviceProvider = services.BuildServiceProvider(); + +// Any DI library can be used as long as it can create an IServiceProvider ConsoleApp.ServiceProvider = serviceProvider; -// setup ConsoleApp system logger -var logger = serviceProvider.GetRequiredService>(); -ConsoleApp.Log = msg => logger.LogInformation(msg); -ConsoleApp.LogError = msg => logger.LogError(msg); +// 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)); +``` + +`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 +app.UseFilter(); + +// inject logger to filter +internal sealed class ReplaceLogFilter(ConsoleAppFilter next, ILogger logger) + : ConsoleAppFilter(next) +{ + public override Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) + { + ConsoleApp.Log = msg => logger.LogInformation(msg); + ConsoleApp.LogError = msg => logger.LogError(msg); + + return Next.InvokeAsync(context, cancellationToken); + } +} ``` DI can also be effectively used when reading application configuration from `appsettings.json`. For example, suppose you have the following JSON file. @@ -899,30 +977,32 @@ DI can also be effectively used when reading application configuration from `app } ``` +```xml + + + PreserveNewest + + +``` + Using `Microsoft.Extensions.Configuration.Json`, reading, binding, and registering with DI can be done as follows. ```csharp // Package Import: Microsoft.Extensions.Configuration.Json -var configuration = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("appsettings.json") - .Build(); - -// Bind to services( Package Import: Microsoft.Extensions.Options.ConfigurationExtensions ) -var services = new ServiceCollection(); -services.Configure(configuration.GetSection("Position")); - -using var serviceProvider = services.BuildServiceProvider(); -ConsoleApp.ServiceProvider = serviceProvider; +var app = ConsoleApp.Create() + .ConfigureDefaultConfiguration() + .ConfigureServices((configuration, services) => + { + // Package Import: Microsoft.Extensions.Options.ConfigurationExtensions + services.Configure(configuration.GetSection("Position")); + }); -var app = ConsoleApp.Create(); app.Add(); app.Run(args); // inject options public class MyCommand(IOptions options) { - [Command("")] public void Echo(string msg) { ConsoleApp.Log($"Binded Option: {options.Value.Title} {options.Value.Name}"); @@ -936,25 +1016,23 @@ public class PositionOptions } ``` -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. +When `Microsoft.Extensions.Configuration.Abstractions` is imported, `ConfigureEmptyConfiguration` becomes available to call. Additionally, when `Microsoft.Extensions.Configuration.Json` is imported, `ConfigureDefaultConfiguration` becomes available to call. In DefaultConfiguration, `SetBasePath(System.IO.Directory.GetCurrentDirectory())` and `AddJsonFile("appsettings.json", optional: true)` are executed before calling `Action configure`. -```csharp -// Package Import: Microsoft.Extensions.Hosting -var builder = Host.CreateApplicationBuilder(); // don't pass args. +Furthermore, overloads of `Action configure` and `Action configure` are added to `ConfigureServices` and `ConfigureLogging`, allowing you to retrieve the Configuration when executing the delegate. -using var host = builder.Build(); // use using for host lifetime -using var scope = host.Services.CreateScope(); // create execution scope -ConsoleApp.ServiceProvider = scope.ServiceProvider; // use host scoped ServiceProvider +without Hosting dependency, I've prefere these import packages. -ConsoleApp.Run(args, ([FromServices] ILogger logger) => logger.LogInformation("Hello World!")); +```xml + + + + + ``` -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. - 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 -var app = ConsoleApp.Create(); app.UseFilter(); internal class ServiceProviderScopeFilter(IServiceProvider serviceProvider, ConsoleAppFilter next) : ConsoleAppFilter(next) @@ -963,13 +1041,35 @@ internal class ServiceProviderScopeFilter(IServiceProvider serviceProvider, Cons { // create Microsoft.Extensions.DependencyInjection scope await using var scope = serviceProvider.CreateAsyncScope(); - await Next.InvokeAsync(context, cancellationToken); + + var originalServiceProvider = ConsoleApp.ServiceProvider; + ConsoleApp.ServiceProvider = scope.ServiceProvider; + try + { + await Next.InvokeAsync(context, cancellationToken); + } + finally + { + ConsoleApp.ServiceProvider = originalServiceProvider; + } } } ``` However, since the construction of the filters is performed before execution, automatic injection using scopes is only effective for the command body itself. +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 call `ToConsoleAppBuilder` from `IHostBuilder` or `HostApplicationBuilder`. + +```csharp +// Package Import: Microsoft.Extensions.Hosting +var app = Host.CreateApplicationBuilder() + .ToConsoleAppBuilder(); +``` + +In this case, it builds the HostBuilder, creates a Scope for the ServiceProvider, and disposes of all of them after execution. + +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. + Colorize --- The framework doesn't support colorization directly; however, utilities like [Cysharp/Kokuban](https://github.com/Cysharp/Kokuban) make console colorization easy. diff --git a/sandbox/CliFrameworkBenchmark/CliFrameworkBenchmark.csproj b/sandbox/CliFrameworkBenchmark/CliFrameworkBenchmark.csproj index 184a2be..2acf4fe 100644 --- a/sandbox/CliFrameworkBenchmark/CliFrameworkBenchmark.csproj +++ b/sandbox/CliFrameworkBenchmark/CliFrameworkBenchmark.csproj @@ -23,6 +23,7 @@ + diff --git a/sandbox/GeneratorSandbox/Filters.cs b/sandbox/GeneratorSandbox/Filters.cs index a05e99c..a05c887 100644 --- a/sandbox/GeneratorSandbox/Filters.cs +++ b/sandbox/GeneratorSandbox/Filters.cs @@ -1,5 +1,7 @@  using ConsoleAppFramework; +using System.ComponentModel.DataAnnotations; + // using Microsoft.Extensions.DependencyInjection; using System.Diagnostics; using System.Reflection; @@ -30,6 +32,8 @@ public override async Task InvokeAsync(ConsoleAppContext context, CancellationTo } } + + internal class AuthenticationFilter(ConsoleAppFilter next) : ConsoleAppFilter(next) { public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) diff --git a/sandbox/GeneratorSandbox/GeneratorSandbox.csproj b/sandbox/GeneratorSandbox/GeneratorSandbox.csproj index 24c9fbe..b8b17e0 100644 --- a/sandbox/GeneratorSandbox/GeneratorSandbox.csproj +++ b/sandbox/GeneratorSandbox/GeneratorSandbox.csproj @@ -15,10 +15,9 @@ - - - - + diff --git a/sandbox/GeneratorSandbox/Program.cs b/sandbox/GeneratorSandbox/Program.cs index a557efd..a370a53 100644 --- a/sandbox/GeneratorSandbox/Program.cs +++ b/sandbox/GeneratorSandbox/Program.cs @@ -1,45 +1,109 @@ #nullable enable using ConsoleAppFramework; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using System.Reflection; -using ZLogger; - -[assembly: ConsoleAppFrameworkGeneratorOptions(DisableNamingConversion = true)] - - - -var app = ConsoleApp.Create() - ; - -app.ConfigureDefaultConfiguration(); - -app.ConfigureServices(services => +//using Microsoft.Extensions.Configuration; +//using Microsoft.Extensions.DependencyInjection; +//// using Microsoft.Extensions.Hosting; +//using Microsoft.Extensions.Logging; +//using Microsoft.Extensions.Options; +//using ZLogger; + +//args = ["echo", "--msg", "zzzz"]; + +//// IHostBuilder +//// HostApplicationBuilder +////var app = Host.CreateApplicationBuilder() +//// .ToConsoleAppBuilder(); +//// appBuilder.Build(); + +//// Package Import: Microsoft.Extensions.Configuration.Json +//var app = ConsoleApp.Create() +// .ConfigureDefaultConfiguration() +// .ConfigureServices((configuration, services) => +// { +// // Microsoft.Extensions.Options.ConfigurationExtensions +// services.Configure(configuration.GetSection("Position")); +// }); + +//app.Add(); +//app.Run(args); + +ConsoleApp.Run(args, () => { }); + +// inject options +//public class MyCommand(IOptions options) +//{ +// public void Echo(string msg) +// { +// ConsoleApp.Log($"Binded Option: {options.Value.Title} {options.Value.Name}"); +// } +//} + +//public class PositionOptions +//{ +// public string Title { get; set; } = ""; +// public string Name { get; set; } = ""; +//} + +//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(); + +// var originalServiceProvider = ConsoleApp.ServiceProvider; +// ConsoleApp.ServiceProvider = scope.ServiceProvider; +// try +// { +// await Next.InvokeAsync(context, cancellationToken); +// } +// finally +// { +// ConsoleApp.ServiceProvider = originalServiceProvider; +// } +// } +//} + + + +//// inject logger to filter +//internal class ReplaceLogFilter(ConsoleAppFilter next, ILogger logger) +// : ConsoleAppFilter(next) +//{ +// public override Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) +// { +// ConsoleApp.Log = msg => logger.LogInformation(msg); +// ConsoleApp.LogError = msg => logger.LogError(msg); + +// return Next.InvokeAsync(context, cancellationToken); +// } +//} + +class MyProvider : IServiceProvider, IAsyncDisposable { + public void Dispose() + { + Console.WriteLine("disposed"); + } -}); - - // .ConfigureLogging( - // .ConfigureDefaultConfiguration() - // ; - -app.Add("", () => { }); - -app.Run(args); - - + public ValueTask DisposeAsync() + { + Console.WriteLine("dispose async"); + return default; + } -public class MyProjectCommand -{ - public void Execute(int x) + public object? GetService(Type serviceType) { - Console.WriteLine("Hello?"); + return null; } } +public class MyService +{ + +} + public class MyCommands { @@ -188,11 +252,4 @@ public class Batch2Attribute : BatchAttribute } - [RegisterCommands, Batch] - public class Takoyaki - { - public void Error12345() - { - } - } } \ No newline at end of file diff --git a/sandbox/GeneratorSandbox/Temp.cs b/sandbox/GeneratorSandbox/Temp.cs new file mode 100644 index 0000000..ae0fa9a --- /dev/null +++ b/sandbox/GeneratorSandbox/Temp.cs @@ -0,0 +1,83 @@ +//// +//#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 +//#pragma warning disable CS8625 + +//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; + +//using Microsoft.Extensions.DependencyInjection; +//using Microsoft.Extensions.Logging; +//using Microsoft.Extensions.Configuration; +//using Microsoft.Extensions.Hosting; + +//internal static class ConsoleAppHostBuilderExtensions +//{ +// class CompositeDisposableServiceProvider(IDisposable host, IServiceProvider serviceServiceProvider, IDisposable scope, IServiceProvider serviceProvider) +// : IServiceProvider, IDisposable +// { +// public object? GetService(Type serviceType) +// { +// return serviceProvider.GetService(serviceType); +// } + +// public void Dispose() +// { +// if (serviceProvider is IDisposable d) +// { +// d.Dispose(); +// } +// scope.Dispose(); +// if (serviceServiceProvider is IDisposable d2) +// { +// d2.Dispose(); +// } +// host.Dispose(); +// } +// } + +// internal static ConsoleApp.ConsoleAppBuilder ToConsoleAppBuilder(this IHostBuilder hostBuilder) +// { +// var host = hostBuilder.Build(); +// var serviceServiceProvider = host.Services; +// var scope = serviceServiceProvider.CreateScope(); +// var serviceProvider = scope.ServiceProvider; +// ConsoleApp.ServiceProvider = new CompositeDisposableServiceProvider(host, serviceServiceProvider, scope, serviceProvider); + +// return ConsoleApp.Create(); +// } + +// internal static ConsoleApp.ConsoleAppBuilder ToConsoleAppBuilder(this HostApplicationBuilder hostBuilder) +// { +// var host = hostBuilder.Build(); +// var serviceServiceProvider = host.Services; +// var scope = serviceServiceProvider.CreateScope(); +// var serviceProvider = scope.ServiceProvider; +// ConsoleApp.ServiceProvider = new CompositeDisposableServiceProvider(host, serviceServiceProvider, scope, serviceProvider); + +// return ConsoleApp.Create(); +// } +//} diff --git a/src/ConsoleAppFramework/Command.cs b/src/ConsoleAppFramework/Command.cs index 5833f9c..1f74ddf 100644 --- a/src/ConsoleAppFramework/Command.cs +++ b/src/ConsoleAppFramework/Command.cs @@ -28,6 +28,7 @@ public record class Command public required DelegateBuildType DelegateBuildType { get; init; } public CommandMethodInfo? CommandMethodInfo { get; set; } // can set...! public required EquatableArray Filters { get; init; } + public IgnoreEquality Symbol { get; init; } public bool HasFilter => Filters.Length != 0; // return is delegateType(Name). diff --git a/src/ConsoleAppFramework/ConsoleAppBaseCode.cs b/src/ConsoleAppFramework/ConsoleAppBaseCode.cs index 2134093..7ae8037 100644 --- a/src/ConsoleAppFramework/ConsoleAppBaseCode.cs +++ b/src/ConsoleAppFramework/ConsoleAppBaseCode.cs @@ -469,15 +469,42 @@ public void UseFilter() where T : ConsoleAppFilter { } public void Run(string[] args) { BuildAndSetServiceProvider(); - RunCore(args); + try + { + RunCore(args); + } + finally + { + if (ServiceProvider is IDisposable d) + { + d.Dispose(); + } + } } - public Task RunAsync(string[] args) + public async Task RunAsync(string[] args) { BuildAndSetServiceProvider(); - Task? task = null; - RunAsyncCore(args, ref task!); - return task ?? Task.CompletedTask; + try + { + Task? task = null; + RunAsyncCore(args, ref task!); + if (task != null) + { + await task; + } + } + finally + { + if (ServiceProvider is IAsyncDisposable ad) + { + await ad.DisposeAsync(); + } + else if (ServiceProvider is IDisposable d) + { + d.Dispose(); + } + } } [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/ConsoleAppFramework/ConsoleAppGenerator.cs b/src/ConsoleAppFramework/ConsoleAppGenerator.cs index 7a275ea..99b95a3 100644 --- a/src/ConsoleAppFramework/ConsoleAppGenerator.cs +++ b/src/ConsoleAppFramework/ConsoleAppGenerator.cs @@ -15,7 +15,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // Emit ConsoleApp.g.cs context.RegisterPostInitializationOutput(EmitConsoleAppTemplateSource); - // Emti ConfigureConfiguration/Logging/Services + // Emti ConfigureConfiguration/Logging/Services and Host.AsConsoleApp var hasDependencyInjection = context.MetadataReferencesProvider .Collect() .Select((xs, _) => @@ -23,11 +23,12 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var hasDependencyInjection = false; var hasLogging = false; var hasConfiguration = false; + var hasJsonConfiguration = false; + var hasHostAbstraction = false; + var hasHost = false; foreach (var x in xs) { - if (hasDependencyInjection && hasLogging && hasConfiguration) break; - var name = x.Display; if (name == null) continue; @@ -49,9 +50,26 @@ public void Initialize(IncrementalGeneratorInitializationContext context) continue; } + if (!hasJsonConfiguration && name.EndsWith("Microsoft.Extensions.Configuration.Json.dll")) + { + hasJsonConfiguration = true; + continue; + } + + if (!hasHostAbstraction && name.EndsWith("Microsoft.Extensions.Hosting.Abstractions.dll")) + { + hasHostAbstraction = true; + continue; + } + + if (!hasHost && name.EndsWith("Microsoft.Extensions.Hosting.dll")) + { + hasHost = true; + continue; + } } - return new DllReference(hasDependencyInjection, hasLogging, hasConfiguration); + return new DllReference(hasDependencyInjection, hasLogging, hasConfiguration, hasJsonConfiguration, hasHostAbstraction, hasHost); }); context.RegisterSourceOutput(hasDependencyInjection, EmitConsoleAppConfigure); @@ -143,7 +161,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .Where(x => { var model = x.Model.GetTypeInfo((x.Node.Expression as MemberAccessExpressionSyntax)!.Expression, x.CancellationToken); - return model.Type?.Name is "ConsoleAppBuilder"; + return model.Type?.Name is "ConsoleAppBuilder" or "IHostBuilder" || model.Type?.Kind == SymbolKind.ErrorType; // allow ErrorType(ConsoleAppBuilder from Configure***(Source Generator generated method) is unknown in Source Generator) }) .WithTrackingName("ConsoleApp.Builder.1_Where") .Collect() @@ -157,12 +175,14 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .Collect(); var combined = builderSource.Combine(registerCommands) + .WithTrackingName("ConsoleApp.Builder.3_Combined") .Select((tuple, token) => { var (context, commands) = tuple; context.AddRegisterAttributes(commands); return context; - }); + }) + .WithTrackingName("ConsoleApp.Builder.4_CombineSelected"); context.RegisterSourceOutput(combined, EmitConsoleAppBuilder); } @@ -271,9 +291,8 @@ static void EmitConsoleAppBuilder(SourceProductionContext sourceProductionContex static void EmitConsoleAppConfigure(SourceProductionContext sourceProductionContext, DllReference dllReference) { - if (!dllReference.HasDependencyInjection && !dllReference.HasLogging && !dllReference.HasConfiguration) + if (!dllReference.HasDependencyInjection && !dllReference.HasLogging && !dllReference.HasConfiguration && !dllReference.HasHost && !dllReference.HasHostAbstraction) { - sourceProductionContext.AddSource("ConsoleApp.Builder.Configure.g.cs", ""); return; } @@ -288,11 +307,20 @@ static void EmitConsoleAppConfigure(SourceProductionContext sourceProductionCont { sb.AppendLine("using Microsoft.Extensions.Logging;"); } - if (dllReference.HasConfiguration) + if (dllReference.HasConfiguration || dllReference.HasJsonConfiguration) { sb.AppendLine("using Microsoft.Extensions.Configuration;"); } + if (dllReference.HasHost || dllReference.HasHostAbstraction) + { + var sb2 = sb.Clone(); + sb2.AppendLine("using Microsoft.Extensions.Hosting;"); + var emitter = new Emitter(); + emitter.EmitAsConsoleAppBuilder(sb2, dllReference); + sourceProductionContext.AddSource("ConsoleAppHostBuilderExtensions.g.cs", sb2.ToString()); + } + using (sb.BeginBlock("internal static partial class ConsoleApp")) using (sb.BeginBlock("internal partial class ConsoleAppBuilder")) { @@ -436,7 +464,7 @@ public CollectBuilderContext(ConsoleAppFrameworkGeneratorOptions generatorOption } // set properties - this.Commands = commands1.Concat(commands2!).ToArray()!; // not null if no diagnostics + this.Commands = commands1.Concat(commands2!).Where(x => x != null).ToArray()!; this.HasRun = methodGroup["Run"].Any(); this.HasRunAsync = methodGroup["RunAsync"].Any(); } @@ -472,7 +500,9 @@ public void AddRegisterAttributes(ImmutableArray x != null).ToArray(); } public bool Equals(CollectBuilderContext other) diff --git a/src/ConsoleAppFramework/Emitter.cs b/src/ConsoleAppFramework/Emitter.cs index a1c8cd7..45e9bde 100644 --- a/src/ConsoleAppFramework/Emitter.cs +++ b/src/ConsoleAppFramework/Emitter.cs @@ -324,6 +324,30 @@ public void EmitRun(SourceBuilder sb, CommandWithId commandWithId, bool isRunAsy sb.AppendLine("LogError(ex.ToString());"); } } + if (!emitForBuilder) + { + using (sb.BeginBlock("finally")) + { + if (!isRunAsync) + { + using (sb.BeginBlock("if (ServiceProvider is IDisposable d)")) + { + sb.AppendLine("d.Dispose();"); + } + } + else + { + using (sb.BeginBlock("if (ServiceProvider is IAsyncDisposable ad)")) + { + sb.AppendLine("await ad.DisposeAsync();"); + } + using (sb.BeginBlock("else if (ServiceProvider is IDisposable d)")) + { + sb.AppendLine("d.Dispose();"); + } + } + } + } } } } @@ -613,27 +637,30 @@ public void EmitConfigure(SourceBuilder sb, DllReference dllReference) sb.AppendLine("bool requireConfiguration;"); sb.AppendLine("IConfiguration? configuration;"); - sb.AppendLine(); - sb.AppendLine("/// Create configuration with SetBasePath(Directory.GetCurrentDirectory()) and AddJsonFile(appsettings.json)."); - using (sb.BeginBlock("public ConsoleApp.ConsoleAppBuilder ConfigureDefaultConfiguration()")) + if (dllReference.HasJsonConfiguration) { - sb.AppendLine("var config = new ConfigurationBuilder();"); - sb.AppendLine("config.SetBasePath(System.IO.Directory.GetCurrentDirectory());"); - sb.AppendLine("config.AddJsonFile(\"appsettings.json\", optional: true);"); - sb.AppendLine("configuration = config.Build();"); - sb.AppendLine("return this;"); - } + sb.AppendLine(); + sb.AppendLine("/// Create configuration with SetBasePath(Directory.GetCurrentDirectory()) and AddJsonFile(appsettings.json)."); + using (sb.BeginBlock("public ConsoleApp.ConsoleAppBuilder ConfigureDefaultConfiguration()")) + { + sb.AppendLine("var config = new ConfigurationBuilder();"); + sb.AppendLine("config.SetBasePath(System.IO.Directory.GetCurrentDirectory());"); + sb.AppendLine("config.AddJsonFile(\"appsettings.json\", optional: true);"); + sb.AppendLine("configuration = config.Build();"); + sb.AppendLine("return this;"); + } - sb.AppendLine(); - sb.AppendLine("/// Create configuration with SetBasePath(Directory.GetCurrentDirectory()) and AddJsonFile(appsettings.json)."); - using (sb.BeginBlock("public ConsoleApp.ConsoleAppBuilder ConfigureDefaultConfiguration(Action configure)")) - { - sb.AppendLine("var config = new ConfigurationBuilder();"); - sb.AppendLine("config.SetBasePath(System.IO.Directory.GetCurrentDirectory());"); - sb.AppendLine("config.AddJsonFile(\"appsettings.json\", optional: true);"); - sb.AppendLine("configure(config);"); - sb.AppendLine("configuration = config.Build();"); - sb.AppendLine("return this;"); + sb.AppendLine(); + sb.AppendLine("/// Create configuration with SetBasePath(Directory.GetCurrentDirectory()) and AddJsonFile(appsettings.json)."); + using (sb.BeginBlock("public ConsoleApp.ConsoleAppBuilder ConfigureDefaultConfiguration(Action configure)")) + { + sb.AppendLine("var config = new ConfigurationBuilder();"); + sb.AppendLine("config.SetBasePath(System.IO.Directory.GetCurrentDirectory());"); + sb.AppendLine("config.AddJsonFile(\"appsettings.json\", optional: true);"); + sb.AppendLine("configure(config);"); + sb.AppendLine("configuration = config.Build();"); + sb.AppendLine("return this;"); + } } sb.AppendLine(); @@ -783,6 +810,73 @@ public void EmitConfigure(SourceBuilder sb, DllReference dllReference) } } + public void EmitAsConsoleAppBuilder(SourceBuilder sb, DllReference dllReference) + { + sb.AppendLine(""" + +internal static class ConsoleAppHostBuilderExtensions +{ + class CompositeDisposableServiceProvider(IDisposable host, IServiceProvider serviceServiceProvider, IDisposable scope, IServiceProvider serviceProvider) + : IServiceProvider, IDisposable + { + public object? GetService(Type serviceType) + { + return serviceProvider.GetService(serviceType); + } + + public void Dispose() + { + if (serviceProvider is IDisposable d) + { + d.Dispose(); + } + scope.Dispose(); + if (serviceServiceProvider is IDisposable d2) + { + d2.Dispose(); + } + host.Dispose(); + } + } + +"""); + + if (dllReference.HasHostAbstraction) + { + sb.AppendLine(""" + internal static ConsoleApp.ConsoleAppBuilder ToConsoleAppBuilder(this IHostBuilder hostBuilder) + { + var host = hostBuilder.Build(); + var serviceServiceProvider = host.Services; + var scope = serviceServiceProvider.CreateScope(); + var serviceProvider = scope.ServiceProvider; + ConsoleApp.ServiceProvider = new CompositeDisposableServiceProvider(host, serviceServiceProvider, scope, serviceProvider); + + return ConsoleApp.Create(); + } + +"""); + } + + if (dllReference.HasHost) + { + sb.AppendLine(""" + internal static ConsoleApp.ConsoleAppBuilder ToConsoleAppBuilder(this HostApplicationBuilder hostBuilder) + { + var host = hostBuilder.Build(); + var serviceServiceProvider = host.Services; + var scope = serviceServiceProvider.CreateScope(); + var serviceProvider = scope.ServiceProvider; + ConsoleApp.ServiceProvider = new CompositeDisposableServiceProvider(host, serviceServiceProvider, scope, serviceProvider); + + return ConsoleApp.Create(); + } +"""); + + sb.AppendLine("}"); + } + } + internal record CommandWithId(string? FieldType, Command Command, int Id) { public static string BuildCustomDelegateTypeName(int id) diff --git a/src/ConsoleAppFramework/Parser.cs b/src/ConsoleAppFramework/Parser.cs index fff1b0e..0ddb85a 100644 --- a/src/ConsoleAppFramework/Parser.cs +++ b/src/ConsoleAppFramework/Parser.cs @@ -385,7 +385,7 @@ internal class Parser(ConsoleAppFrameworkGeneratorOptions generatorOptions, Diag MethodKind = MethodKind.Lambda, Description = "", DelegateBuildType = delegateBuildType, - Filters = globalFilters, + Filters = globalFilters }; return cmd; @@ -560,6 +560,7 @@ internal class Parser(ConsoleAppFrameworkGeneratorOptions generatorOptions, Diag Description = summary, DelegateBuildType = delegateBuildType, Filters = globalFilters.Concat(typeFilters).Concat(methodFilters).ToArray(), + Symbol = new IgnoreEquality(methodSymbol) }; return cmd; diff --git a/src/ConsoleAppFramework/SourceBuilder.cs b/src/ConsoleAppFramework/SourceBuilder.cs index b16db5b..e3fb8b9 100644 --- a/src/ConsoleAppFramework/SourceBuilder.cs +++ b/src/ConsoleAppFramework/SourceBuilder.cs @@ -100,6 +100,13 @@ public void Dispose() } } + public SourceBuilder Clone() + { + var sb = new SourceBuilder(level); + sb.builder.Append(builder.ToString()); + return sb; + } + class NullDisposable : IDisposable { public static readonly IDisposable Instance = new NullDisposable(); diff --git a/src/ConsoleAppFramework/SourceGeneratorContexts.cs b/src/ConsoleAppFramework/SourceGeneratorContexts.cs index 2dec396..abbe03d 100644 --- a/src/ConsoleAppFramework/SourceGeneratorContexts.cs +++ b/src/ConsoleAppFramework/SourceGeneratorContexts.cs @@ -2,4 +2,4 @@ readonly record struct ConsoleAppFrameworkGeneratorOptions(bool DisableNamingConversion); -readonly record struct DllReference(bool HasDependencyInjection, bool HasLogging, bool HasConfiguration); \ No newline at end of file +readonly record struct DllReference(bool HasDependencyInjection, bool HasLogging, bool HasConfiguration, bool HasJsonConfiguration, bool HasHostAbstraction, bool HasHost); \ No newline at end of file diff --git a/tests/ConsoleAppFramework.GeneratorTests/CSharpGeneratorRunner.cs b/tests/ConsoleAppFramework.GeneratorTests/CSharpGeneratorRunner.cs index 9832e31..0711248 100644 --- a/tests/ConsoleAppFramework.GeneratorTests/CSharpGeneratorRunner.cs +++ b/tests/ConsoleAppFramework.GeneratorTests/CSharpGeneratorRunner.cs @@ -49,6 +49,8 @@ public static (Compilation, ImmutableArray) RunGenerator([StringSynt driver = (Microsoft.CodeAnalysis.CSharp.CSharpGeneratorDriver)driver.WithUpdatedAnalyzerConfigOptions(options); } + // MetadataReference.CreateFromFile("", MetadataReferenceProperties. + var compilation = baseCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(source, parseOptions)); driver.RunGeneratorsAndUpdateCompilation(compilation, out var newCompilation, out var diagnostics); diff --git a/tests/ConsoleAppFramework.GeneratorTests/ConfigureTest.cs b/tests/ConsoleAppFramework.GeneratorTests/ConfigureTest.cs new file mode 100644 index 0000000..79d67dd --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/ConfigureTest.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace ConsoleAppFramework.GeneratorTests; + +public class ConfigureTest(ITestOutputHelper output) +{ + readonly VerifyHelper verifier = new(output, "CAF"); +} diff --git a/tests/ConsoleAppFramework.GeneratorTests/RegisterCommandsTest.cs b/tests/ConsoleAppFramework.GeneratorTests/RegisterCommandsTest.cs new file mode 100644 index 0000000..a119c19 --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/RegisterCommandsTest.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace ConsoleAppFramework.GeneratorTests; + +public class RegisterCommandsTest(ITestOutputHelper output) +{ + readonly VerifyHelper verifier = new(output, "CAF"); + + [Fact] + public void VerifyDuplicate() + { + verifier.Verify(7, """ +var app = ConsoleApp.Create(); +app.Run(args); + +[RegisterCommands] +public class Foo +{ + public void Bar(int x) + { + Console.Write(x); + } + + public void Baz(int y) + { + Console.Write(y); + } +} + +[RegisterCommands] +public class Hoge +{ + public void Bar(int x) + { + Console.Write(x); + } + + public void Baz(int y) + { + Console.Write(y); + } +} +""", "Bar"); + } + + [Fact] + public void Exec() + { + var code = """ +var app = ConsoleApp.Create(); +app.Run(args); + +[RegisterCommands] +public class Foo +{ + public void Bar(int x) + { + Console.Write(x); + } + + public void Baz(int y) + { + Console.Write(y); + } +} + +[RegisterCommands("hoge")] +public class Hoge +{ + public void Bar(int x) + { + Console.Write(x); + } + + public void Baz(int y) + { + Console.Write(y); + } +} +"""; + + verifier.Execute(code, "bar --x 10", "10"); + verifier.Execute(code, "baz --y 20", "20"); + verifier.Execute(code, "hoge bar --x 10", "10"); + verifier.Execute(code, "hoge baz --y 20", "20"); + } +}