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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 180 additions & 4 deletions ReadMe.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ app.Run(args);

You can also combine this with `Add` or `Add<T>` to add more commands.

### Alias command
### Alias command

Similar to option aliases, commands also support aliases. In the `Add` method, separating `commandName` or `[Command]` with `|` defines them as aliases.

Expand Down Expand Up @@ -598,7 +598,7 @@ The method parameter names and types determine how to parse and bind values from
```csharp
ConsoleApp.Run(args, (
[Argument]DateTime dateTime, // Argument
[Argument]Guid guidvalue, //
[Argument]Guid guidvalue, //
int intVar, // required
bool boolFlag, // flag
MyEnum enumValue, // enum
Expand All @@ -608,7 +608,7 @@ ConsoleApp.Run(args, (
double? nullableValue = null, // nullable
params string[] paramsArray // params
) => { });
```
```

When using `ConsoleApp.Run`, you can check the syntax of the command line in the tooltip to see how it is generated.

Expand Down Expand Up @@ -648,6 +648,152 @@ If you want to change the deserialization options, you can set `JsonSerializerOp

> NOTE: If they are not set when NativeAOT is used, a runtime exception may occur. If they are included in the parsing process, be sure to set source generated options.

### Class-Based Parameter Binding with [AsParameters]

When commands have many parameters, defining them inline becomes unwieldy. The `[AsParameters]` attribute enables class-based parameter binding, where a class or record's members become command options.

```csharp
// Both classes and records are supported
public record ServerConfig(
string Host = "localhost", // Optional with default
int Port = 8080, // Optional with default
bool Verbose = false, // Bool options are flags
string[] AllowedOrigins = null // Arrays use comma-separated values
);

// All parameters become CLI options: --host, --port, --verbose, --allowed-origins
ConsoleApp.Run(args, ([AsParameters] ServerConfig config) =>
{
Console.WriteLine($"Starting server on {config.Host}:{config.Port}");
});
// Usage: app --host 0.0.0.0 --port 3000 --verbose --allowed-origins http://a.com,http://b.com
```

**Why use [AsParameters]?** It provides a cleaner way to organize commands with many parameters, enables reuse of parameter groups across commands, and supports primary constructors and records for immutable configuration.

Supported types include are the same defined in the [Parse and Value Binding](#parse-and-value-binding) section.

#### Required Parameters

Use the `required` modifier or constructor parameters without defaults to mark options as required:

```csharp
public record DeployConfig(
string Environment, // Required (no default)
int Replicas = 1 // Optional
)
{
public required string ImageTag { get; init; } // Required via 'required' modifier
}
```

#### Positional Arguments

Use `[Argument]` attribute or the `argument,` XML doc tag for positional (non-named) parameters:

```csharp
public record BuildConfig(
[property: Argument] string ProjectPath, // [0] positional argument
string Configuration = "Release"
);

// Or using XML doc:
/// <summary>Copy options</summary>
/// <param name="source">argument, The source file</param>
/// <param name="destination">argument, The destination file</param>
public record CopyConfig(string source, string destination);

ConsoleApp.Run(args, ([AsParameters] CopyConfig config) => { });
// Usage: app ./source.txt ./dest.txt
```

#### Aliases and Descriptions

Use XML doc comments for aliases and descriptions, same as regular parameters:

```csharp
public record ServerConfig
{
/// <summary>-h|--host, Server hostname to bind.</summary>
public string Host { get; init; } = "localhost";

/// <summary>-p, Port number.</summary>
public int Port { get; init; } = 8080;
}
```

#### Mixed Constructor and Property Binding

Combine constructor parameters with settable properties for flexible initialization:

```csharp
public record MixedConfig(
string Name, // Required constructor param
int Count = 10 // Optional constructor param
)
{
public bool Verbose { get; set; } // Optional property (flag)
public required string OutputPath { get; init; } // Required property
}

ConsoleApp.Run(args, ([AsParameters] MixedConfig config) => { });
// Usage: app --name myapp --output-path ./out --verbose --count 5
```

#### Multiple [AsParameters] Parameters with Prefixes

Use multiple `[AsParameters]` parameters to compose configuration from separate types. Use `[AsParameters(Prefix = "prefix")]` to namespace options and avoid conflicts:

```csharp
public record SourceConfig(string Host = "localhost", int Port = 5432);
public record TargetConfig(string Host = "localhost", int Port = 5432);

ConsoleApp.Run(args, (
[AsParameters(Prefix = "source")] SourceConfig source,
[AsParameters(Prefix = "target")] TargetConfig target
) =>
{
Console.WriteLine($"Migrating from {source.Host}:{source.Port} to {target.Host}:{target.Port}");
});
// Usage: app --source-host db1.local --source-port 5432 --target-host db2.local --target-port 5433
```

The prefix is prepended to each option name with a hyphen separator.

#### Combining [AsParameters] with Typed Global Options

When your `[AsParameters]` type inherits from the global options type registered via `ConfigureGlobalOptions<T>()`, the inherited properties are automatically populated from the parsed global options. This enables shared configuration across commands without repetition.

```csharp
public record GlobalOptions
{
/// <summary>-v|--verbose, Enable verbose output.</summary>
public bool Verbose { get; init; }
}

// Inherits Verbose from GlobalOptions
public record DeployConfig : GlobalOptions
{
public string Environment { get; init; } = "staging";
public int Replicas { get; init; } = 1;
}

var app = ConsoleApp.Create();
app.ConfigureGlobalOptions<GlobalOptions>(); // Register global options

app.Add("deploy", ([AsParameters] DeployConfig config) =>
{
// config.Verbose comes from global options (parsed before command routing)
// config.Environment and config.Replicas are command-specific
if (config.Verbose) Console.WriteLine($"Deploying to {config.Environment}...");
});

app.Run(args);
// Usage: app --verbose deploy --environment production --replicas 3
```

The global options can appear before or after the command name, and the `[AsParameters]` type receives both the inherited global values and command-specific options.

### GlobalOptions

By calling `ConfigureGlobalOptions` on `ConsoleAppBuilder`, you can define global options that are enabled for all commands. For example, `--dry-run` or `--verbose` are suitable as common options.
Expand Down Expand Up @@ -735,6 +881,36 @@ internal class Commands(GlobalOptions globalOptions)
}
```

### Typed Global Options

For simpler scenarios, `ConfigureGlobalOptions<T>` provides a type-based approach that avoids manual builder wiring. Define your options as any class or record with a parameterless constructor and the framework handles parsing automatically.

```csharp
public record GlobalOptions
{
/// <summary>-v|--verbose, Enable verbose output.</summary>
public bool Verbose { get; init; }

/// <summary>Log output path.</summary>
public string LogPath { get; init; } = "app.log";
}

