Skip to content

Commit 1684d6b

Browse files
committed
Move initialization to static constructor
Update docs
1 parent 19f19f3 commit 1684d6b

File tree

3 files changed

+95
-89
lines changed

3 files changed

+95
-89
lines changed

README.md

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,25 @@ The library is available in a NuGet package:
66
- [SnapCLI](https://www.nuget.org/packages/SnapCLI/)
77

88
# Motivation
9-
The goal of this project is to provide a simple and effective way to handle command-line commands and parameters, allowing developers to create POSIX-like CLI applications with minimal hassle in parsing the command line and enabling them to focus on application logic. Additionally, it facilitates providing all necessary information for the application's help system, making it easily accessible to end users. The [DragonFruit](https://github.com/dotnet/command-line-api/blob/main/docs/DragonFruit-overview.md) project was a step in this direction, but is very limited in abilities it provides.
9+
The goal of this project is to enable developers to create POSIX-like Command Line Interface (CLI) applications without the need to parse the command line themselves, allowing them to focus on application logic. The library automatically handles command-line commands and parameters using the provided metadata, simplifying the development process. It also streamlines the creation of the application's help system, ensuring that all necessary information is easily accessible to end users.
10+
11+
The [DragonFruit](https://github.com/dotnet/command-line-api/blob/main/docs/DragonFruit-overview.md) project was a step in this direction, but is very limited in abilities it provides.
1012

1113
# API Paradigm
1214
The API paradigm of this project is to use [attributes](https://learn.microsoft.com/en-us/dotnet/csharp/advanced-topics/reflection-and-attributes/) to declare and describe CLI commands, options, and arguments.
1315

1416
Any public static method can be declared as a CLI command handler using the `[Command]` attribute, and effectively represent an entry point to the CLI application for that command. Any parameter of command handler method automatically becomes a command option. See the [usage](#usage) section and examples below for more details.
1517

1618
## What about classes?
17-
There are multiple CLI frameworks that require separate class implementation for each command. In my opinion, creating a per-command classes adds unnecessary bloat to the code with little to no benefit. To provide additional information such as descriptions and aliases, attributes are anyway required on top of the class declaration. Since the goal is to simplify things as much as possible, I decided not to use classes at all in my approach. While this approach may not be as flexible as some other solutions, it meets the basic needs of most CLI applications.
19+
Many CLI frameworks require separate class implementations for each command. In my opinion, creating individual classes for each command adds unnecessary bloat to the code with minimal benefit. Using attributes is easier to maintain and understand, as they are declared close to the entities they describe, keeping all related information in one place. Additionally, attributes allow for extra details, such as descriptions and aliases. Since the goal is to simplify the implementation as much as possible, I decided not to use classes at all in my approach. While this method may not be as flexible as some other solutions, it effectively meets the needs of most CLI applications.
1820

1921
## Command line syntax
2022
Since this project is based on the [System.CommandLine](https://learn.microsoft.com/en-us/dotnet/standard/commandline/) library, the parsing rules are exactly the same as those for that package. The Microsoft documentation provides detailed explanations of the [command-line syntax](https://learn.microsoft.com/en-us/dotnet/standard/commandline/syntax) recognized by `System.CommandLine`. I will include more links to this documentation throughout the text below.
2123

2224
## Main method
23-
Normally, the `Main` method is the entry point of a C# application. However, to simplify startup code and usage, this library overrides the program's entry point and uses command handler methods as the entry points instead. This means that if you include your own `Main` function in the program, it will **not** be invoked. If you need some initialization code to run before command, it can be placed in [Startup](#startup) method.
25+
Typically, the `Main` method serves as the entry point of a C# application. However, to simplify startup code and usage, this library overrides the program's entry point and uses command handler methods as the entry points instead. This means you don't need to write any startup boilerplate code for your CLI application and can dive straight into implementing the application logic, i.e. commands.
26+
27+
It’s important to note that since the library overrides the entry point, if you include your own `Main` function in the program, it will **not** be invoked. If you need some initialization code to run before command, it can be placed in [Startup](#startup) method.
2428

2529
If you really need to use your own `Main`, you can still do so:
2630
1. Add `<AutoGenerateEntryPoint>false</AutoGenerateEntryPoint>` property into your program .csproj file
@@ -206,7 +210,7 @@ Options:
206210

207211
**Argument name convention**
208212
- Argument name is used only for help, it cannot be specified on command line.
209-
- If argument name is not explicitly specified in the attribute, the name of the parameter will be implicitly used.
213+
- If argument name is not explicitly specified in the attribute, the name of the parameter will be implicitly used.
210214

211215
You can provide options before arguments or arguments before options on the command line. See [documentation](https://learn.microsoft.com/en-us/dotnet/standard/commandline/syntax#order-of-options-and-arguments) for details.
212216
@@ -216,7 +220,7 @@ The [arity](https://learn.microsoft.com/en-us/dotnet/standard/commandline/syntax
216220
```csharp
217221
[Command(name: "print", description: "Arity example")]
218222
public static void Print(
219-
[Argument(arityMin:1, arityMax:2, name:"numbers", description:"Takes 1 or 2 numbers")]
223+
[Argument(name:"numbers", arityMin:1, arityMax:2, description:"Takes 1 or 2 numbers")]
220224
int[] nums
221225
)
222226
{
@@ -404,21 +408,33 @@ public static void Startup(CommandLineBuilder commandLineBuilder)
404408
}
405409
```
406410

411+
The `CLI.RootCommand` property is available in startup method and commands could be additionaly customized by startup code.
412+
413+
**Important**: When the startup method is invoked, the command line has not been parsed yet; therefore, global parameters still have their default values and not the values from the command line.
414+
407415
## Exception handling
408-
To catch unhandled exceptions during command execution you may set exception handler in [Startup](#startup) method. The handler is intended to provide diagnostics according to the need of your application. The return value from handler will be used as program's exit code. For example:
416+
To catch unhandled exceptions during command execution you may set exception handler in [Startup](#startup) method. The handler is intended to provide exception diagnostics according to the need of your application before exiting. The return value from handler will be used as program's exit code. For example:
409417

410-
```
418+
```csharp
411419
[Startup]
412420
public static void Startup()
413421
{
414422
CLI.ExceptionHandler = (exception) => {
415-
if (exception is not OperationCanceledException)
416-
{
417-
var color = Console.ForegroundColor;
418-
Console.ForegroundColor = ConsoleColor.Red;
423+
var color = Console.ForegroundColor;
424+
Console.ForegroundColor = ConsoleColor.Red;
425+
if (exception is OperationCanceledException)
426+
{ // special case
427+
Console.Error.WriteLine("Operation cancelled!");
428+
}
429+
else if (g_debugMode)
430+
{ // show detailed exception info in debug mode
419431
Console.Error.WriteLine(exception.ToString());
420-
Console.ForegroundColor = color;
421432
}
433+
else
434+
{ // show short error message during normal run
435+
Console.Error.WriteLine($"Error: {exception.Message}");
436+
}
437+
Console.ForegroundColor = color;
422438
return 1; // exit code
423439
};
424440
}

src/SnapCLI.cs

Lines changed: 66 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -346,71 +346,17 @@ private ConsoleHelper(TextWriter? output, TextWriter? error)
346346
/// </summary>
347347
public static event AfterCommandCallback? AfterCommand;
348348

349-
private static Parser? _parser;
350-
private static Parser Parser => _parser ??= BuildCommands();
349+
private static Parser Parser { get; }
351350

352351
/// <summary>
353-
/// Helper method to run CLI application. Should be called from program Main() entry point.
354-
/// </summary>
355-
/// <param name="args">Command line arguments passed from Main()</param>
356-
/// <param name="output">Redirect output stream</param>
357-
/// <param name="error">Redirect error stream</param>
358-
/// <returns></returns>
359-
public static int Run(string[]? args = null, TextWriter? output = null, TextWriter? error = null)
360-
{
361-
try
362-
{
363-
_error = error;
364-
var parseResult = Parser.Parse(args ?? Environment.GetCommandLineArgs().Skip(1).ToArray());
365-
return parseResult.Invoke(ConsoleHelper.CreateOrDefault(output, error));
366-
}
367-
catch (Exception ex)
368-
{
369-
if (ExceptionHandler != null)
370-
return ExceptionHandler(ex);
371-
ExceptionDispatchInfo.Capture(ex).Throw();
372-
return 1;
373-
}
374-
}
375-
376-
/// <summary>
377-
/// Helper asynchronous method to run CLI application. Should be called from program async Main() entry point.
352+
/// Provides access to commands hierarchy and their options and arguments.
378353
/// </summary>
379-
/// <param name="args">Command line arguments passed from Main()</param>
380-
/// <param name="output">Redirect output stream</param>
381-
/// <param name="error">Redirect error stream</param>
382-
/// <returns></returns>
383-
public static async Task<int> RunAsync(string[]? args = null, TextWriter? output = null, TextWriter? error = null)
384-
{
385-
try
386-
{
387-
_error = error;
388-
var parseResult = Parser.Parse(args ?? Environment.GetCommandLineArgs().Skip(1).ToArray());
389-
return await parseResult.InvokeAsync(ConsoleHelper.CreateOrDefault(output, error));
390-
}
391-
catch (Exception ex)
392-
{
393-
if (ExceptionHandler != null)
394-
return ExceptionHandler(ex);
395-
ExceptionDispatchInfo.Capture(ex).Throw();
396-
return 1;
397-
}
398-
}
399-
400-
/// <summary>
401-
/// Provides access to commands hierarchy and their options and arguments.
402-
/// </summary>
403-
public static Command RootCommand => Parser.Configuration.RootCommand;
354+
public static RootCommand RootCommand { get; }
404355

405356
/// <summary>
406357
/// Provides access to currently executing command definition.
407358
/// </summary>
408-
public static Command CurrentCommand {
409-
get => _currentCommand ?? throw new InvalidOperationException($"Cannot access {nameof(CurrentCommand)} from outside of CLI command handler method");
410-
private set => _currentCommand = value;
411-
}
412-
private static Command? _currentCommand = null;
413-
359+
public static Command? CurrentCommand;
414360
/// <summary>
415361
/// Handler to use when exception is occured during command execution. Set <code>null</code> to suppress exception handling.
416362
/// </summary>
@@ -451,8 +397,7 @@ private static int DefaultExceptionHandler(Exception exception)
451397
/// <summary>
452398
/// Current command invocation context provides access to parsed command line, CancellationToken, ExitCode and other properties.
453399
/// </summary>
454-
public static InvocationContext CurrentContext => _currentContext ?? throw new InvalidOperationException($"Cannot access {nameof(CurrentContext)} from outside of command handler method");
455-
private static InvocationContext? _currentContext;
400+
public static InvocationContext? CurrentContext;
456401

457402
#if BEFORE_AFTER_COMMAND_ATTRIBUTE
458403
private static MethodInfo[]? _beforeCommandsCallbacks;
@@ -474,11 +419,58 @@ public CommandMethodDesc(MethodInfo method, DescriptorAttribute desc)
474419
}
475420

476421
/// <summary>
477-
/// Builds commands hierarchy based on attributes.
422+
/// Helper method to run CLI application. Should be called from program Main() entry point.
423+
/// </summary>
424+
/// <param name="args">Command line arguments passed from Main()</param>
425+
/// <param name="output">Redirect output stream</param>
426+
/// <param name="error">Redirect error stream</param>
427+
/// <returns></returns>
428+
public static int Run(string[]? args = null, TextWriter? output = null, TextWriter? error = null)
429+
{
430+
try
431+
{
432+
_error = error;
433+
var parseResult = Parser.Parse(args ?? Environment.GetCommandLineArgs().Skip(1).ToArray());
434+
return parseResult.Invoke(ConsoleHelper.CreateOrDefault(output, error));
435+
}
436+
catch (Exception ex)
437+
{
438+
if (ExceptionHandler != null)
439+
return ExceptionHandler(ex);
440+
ExceptionDispatchInfo.Capture(ex).Throw();
441+
return 1;
442+
}
443+
}
444+
445+
/// <summary>
446+
/// Helper asynchronous method to run CLI application. Should be called from program async Main() entry point.
447+
/// </summary>
448+
/// <param name="args">Command line arguments passed from Main()</param>
449+
/// <param name="output">Redirect output stream</param>
450+
/// <param name="error">Redirect error stream</param>
451+
/// <returns></returns>
452+
public static async Task<int> RunAsync(string[]? args = null, TextWriter? output = null, TextWriter? error = null)
453+
{
454+
try
455+
{
456+
_error = error;
457+
var parseResult = Parser.Parse(args ?? Environment.GetCommandLineArgs().Skip(1).ToArray());
458+
return await parseResult.InvokeAsync(ConsoleHelper.CreateOrDefault(output, error));
459+
}
460+
catch (Exception ex)
461+
{
462+
if (ExceptionHandler != null)
463+
return ExceptionHandler(ex);
464+
ExceptionDispatchInfo.Capture(ex).Throw();
465+
return 1;
466+
}
467+
}
468+
469+
/// <summary>
470+
/// Static constructor, initializes commands hierarchy from attributes.
478471
/// </summary>
479-
/// <returns>Returns <see cref="Parser"></see></returns>
480-
/// <exception cref="InvalidOperationException">Commands hierarchy already built or there are attributes usage errors detected.</exception>
481-
private static Parser BuildCommands()
472+
/// <exception cref="InvalidOperationException">Attribute usage error detected.</exception>
473+
static CLI()
482474
{
483475
Assembly executingAssembly = Assembly.GetExecutingAssembly();
484476
Assembly assembly = Assembly.GetEntryAssembly() ?? executingAssembly;
@@ -509,12 +501,12 @@ private static Parser BuildCommands()
509501

510502
// create root command
511503

512-
RootCommand rootCommand = CreateRootCommand(assembly, globalDescriptors, commandMethods, out var rootMethod);
504+
RootCommand = CreateRootCommand(assembly, globalDescriptors, commandMethods, out var rootMethod);
513505

514506
// add commands without handler methods, i.e. those declared with [Command] on class level
515507

516508
var parentCommands = globalDescriptors.Where(d => d.Kind == DescriptorAttribute.DescKind.Command)
517-
.Select(desc => CreateAndAddCommand(rootCommand, desc.Name!, desc))
509+
.Select(desc => CreateAndAddCommand(RootCommand, desc.Name!, desc))
518510
.ToArray();
519511

520512
// add properties and fields described with [Option]
@@ -532,7 +524,7 @@ private static Parser BuildCommands()
532524
if (!prop.SetMethod?.IsStatic == null)
533525
throw new InvalidOperationException($"Property {prop.Name} declared as [Option] must be static");
534526
var opt = CreateOption(desc, prop.Name, prop.PropertyType, () => prop.GetValue(null));
535-
rootCommand.AddGlobalOption(opt);
527+
RootCommand.AddGlobalOption(opt);
536528
globalOptionsInitializersList.Add((ctx) => prop.SetValue(null, ctx.ParseResult.GetValueForOption(opt)));
537529
}
538530

@@ -547,7 +539,7 @@ private static Parser BuildCommands()
547539
if (!field.IsStatic)
548540
throw new InvalidOperationException($"Field {field.Name} declared as [Option] must be static");
549541
var opt = CreateOption(desc, field.Name, field.FieldType, () => field.GetValue(null));
550-
rootCommand.AddGlobalOption(opt);
542+
RootCommand.AddGlobalOption(opt);
551543
globalOptionsInitializersList.Add((ctx) => field.SetValue(null, ctx.ParseResult.GetValueForOption(opt)));
552544
}
553545

@@ -572,13 +564,13 @@ private static Parser BuildCommands()
572564
#endif
573565

574566
if (rootMethod != null)
575-
AddCommandHandler(rootCommand, rootMethod.Method, globalOptionsInitializers);
567+
AddCommandHandler(RootCommand, rootMethod.Method, globalOptionsInitializers);
576568

577569
foreach (var m in commandMethods
578570
.Where(m => m.Desc.Kind == DescriptorAttribute.DescKind.Command && m != rootMethod)
579571
.OrderBy(m => m.CommandName.Length)) // sort by name length to ensure parent commands created before subcommands
580572
{
581-
var command = CreateAndAddCommand(rootCommand, m.CommandName, m.Desc);
573+
var command = CreateAndAddCommand(RootCommand, m.CommandName, m.Desc);
582574
AddCommandHandler(command, m.Method, globalOptionsInitializers);
583575
}
584576

@@ -588,7 +580,7 @@ private static Parser BuildCommands()
588580
if (command.Subcommands.Count == 0 && command.Handler == null && command.IsHidden == false)
589581
throw new InvalidOperationException($"Command '{command.Name}' has no subcommands nor handler methods");
590582

591-
var builder = new CommandLineBuilder(rootCommand);
583+
var builder = new CommandLineBuilder(RootCommand);
592584

593585
// call [Startup] methods
594586

@@ -647,9 +639,7 @@ private static Parser BuildCommands()
647639
ExceptionHandler?.Invoke(ex);
648640
};
649641

650-
var parser = builder.Build();
651-
652-
return parser;
642+
Parser = builder.Build();
653643
}
654644

655645
// find [RootCommand] and [Command] attributes declared on class
@@ -816,8 +806,8 @@ private static void AddCommandHandler(Command command, MethodInfo method, Action
816806

817807
command.SetHandler(async (ctx) =>
818808
{
819-
_currentCommand = command;
820-
_currentContext = ctx;
809+
CurrentCommand = command;
810+
CurrentContext = ctx;
821811

822812
foreach (var initializer in globalOptionsInitializers)
823813
initializer.Invoke(ctx);

tests/UnitTest1.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ public void TestCLI(string commandLine, string pattern, UseExceptionHandler useE
127127

128128
private static void TraceCommand(params object?[] args)
129129
{
130-
Out.WriteLine($"[{CLI.CurrentCommand.Name}({string.Join(",", args)})]");
130+
Out.WriteLine($"[{CLI.CurrentCommand?.Name}({string.Join(",", args)})]");
131131
}
132132

133133
private static int CustomExceptionHandler(Exception exception)

0 commit comments

Comments
 (0)