ConsoleAppFramework v5.3.0 — Enhanced DI Integration through Auto-generated Methods from NuGet References, and More
I’ve made a relatively significant update to ConsoleAppFramework v5! For details about v5 itself, please refer to my previous article ConsoleAppFramework v5 — Zero Overhead, Native AOT-compatible CLI Framework for C#. While v5 introduced some interesting concepts that were well-received, it did sacrifice some usability aspects. This update addresses those issues, and I believe it has significantly improved the overall user experience!
Disabling Automatic Name Conversion
By default, command names and option names are automatically converted to kebab-case. While this follows standard command-line tool naming conventions, it might feel cumbersome when using the framework for internal applications or batch file creation. Therefore, we’ve added the ability to disable this conversion at the assembly level.
using ConsoleAppFramework;
[assembly: ConsoleAppFrameworkGeneratorOptions(DisableNamingConversion = true)]
var app = ConsoleApp.Create();
app.Add<MyProjectCommand>();
app.Run(args);
public class MyProjectCommand
{
public void Execute(string fooBarBaz)
{
Console.WriteLine(fooBarBaz);
}
}
The automatic conversion is disabled by [assembly: ConsoleAppFrameworkGeneratorOptions(DisableNamingConversion = true)]
. In this example, the command becomes ExecuteCommand --fooBarBaz
.
From an implementation perspective, while many Source Generators use AdditionalFiles with JSON or custom format files (like BannedSymbols.txt in BannedApiAnalyzers) to provide configuration, using files can be quite cumbersome. For setting just one or two boolean values, using assembly attributes is the most straightforward approach.
The implementation can pull this from CompilationProvider
using Assembly.GetAttributes
:
var generatorOptions = context.CompilationProvider.Select((compilation, token) =>
{
foreach (var attr in compilation.Assembly.GetAttributes())
{
if (attr.AttributeClass?.Name == "ConsoleAppFrameworkGeneratorOptionsAttribute")
{
var args = attr.NamedArguments;
var disableNamingConversion = args.FirstOrDefault(x => x.Key == "DisableNamingConversion").Value.Value as bool? ?? false;
return new ConsoleAppFrameworkGeneratorOptions(disableNamingConversion);
}
}
return new ConsoleAppFrameworkGeneratorOptions(DisableNamingConversion: false);
});
By combining this with Source from other SyntaxProviders, we can reference the attribute values during generation.
ConfigureServices/Logging/Configuration
ConsoleAppFramework v5 had a constraint where it couldn’t generate code dependent on specific libraries due to its zero-dependency principle. This meant that integrating with DI required manually building the ServiceProvider, adding an extra step for users. To address this, we’ve added functionality that analyzes NuGet DLL references and makes the ConfigureServices
method available on ConsoleAppBuilder
when Microsoft.Extensions.DependencyInjection
is referenced.
var app = ConsoleApp.Create()
.ConfigureServices(service =>
{
service.AddTransient<MyService>();
});
app.Add("", ([FromServices] MyService service, int x, int y) => Console.WriteLine(x + y));
app.Run(args);
This provides a new experience where the framework itself maintains zero dependencies while still being able to generate library-dependent code. This is achieved by pulling from MetadataReferencesProvider
and feeding it into the generation process:
var hasDependencyInjection = context.MetadataReferencesProvider
.Collect()
.Select((xs, _) =>
{
var hasDependencyInjection = false;
foreach (var x in xs)
{
var name = x.Display;
if (name == null) continue;
if (!hasDependencyInjection && name.EndsWith("Microsoft.Extensions.DependencyInjection.dll"))
{
hasDependencyInjection = true;
continue;
}
// etc...
}
return new DllReference(hasDependencyInjection, hasLogging, hasConfiguration, hasJsonConfiguration, hasHost);
});
context.RegisterSourceOutput(hasDependencyInjection, EmitConsoleAppConfigure);
Reference analysis is performed for multiple dependencies. For example, if Microsoft.Extensions.Logging
is referenced, ConfigureLogging
becomes available. This allows for clean integration with ZLogger:
// Package Import: ZLogger
var app = ConsoleApp.Create()
.ConfigureLogging(x =>
{
x.ClearProviders();
x.SetMinimumLevel(LogLevel.Trace);
x.AddZLoggerConsole();
x.AddZLoggerFile("log.txt");
});
app.Add<MyCommand>();
app.Run(args);
// inject logger to constructor
public class MyCommand(ILogger<MyCommand> logger)
{
public void Echo(string msg)
{
logger.ZLogInformation($"Message is {msg}");
}
}
Loading configuration from appsettings.json
is now a common pattern, and when Microsoft.Extensions.Configuration.Json
is referenced, ConfigureDefaultConfiguration
becomes available. This automatically performs SetBasePath(System.IO.Directory.GetCurrentDirectory())
and AddJsonFile("appsettings.json", optional: true)
(additional configuration via Action is possible, and ConfigureEmptyConfiguration is also available).
This makes it simple to read configuration, bind it to classes, and inject it into commands:
// Package Import: Microsoft.Extensions.Configuration.Json
var app = ConsoleApp.Create()
.ConfigureDefaultConfiguration()
.ConfigureServices((configuration, services) =>
{
// Package Import: Microsoft.Extensions.Options.ConfigurationExtensions
services.Configure<PositionOptions>(configuration.GetSection("Position"));
});
app.Add<MyCommand>();
app.Run(args);
// inject options
public class MyCommand(IOptions<PositionOptions> options)
{
public void Echo(string msg)
{
ConsoleApp.Log($"Binded Option: {options.Value.Title} {options.Value.Name}");
}
}
For those wanting to build with Microsoft.Extensions.Hosting
, ToConsoleAppBuilder
becomes available when Microsoft.Extensions.Hosting
is referenced:
// Package Import: Microsoft.Extensions.Hosting
var app = Host.CreateApplicationBuilder()
.ToConsoleAppBuilder();
Additionally, the configured IServiceProvider
is now automatically disposed after Run
or RunAsync
completes.
RegisterCommands from Attribute
While commands previously required Add
or Add<T>
, we've added functionality to automatically add commands through class attributes:
[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:
var app = ConsoleApp.Create();
// Commands:
// baz
// bar baz
app.Run(args);
You can still use Add
and Add<T>
alongside these attribute-based registrations.
Initially, we planned to allow arbitrary attributes, but due to IncrementalGenerator
API limitations, we're restricted to the fixed RegisterCommands
attribute. Inheritance is also not supported.
Since the v5 release, we’ve continued making improvements, including allowing filters to be defined in external assemblies and optimizing the Incremental Generator implementation for better performance. The framework has evolved into an excellent solution!
By the way, regarding System.CommandLine, they announced Resetting System.CommandLine in March due to ongoing issues. As expected, there hasn’t been much progress. This was predictable, and it’s better not to have high expectations. Using ConsoleAppFramework is a solid choice moving forward.