Skip to content
Merged
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
68 changes: 68 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,74 @@ Notes:
- in interactive sessions, response-file expansion is disabled by default
- response-file paths are read from the local filesystem as provided; treat `@file` input as trusted CLI input

## Options groups

Handler parameters can use a class annotated with `[ReplOptionsGroup]` to declare reusable parameter groups:

```csharp
using Repl.Parameters;

[ReplOptionsGroup]
public class OutputOptions
{
[ReplOption(Aliases = ["-f"])]
[Description("Output format.")]
public string Format { get; set; } = "text";

[ReplOption(ReverseAliases = ["--no-verbose"])]
public bool Verbose { get; set; }
}

app.Map("list", (OutputOptions output, int limit) => $"{output.Format}:{limit}");
app.Map("show", (OutputOptions output, string id) => $"{output.Format}:{id}");
```

Options group behavior:

- group properties become individual command options (the same as regular handler parameters)
- PascalCase property names are automatically lowered to camelCase (`Format` → `--format`)
- property initializer values serve as defaults when options are not provided
- `[ReplOption]`, `[ReplArgument]`, `[ReplValueAlias]` attributes work on properties
- the same group class can be reused across multiple commands
- groups and regular parameters can be mixed in the same handler
- group properties are `OptionOnly` by default; use explicit attributes to opt into positional binding
- when a group property receives both named and positional values in one invocation, parsing fails with a validation error
- parameter name collisions between group properties and regular parameters cause an `InvalidOperationException` at registration
- positional group properties cannot be mixed with positional regular handler parameters in the same command
- abstract, interface, or nested group types are rejected at registration

## Temporal range types

Handler parameters can use temporal range types for date/time intervals:

```csharp
app.Map("report", (ReplDateRange period) =>
$"{period.From:yyyy-MM-dd} to {period.To:yyyy-MM-dd}");

app.Map("logs", (ReplDateTimeRange window) =>
$"{window.From:HH:mm} to {window.To:HH:mm}");

app.Map("audit", (ReplDateTimeOffsetRange span) =>
$"{span.From} to {span.To}");
```

Two syntaxes are supported:

- range: `--period 2024-01-15..2024-02-15`
- duration: `--period 2024-01-15@30d`

Available types:

| Type | From/To type | Example |
|------|-------------|---------|
| `ReplDateRange` | `DateOnly` | `2024-01-15..2024-02-15` |
| `ReplDateTimeRange` | `DateTime` | `2024-01-15T10:00..2024-01-15T18:00` |
| `ReplDateTimeOffsetRange` | `DateTimeOffset` | `2024-01-15T10:00+02:00..2024-01-15T18:00+02:00` |

Duration syntax uses the same format as `TimeSpan` literals (`30d`, `8h`, `1h30m`, `PT1H`, etc.).
Reversed ranges (`To < From`) produce a validation error.
For `ReplDateRange` (`DateOnly`), duration syntax must resolve to whole days.

## Supported parameter conversions

Handler parameters support native conversion for:
Expand Down
4 changes: 4 additions & 0 deletions docs/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@ Repl Toolkit is a command-surface framework — not just a CLI parser. It builds
| Response files (`@file.rsp`) | ✅ | ❌ | ✅ |
| POSIX `--` separator | ✅ | ✅ | ✅ |
| Type conversion (FileInfo, enums...) | ✅ Widest built-in set | ✅ Via TypeConverter | ✅ |
| Reusable options groups | ⚠️ Via custom composition | ⚠️ Via shared settings patterns | ✅ `[ReplOptionsGroup]` |
| Temporal range literals (`start..end`, `start@duration`) | ⚠️ Via custom parser/binder | ⚠️ Via custom converter/binder | ✅ Built-in range types |
| Global / recursive options | ✅ `Recursive = true` | ⚠️ Settings inheritance | ✅ `AddGlobalOption` |
| Parse diagnostics with suggestions | ✅ | ✅ | ✅ |

`⚠️` indicates the capability is achievable, but not as a first-class built-in abstraction.

## Interactive & Session

| Feature | System.CommandLine | Spectre.Console.Cli | Repl Toolkit |
Expand Down
30 changes: 30 additions & 0 deletions docs/parameter-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,35 @@ Supporting enums:
- `ReplParameterMode`
- `ReplArity`

### Options groups

- `ReplOptionsGroupAttribute` (on a class) marks it as a reusable parameter group
- the group's public writable properties become command options
- standard `ReplOptionAttribute`, `ReplArgumentAttribute`, `ReplValueAliasAttribute` apply on properties
- PascalCase property names are automatically lowered to camelCase for canonical tokens (`Format` → `--format`)
- group properties are `OptionOnly` by default; positional binding is opt-in via explicit property attributes
- properties with initializer values serve as defaults (no `HasDefaultValue` on `PropertyInfo`, so arity defaults to `ZeroOrOne`)
- named + positional values for the same group property in one invocation are rejected as validation errors
- abstract/interface group types and nested groups are rejected at registration time
- parameter name collisions between group properties and regular handler parameters are detected at registration time
- positional group properties cannot be mixed with positional non-group handler parameters in the same command