var app = ConsoleApp.Create();
app.ConfigureGlobalOptions<GlobalOptions>(); // Register the typed global options
app.Add<Commands>();
app.Run(args);

// Access via constructor injection
public class Commands(GlobalOptions options)
{
public void Run() => Console.WriteLine($"Verbose: {options.Verbose}, Log: {options.LogPath}");
}
```

Typed global options use the same XML doc comment conventions as regular parameters for aliases and descriptions. Global options are parsed before command routing, allowing them to appear anywhere on the command line.

> NOTE: `ConfigureGlobalOptions` can only be called once. You cannot mix typed `ConfigureGlobalOptions<T>()` with the builder-based `ConfigureGlobalOptions((ref builder) => ...)` approach—they are mutually exclusive alternatives.

### Custom Value Converter

To perform custom binding to existing types that do not support `ISpanParsable<T>`, 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<T>`'s `static bool TryParse(ReadOnlySpan<char> s, out Vector3 result)` as follows:
Expand Down Expand Up @@ -871,7 +1047,7 @@ await ConsoleApp.RunAsync(args, async Task<int> (string url, CancellationToken c
});
```

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

Attribute based parameters validation
---
Expand Down
115 changes: 114 additions & 1 deletion src/ConsoleAppFramework/Command.cs
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,9 @@ public record class CommandParameter
public bool IsArgument => ArgumentIndex != -1;
public required EquatableArray<string> Aliases { get; init; }
public required string Description { get; init; }
public bool RequireCheckArgumentParsed => !(HasDefaultValue || IsParams || IsFlag);
public required ObjectBindingInfo? ObjectBinding { get; init; }
public bool IsBound => ObjectBinding != null;
public bool RequireCheckArgumentParsed => !(HasDefaultValue || IsParams || IsFlag || IsBound);

// increment = false when passed from [Argument]
public string BuildParseMethod(int argCount, string argumentName, bool increment)
Expand Down Expand Up @@ -518,6 +520,7 @@ public CommandParameter ToDummyCommandParameter()
HasValidation = false,
ArgumentIndex = -1,
IsDefaultValueHidden = false,
ObjectBinding = null,

Type = Type,
HasDefaultValue = !IsRequired, // if not required, needs defaultValue
Expand All @@ -528,3 +531,113 @@ public CommandParameter ToDummyCommandParameter()
};
}
}

/// <summary>
/// Represents typed global options registered via ConfigureGlobalOptions&lt;T&gt;()
/// </summary>
public record class TypedGlobalOptionsInfo
{
public required EquatableTypeSymbol Type { get; init; }
public required ObjectBindingInfo ObjectBinding { get; init; }
}

/// <summary>
/// Represents a bindable property (either from constructor parameter or settable property)
/// </summary>
public sealed record class BindablePropertyInfo : IEquatable<BindablePropertyInfo>
{
public required string CliName { get; init; } // "--force" or "[0]" for arguments
public required EquatableTypeSymbol Type { get; init; }
public required bool HasDefaultValue { get; init; }
public required object? DefaultValue { get; init; }
public required string PropertyName { get; init; } // "Force"
public required string PropertyAccessPath { get; init; } // "Nested.Force" for nested objects
public required EquatableArray<string> ParentPath { get; init; } // ["Nested"] for nested objects
public required string Description { get; init; }
public required EquatableArray<string> Aliases { get; init; } // Short aliases like "-f" for "--force"
public required bool IsRequired { get; init; }
public required bool IsConstructorParameter { get; init; } // True if from primary constructor
public required int ConstructorParameterIndex { get; init; } // Position in constructor (-1 if not)
public required bool IsInitOnly { get; init; } // True if init-only property
public required int ArgumentIndex { get; init; } // -1 if not an argument, otherwise the positional index
public required bool IsFromGlobalOptions { get; init; } // True if inherited from [GlobalOptions] type
public required ParseInfo ParseInfo { get; init; } // Type classification for parsing
public bool IsArgument => ArgumentIndex != -1;
public bool IsFlag => Type.SpecialType == SpecialType.System_Boolean;

public bool Equals(BindablePropertyInfo? other)
{
if (other is null) return false;
return CliName == other.CliName &&
Type.Equals(other.Type) &&
HasDefaultValue == other.HasDefaultValue &&
Equals(DefaultValue, other.DefaultValue) &&
PropertyName == other.PropertyName &&
PropertyAccessPath == other.PropertyAccessPath &&
ParentPath.Equals(other.ParentPath) &&
Description == other.Description &&
Aliases.Equals(other.Aliases) &&
IsRequired == other.IsRequired &&
IsConstructorParameter == other.IsConstructorParameter &&
ConstructorParameterIndex == other.ConstructorParameterIndex &&
IsInitOnly == other.IsInitOnly &&
ArgumentIndex == other.ArgumentIndex &&
IsFromGlobalOptions == other.IsFromGlobalOptions &&
ParseInfo.Equals(other.ParseInfo);
}

public override int GetHashCode() => CliName.GetHashCode();
}

/// <summary>
/// Represents the binding info for an [AsParameters] parameter's type
/// </summary>
public sealed record class ObjectBindingInfo : IEquatable<ObjectBindingInfo>
{
public required EquatableTypeSymbol BoundType { get; init; }
public required EquatableArray<BindablePropertyInfo> Properties { get; init; }
public required bool HasPrimaryConstructor { get; init; }
public required EquatableArray<ConstructorParameterInfo> ConstructorParameters { get; init; }
public required string? CustomPrefix { get; init; } // Custom prefix if specified in attribute
public EquatableTypeSymbol? GlobalOptionsBaseType { get; init; } // The [GlobalOptions] base type if inheriting from one

public bool Equals(ObjectBindingInfo? other)
{
if (other is null) return false;
return BoundType.Equals(other.BoundType) &&
Properties.Equals(other.Properties) &&
HasPrimaryConstructor == other.HasPrimaryConstructor &&
ConstructorParameters.Equals(other.ConstructorParameters) &&
CustomPrefix == other.CustomPrefix &&
Equals(GlobalOptionsBaseType, other.GlobalOptionsBaseType);
}

public override int GetHashCode() => BoundType.GetHashCode();
}

/// <summary>
/// Represents a constructor parameter for object binding
/// </summary>
public sealed record class ConstructorParameterInfo : IEquatable<ConstructorParameterInfo>
{
public required string Name { get; init; }
public required EquatableTypeSymbol Type { get; init; }
public required bool HasDefaultValue { get; init; }
public required object? DefaultValue { get; init; }
public required int Index { get; init; }
public required int ArgumentIndex { get; init; } // -1 if not an argument, otherwise the positional index
public bool IsArgument => ArgumentIndex != -1;

public bool Equals(ConstructorParameterInfo? other)
{
if (other is null) return false;
return Name == other.Name &&
Type.Equals(other.Type) &&
HasDefaultValue == other.HasDefaultValue &&
Equals(DefaultValue, other.DefaultValue) &&
Index == other.Index &&
ArgumentIndex == other.ArgumentIndex;
}

public override int GetHashCode() => Name.GetHashCode();
}
Loading
Loading