### Temporal range types

Three public record types represent temporal intervals:

- `ReplDateRange(DateOnly From, DateOnly To)`
- `ReplDateTimeRange(DateTime From, DateTime To)`
- `ReplDateTimeOffsetRange(DateTimeOffset From, DateTimeOffset To)`

These types live under `Repl` namespace and support two parsing syntaxes:

- range: `start..end` (double-dot separator)
- duration: `start@duration` (at sign with `TimeSpanLiteralParser` duration)

Reversed ranges (`To < From`) are validation errors.
For `ReplDateRange` (`DateOnly`), `start@duration` accepts whole-day durations only.

These public types live under `Repl.Parameters`.
Typical app code starts with:

Expand Down Expand Up @@ -101,6 +130,7 @@ This same schema drives:
- global options are consumed before command routing and can be app-extended
- response-file expansion is disabled by default in interactive sessions
- short-option bundling (`-abc` -> `-a -b -c`) is not enabled implicitly
- reusable options groups and temporal range literals are first-class in Repl Toolkit, while System.CommandLine typically requires custom composition/parsing for equivalent behavior

## Notes

Expand Down
33 changes: 30 additions & 3 deletions samples/01-core-basics/Program.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.ComponentModel;
using Repl;
using Repl.Parameters;

// Sample goal:
// - minimal CoreReplApp (no DI package)
Expand All @@ -18,6 +19,7 @@
app.Map("add {name} {email:email}", commands.Add);
app.Map("show {id:int}", commands.Show);
app.Map("count", commands.Count);
app.Map("report period", commands.ReportPeriod);
app.Map("error", ErrorCommand);
app.Map("debug reset", commands.Reset);

Expand All @@ -29,7 +31,11 @@ static object ErrorCommand() =>
file sealed class ContactCommands(ContactStore store)
{
[Description("List all contacts.")]
public List<Contact> List() => [.. store.List()];
public List<Contact> List(SampleOutputOptions output)
{
_ = output;
return [.. store.List()];
}

[Description("Add a new contact.")]
public Contact Add(
Expand All @@ -38,14 +44,23 @@ public Contact Add(
=> store.Add(name, email);

[Description("Show one contact by id.")]
public object Show([Description("Contact numeric id")] int id)
=> store.Find(id) is { } contact
public object Show(
[Description("Contact numeric id")] int id,
SampleOutputOptions output)
{
_ = output;
return store.Find(id) is { } contact
? contact
: Results.NotFound($"Contact '{id}' was not found.");
}

[Description("Return the number of contacts.")]
public object Count() => Results.Success("Contact count.", store.Count());

[Description("Render a date-only reporting period from a temporal range literal.")]
public string ReportPeriod(ReplDateRange period) =>
$"Reporting from {period.From:yyyy-MM-dd} to {period.To:yyyy-MM-dd} ({store.Count()} contacts in memory).";

[Description("Reset in-memory sample data.")]
[Browsable(false)]
public object Reset()
Expand All @@ -54,3 +69,15 @@ public object Reset()
return Results.Success("Data reset complete.");
}
}

[ReplOptionsGroup]
file sealed class SampleOutputOptions
{
[ReplOption(Aliases = ["-f"])]
[Description("Output format for this command.")]
public string Format { get; set; } = "text";

[ReplOption(ReverseAliases = ["--no-verbose"])]
[Description("Enable verbose output.")]
public bool Verbose { get; set; }
}
31 changes: 31 additions & 0 deletions samples/01-core-basics/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,37 @@ No screen scraping. No custom schema. No extra endpoints.

---

## Options groups and temporal ranges

This sample also demonstrates two advanced parameter features:

- reusable options groups via `[ReplOptionsGroup]`
- date-only temporal ranges via `ReplDateRange`

Try these commands:

```text
$ myapp list --format json
$ myapp show 1 --no-verbose
$ myapp report period --period 2024-01-15..2024-02-15
$ myapp report period --period 2024-01-15@30d
```

Expected behavior:

- `--format` and `--no-verbose` are provided by a shared options-group object.
- `report period` accepts `start..end` and `start@duration`.
- `ReplDateRange` accepts whole-day durations only.

Validation example:

```text
$ myapp report period --period 2024-01-15@8h
Validation: '2024-01-15@8h' is not a valid date range literal. Use start..end or start@duration with whole days.
```

---

## Notes and limitations

- This sample uses an **in-memory store**.
Expand Down
2 changes: 1 addition & 1 deletion samples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ If you’re new, start with **01**, then follow the sequence.
## Index (recommended order)

1. [01 — Core Basics](01-core-basics/)
`Repl.Core` only: routing, parsing/binding, typed params + constraints, help/discovery, CLI + REPL from the same command graph.
`Repl.Core` only: routing, parsing/binding, typed params + constraints, reusable options groups, temporal ranges, help/discovery, CLI + REPL from the same command graph.
2. [02 — Scoped Contacts](02-scoped-contacts/)
Dynamic scopes + REPL navigation (`..`) + DI-backed handlers.
3. [03 — Modular Ops](03-modular-ops/)
Expand Down
82 changes: 77 additions & 5 deletions src/Repl.Core/CoreReplApp.Documentation.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using Repl.Internal.Options;

Expand Down Expand Up @@ -144,15 +145,25 @@ private ReplDocCommand BuildDocumentationCommand(RouteDefinition route)
Description: null))
.ToArray();

var options = route.Command.Handler.Method
.GetParameters()
var handlerParams = route.Command.Handler.Method.GetParameters();
var regularOptions = handlerParams
.Where(parameter =>
!string.IsNullOrWhiteSpace(parameter.Name)
&& parameter.ParameterType != typeof(CancellationToken)
&& !routeParameterNames.Contains(parameter.Name!)
&& !IsFrameworkInjectedParameter(parameter.ParameterType))
.Select(parameter => BuildDocumentationOption(route.OptionSchema, parameter))
.ToArray();
&& !IsFrameworkInjectedParameter(parameter.ParameterType)
&& !Attribute.IsDefined(parameter.ParameterType, typeof(ReplOptionsGroupAttribute), inherit: true))
.Select(parameter => BuildDocumentationOption(route.OptionSchema, parameter));
var groupOptions = handlerParams
.Where(parameter => Attribute.IsDefined(parameter.ParameterType, typeof(ReplOptionsGroupAttribute), inherit: true))
.SelectMany(parameter =>
{
var defaultInstance = CreateOptionsGroupDefault(parameter.ParameterType);
return GetOptionsGroupProperties(parameter.ParameterType)
.Where(prop => prop.CanWrite)
.Select(prop => BuildDocumentationOptionFromProperty(route.OptionSchema, prop, defaultInstance));
});
var options = regularOptions.Concat(groupOptions).ToArray();

return new ReplDocCommand(
Path: route.Template.Template,
Expand Down Expand Up @@ -250,6 +261,9 @@ private static string GetFriendlyTypeName(Type type)
"timeonly" => "time",
"datetimeoffset" => "datetimeoffset",
"timespan" => "timespan",
"repldaterange" => "date-range",
"repldatetimerange" => "datetime-range",
"repldatetimeoffsetrange" => "datetimeoffset-range",
_ => type.Name,
};
}
Expand All @@ -259,6 +273,50 @@ private static string GetFriendlyTypeName(Type type)
return $"{genericName}<{genericArgs}>";
}

private static ReplDocOption BuildDocumentationOptionFromProperty(
OptionSchema schema,
PropertyInfo property,
object defaultInstance)
{
var entries = schema.Entries
.Where(entry => string.Equals(entry.ParameterName, property.Name, StringComparison.OrdinalIgnoreCase))
.ToArray();
var aliases = entries
.Where(entry => entry.TokenKind is OptionSchemaTokenKind.NamedOption or OptionSchemaTokenKind.BoolFlag)
.Select(entry => entry.Token)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var reverseAliases = entries
.Where(entry => entry.TokenKind == OptionSchemaTokenKind.ReverseFlag)
.Select(entry => entry.Token)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var valueAliases = entries
.Where(entry => entry.TokenKind is OptionSchemaTokenKind.ValueAlias or OptionSchemaTokenKind.EnumAlias)
.Select(entry => new ReplDocValueAlias(entry.Token, entry.InjectedValue ?? string.Empty))
.GroupBy(alias => alias.Token, StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.ToArray();
var effectiveType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType;
var enumValues = effectiveType.IsEnum
? Enum.GetNames(effectiveType)
: [];
var propDefault = property.GetValue(defaultInstance);
var defaultValue = propDefault is not null
? propDefault.ToString()
: null;
return new ReplDocOption(
Name: property.Name,
Type: GetFriendlyTypeName(property.PropertyType),
Required: false,
Description: property.GetCustomAttribute<DescriptionAttribute>()?.Description,
Aliases: aliases,
ReverseAliases: reverseAliases,
ValueAliases: valueAliases,
EnumValues: enumValues,
DefaultValue: defaultValue);
}

private static ReplDocOption BuildDocumentationOption(OptionSchema schema, ParameterInfo parameter)
{
var entries = schema.Entries
Expand Down Expand Up @@ -298,4 +356,18 @@ private static ReplDocOption BuildDocumentationOption(OptionSchema schema, Param
EnumValues: enumValues,
DefaultValue: defaultValue);
}

[UnconditionalSuppressMessage(
"Trimming",
"IL2067",
Justification = "Options group types are user-defined and always preserved by the handler delegate reference.")]
private static object CreateOptionsGroupDefault(Type groupType) =>
Activator.CreateInstance(groupType)!;

[UnconditionalSuppressMessage(
"Trimming",
"IL2070",
Justification = "Options group types are user-defined and always preserved by the handler delegate reference.")]
private static PropertyInfo[] GetOptionsGroupProperties(Type groupType) =>
groupType.GetProperties(BindingFlags.Public | BindingFlags.Instance);
}
Loading