diff --git a/docs/commands.md b/docs/commands.md index 04aba31..4836a52 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -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: diff --git a/docs/comparison.md b/docs/comparison.md index c4de18c..731f514 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -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 | diff --git a/docs/parameter-system.md b/docs/parameter-system.md index 5cf717a..1a1190e 100644 --- a/docs/parameter-system.md +++ b/docs/parameter-system.md @@ -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: @@ -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 diff --git a/samples/01-core-basics/Program.cs b/samples/01-core-basics/Program.cs index 42d454e..991ba9f 100644 --- a/samples/01-core-basics/Program.cs +++ b/samples/01-core-basics/Program.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using Repl; +using Repl.Parameters; // Sample goal: // - minimal CoreReplApp (no DI package) @@ -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); @@ -29,7 +31,11 @@ static object ErrorCommand() => file sealed class ContactCommands(ContactStore store) { [Description("List all contacts.")] - public List List() => [.. store.List()]; + public List List(SampleOutputOptions output) + { + _ = output; + return [.. store.List()]; + } [Description("Add a new contact.")] public Contact Add( @@ -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() @@ -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; } +} diff --git a/samples/01-core-basics/README.md b/samples/01-core-basics/README.md index 5af95c2..ef4c3c5 100644 --- a/samples/01-core-basics/README.md +++ b/samples/01-core-basics/README.md @@ -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**. diff --git a/samples/README.md b/samples/README.md index bcb787e..8cceaab 100644 --- a/samples/README.md +++ b/samples/README.md @@ -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/) diff --git a/src/Repl.Core/CoreReplApp.Documentation.cs b/src/Repl.Core/CoreReplApp.Documentation.cs index fd01997..37a4034 100644 --- a/src/Repl.Core/CoreReplApp.Documentation.cs +++ b/src/Repl.Core/CoreReplApp.Documentation.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using Repl.Internal.Options; @@ -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, @@ -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, }; } @@ -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()?.Description, + Aliases: aliases, + ReverseAliases: reverseAliases, + ValueAliases: valueAliases, + EnumValues: enumValues, + DefaultValue: defaultValue); + } + private static ReplDocOption BuildDocumentationOption(OptionSchema schema, ParameterInfo parameter) { var entries = schema.Entries @@ -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); } diff --git a/src/Repl.Core/HandlerArgumentBinder.cs b/src/Repl.Core/HandlerArgumentBinder.cs index d628679..0a9e0b9 100644 --- a/src/Repl.Core/HandlerArgumentBinder.cs +++ b/src/Repl.Core/HandlerArgumentBinder.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Reflection; using Repl.Internal.Options; namespace Repl; @@ -52,6 +53,13 @@ internal static class HandlerArgumentBinder $"Unable to bind parameter '{parameter.Name}' ({parameter.ParameterType.Name})."); } +#pragma warning disable IL2072 + if (IsOptionsGroupParameter(parameter)) + { + return BindOptionsGroup(parameter.ParameterType, context, ref positionalIndex); + } +#pragma warning restore IL2072 + if (context.RouteValues.TryGetValue(parameterName, out var routeValue)) { return ParameterValueConverter.ConvertSingle( @@ -511,4 +519,162 @@ private static object ConvertMatchesToTargetCollection( return list; } + + private static bool IsOptionsGroupParameter(System.Reflection.ParameterInfo parameter) => + Attribute.IsDefined(parameter.ParameterType, typeof(ReplOptionsGroupAttribute), inherit: true); + + [UnconditionalSuppressMessage( + "Trimming", + "IL2067", + Justification = "Options group types are user-defined and always preserved by the handler delegate reference.")] + private static object BindOptionsGroup( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] + Type groupType, + InvocationBindingContext context, + ref int positionalIndex) + { + var instance = Activator.CreateInstance(groupType)!; + + foreach (var property in groupType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (!property.CanWrite) + { + continue; + } + + var propertyName = property.Name; + var bindingMode = ResolvePropertyBindingMode(propertyName, property, context.OptionSchema); + var enumIgnoreCase = ResolveEnumIgnoreCase(propertyName, context); + + if (bindingMode != ReplParameterMode.ArgumentOnly + && context.NamedOptions.TryGetValue(propertyName, out var namedValues)) + { + if (bindingMode == ReplParameterMode.OptionAndPositional + && CanConsumePositionalForProperty(property, context.PositionalArguments, positionalIndex)) + { + throw new InvalidOperationException( + $"Property '{propertyName}' cannot receive both named and positional values in the same invocation."); + } + + var converted = ConvertPropertyManyOrSingle( + property, + namedValues, + property.PropertyType, + context.NumericFormatProvider, + enumIgnoreCase); + property.SetValue(instance, converted); + continue; + } + + if (bindingMode != ReplParameterMode.OptionOnly + && TryConsumePositionalForProperty( + property, + context.PositionalArguments, + context.NumericFormatProvider, + enumIgnoreCase, + ref positionalIndex, + out var positionalValue)) + { + property.SetValue(instance, positionalValue); + } + } + + return instance; + } + + private static ReplParameterMode ResolvePropertyBindingMode( + string propertyName, + PropertyInfo property, + OptionSchema optionSchema) + { + if (optionSchema.TryGetParameter(propertyName, out var schemaParameter)) + { + return schemaParameter.Mode; + } + + var optionAttribute = property.GetCustomAttribute(inherit: true); + if (optionAttribute is not null) + { + return optionAttribute.Mode; + } + + var argumentAttribute = property.GetCustomAttribute(inherit: true); + return argumentAttribute?.Mode ?? ReplParameterMode.OptionAndPositional; + } + + private static object? ConvertPropertyManyOrSingle( + PropertyInfo property, + IReadOnlyList values, + Type targetType, + IFormatProvider numericFormatProvider, + bool enumIgnoreCase) + { + if (!TryGetCollectionElementType(targetType, out var elementType)) + { + if (values.Count > 1) + { + throw new InvalidOperationException( + $"Property '{property.Name}' received multiple values but does not support repeated occurrences."); + } + + return ParameterValueConverter.ConvertSingle( + values.Count == 0 ? null : values[0], + targetType, + numericFormatProvider, + enumIgnoreCase); + } + + return ConvertMany(values, targetType, elementType, numericFormatProvider, enumIgnoreCase); + } + + private static bool TryConsumePositionalForProperty( + PropertyInfo property, + IReadOnlyList positionalArguments, + IFormatProvider numericFormatProvider, + bool enumIgnoreCase, + ref int positionalIndex, + out object? value) + { + var targetType = property.PropertyType; + if (TryGetCollectionElementType(targetType, out var elementType)) + { + var remaining = positionalArguments.Skip(positionalIndex).ToArray(); + if (remaining.Length == 0) + { + value = null; + return false; + } + + positionalIndex = positionalArguments.Count; + value = ConvertMany(remaining, targetType, elementType, numericFormatProvider, enumIgnoreCase); + return true; + } + + if (positionalIndex >= positionalArguments.Count) + { + value = null; + return false; + } + + value = ParameterValueConverter.ConvertSingle( + positionalArguments[positionalIndex], + targetType, + numericFormatProvider, + enumIgnoreCase); + positionalIndex++; + return true; + } + + private static bool CanConsumePositionalForProperty( + PropertyInfo property, + IReadOnlyList positionalArguments, + int positionalIndex) + { + if (TryGetCollectionElementType(property.PropertyType, out _)) + { + return positionalIndex < positionalArguments.Count; + } + + return positionalIndex < positionalArguments.Count; + } } diff --git a/src/Repl.Core/HelpTextBuilder.cs b/src/Repl.Core/HelpTextBuilder.cs index 8eb21b9..023fa3e 100644 --- a/src/Repl.Core/HelpTextBuilder.cs +++ b/src/Repl.Core/HelpTextBuilder.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Text; using Repl.Internal.Options; @@ -325,9 +326,25 @@ private static string BuildOptionSection(RouteDefinition route, bool useAnsi, An var parameters = route.Command.Handler.Method.GetParameters() .Where(parameter => !string.IsNullOrWhiteSpace(parameter.Name)) .ToDictionary(parameter => parameter.Name!, StringComparer.OrdinalIgnoreCase); + var groupProperties = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var methodParam in route.Command.Handler.Method.GetParameters()) + { + if (!Attribute.IsDefined(methodParam.ParameterType, typeof(ReplOptionsGroupAttribute), inherit: true)) + { + continue; + } + + var defaultInstance = CreateOptionsGroupDefault(methodParam.ParameterType); + foreach (var prop in GetOptionsGroupProperties(methodParam.ParameterType) + .Where(prop => prop.CanWrite && !groupProperties.ContainsKey(prop.Name))) + { + groupProperties[prop.Name] = (prop, defaultInstance); + } + } + var optionRows = route.OptionSchema.Parameters.Values .Where(parameter => parameter.Mode != ReplParameterMode.ArgumentOnly) - .Select(parameter => BuildOptionRow(route.OptionSchema, parameter, parameters)) + .Select(parameter => BuildOptionRow(route.OptionSchema, parameter, parameters, groupProperties)) .Where(row => row is not null) .Select(row => row!) .ToArray(); @@ -355,13 +372,9 @@ private static string BuildOptionSection(RouteDefinition route, bool useAnsi, An private static string[]? BuildOptionRow( OptionSchema schema, OptionSchemaParameter schemaParameter, - Dictionary parameters) + Dictionary parameters, + Dictionary? groupProperties = null) { - if (!parameters.TryGetValue(schemaParameter.Name, out var parameter)) - { - return null; - } - var entries = schema.Entries .Where(entry => string.Equals(entry.ParameterName, schemaParameter.Name, StringComparison.OrdinalIgnoreCase) @@ -381,11 +394,34 @@ or OptionSchemaTokenKind.ValueAlias .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); var tokenDisplay = string.Join(", ", visibleTokens); - var placeholder = ResolveOptionPlaceholder(parameter.ParameterType); - var description = parameter.GetCustomAttribute()?.Description ?? string.Empty; - var defaultValue = parameter.HasDefaultValue && parameter.DefaultValue is not null - ? $" [default: {parameter.DefaultValue}]" - : string.Empty; + + Type parameterType; + string description; + string defaultValue; + if (parameters.TryGetValue(schemaParameter.Name, out var parameter)) + { + parameterType = parameter.ParameterType; + description = parameter.GetCustomAttribute()?.Description ?? string.Empty; + defaultValue = parameter.HasDefaultValue && parameter.DefaultValue is not null + ? $" [default: {parameter.DefaultValue}]" + : string.Empty; + } + else if (groupProperties is not null + && groupProperties.TryGetValue(schemaParameter.Name, out var groupInfo)) + { + parameterType = groupInfo.Property.PropertyType; + description = groupInfo.Property.GetCustomAttribute()?.Description ?? string.Empty; + var propDefault = groupInfo.Property.GetValue(groupInfo.DefaultInstance); + defaultValue = propDefault is not null && !IsDefaultForType(propDefault, parameterType) + ? $" [default: {propDefault}]" + : string.Empty; + } + else + { + return null; + } + + var placeholder = ResolveOptionPlaceholder(parameterType); var left = string.IsNullOrWhiteSpace(placeholder) ? tokenDisplay : $"{tokenDisplay} {placeholder}"; @@ -393,6 +429,31 @@ or OptionSchemaTokenKind.ValueAlias return [left, right]; } + private static bool IsDefaultForType(object value, Type type) + { + if (type == typeof(bool)) + { + return value is false; + } + + if (type == typeof(int)) + { + return value is 0; + } + + if (type == typeof(long)) + { + return value is 0L; + } + + if (type == typeof(double)) + { + return value is 0.0d; + } + + return false; + } + private static string ResolveOptionPlaceholder(Type parameterType) { var effectiveType = Nullable.GetUnderlyingType(parameterType) ?? parameterType; @@ -441,6 +502,21 @@ private static string GetTypePlaceholderName(Type type) return "directory"; } + if (type == typeof(ReplDateRange)) + { + return "date-range"; + } + + if (type == typeof(ReplDateTimeRange)) + { + return "datetime-range"; + } + + if (type == typeof(ReplDateTimeOffsetRange)) + { + return "datetimeoffset-range"; + } + return type.Name.ToLowerInvariant(); } @@ -739,6 +815,20 @@ private static bool IsExactMatch( return MatchesPrefix(template, tokens, parsingOptions); } + [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); + private static bool MatchesPrefix( RouteTemplate template, IReadOnlyList tokens, diff --git a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs index 2b861bf..77a7ca6 100644 --- a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs +++ b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Reflection; using Repl; @@ -20,6 +21,8 @@ public static OptionSchema Build( .ToHashSet(StringComparer.OrdinalIgnoreCase); var entries = new List(); var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase); + var regularPositionalParameterNames = new List(); + var groupPositionalPropertyNames = new List(); foreach (var parameter in command.Handler.Method.GetParameters()) { if (ShouldSkipSchemaParameter(parameter, routeParameterNames)) @@ -27,9 +30,27 @@ public static OptionSchema Build( continue; } - AppendParameterSchemaEntries(parameter, entries, parameters); +#pragma warning disable IL2072 + if (IsOptionsGroupParameter(parameter)) + { + AppendOptionsGroupSchemaEntries( + parameter.ParameterType, + entries, + parameters, + groupPositionalPropertyNames); + } +#pragma warning restore IL2072 + else + { + AppendParameterSchemaEntries(parameter, entries, parameters); + if (IsExplicitPositionalParameter(parameter)) + { + regularPositionalParameterNames.Add(parameter.Name!); + } + } } + ValidatePositionalBindingCompatibility(regularPositionalParameterNames, groupPositionalPropertyNames); ValidateTokenCollisions(entries, parsingOptions); return new OptionSchema(entries, parameters); } @@ -69,10 +90,13 @@ private static void AppendParameterSchemaEntries( Dictionary parameters) { var optionAttribute = parameter.GetCustomAttribute(inherit: true); - var argumentAttribute = parameter.GetCustomAttribute(inherit: true); - var mode = optionAttribute?.Mode - ?? argumentAttribute?.Mode - ?? ReplParameterMode.OptionAndPositional; + var mode = ResolveParameterMode(parameter); + if (parameters.ContainsKey(parameter.Name!)) + { + throw new InvalidOperationException( + $"Option token collision detected for parameter name '{parameter.Name}'."); + } + parameters[parameter.Name!] = new OptionSchemaParameter( parameter.Name!, parameter.ParameterType, @@ -208,6 +232,15 @@ private static bool IsCollection(Type type) => private static bool IsBoolParameter(Type type) => (Nullable.GetUnderlyingType(type) ?? type) == typeof(bool); + private static string ToCamelCase(string name) => + name.Length == 0 || char.IsLower(name[0]) + ? name + : string.Create(name.Length, name, static (span, source) => + { + source.AsSpan().CopyTo(span); + span[0] = char.ToLowerInvariant(span[0]); + }); + private static string EnsureLongPrefix(string name) => name.StartsWith("--", StringComparison.Ordinal) ? name : $"--{name}"; @@ -257,6 +290,254 @@ private static void AppendEnumAliases( } } + private static bool IsOptionsGroupParameter(ParameterInfo parameter) => + Attribute.IsDefined(parameter.ParameterType, typeof(ReplOptionsGroupAttribute), inherit: true); + + private static ReplParameterMode ResolveParameterMode(ParameterInfo parameter) + { + var optionAttribute = parameter.GetCustomAttribute(inherit: true); + if (optionAttribute is not null) + { + return optionAttribute.Mode; + } + + var argumentAttribute = parameter.GetCustomAttribute(inherit: true); + return argumentAttribute?.Mode ?? ReplParameterMode.OptionAndPositional; + } + + private static bool IsExplicitPositionalParameter(ParameterInfo parameter) + { + var optionAttribute = parameter.GetCustomAttribute(inherit: true); + if (optionAttribute is not null) + { + return optionAttribute.Mode != ReplParameterMode.OptionOnly; + } + + var argumentAttribute = parameter.GetCustomAttribute(inherit: true); + return argumentAttribute is not null && argumentAttribute.Mode != ReplParameterMode.OptionOnly; + } + + private static void ValidatePositionalBindingCompatibility( + List regularPositionalParameterNames, + List groupPositionalPropertyNames) + { + if (regularPositionalParameterNames.Count == 0 || groupPositionalPropertyNames.Count == 0) + { + return; + } + + throw new InvalidOperationException( + $"Cannot mix positional options-group properties ({string.Join(", ", groupPositionalPropertyNames)}) " + + $"with positional handler parameters ({string.Join(", ", regularPositionalParameterNames)})."); + } + + [UnconditionalSuppressMessage( + "Trimming", + "IL2070", + Justification = "Options group types are user-defined and always preserved by the handler delegate reference.")] + private static void AppendOptionsGroupSchemaEntries( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] + Type groupType, + List entries, + Dictionary parameters, + List positionalPropertyNames) + { + ValidateOptionsGroupType(groupType); + + foreach (var property in groupType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (!property.CanWrite) + { + continue; + } + + if (Attribute.IsDefined(property.PropertyType, typeof(ReplOptionsGroupAttribute), inherit: true)) + { + throw new InvalidOperationException( + $"Nested options groups are not supported. Property '{property.Name}' on '{groupType.Name}' is itself an options group."); + } + + AppendPropertySchemaEntries(property, entries, parameters, positionalPropertyNames); + } + } + + [UnconditionalSuppressMessage( + "Trimming", + "IL2070", + Justification = "Options group types are user-defined and always preserved by the handler delegate reference.")] + private static void ValidateOptionsGroupType( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] + Type groupType) + { + if (groupType.IsAbstract || groupType.IsInterface) + { + throw new InvalidOperationException( + $"Options group type '{groupType.Name}' must be a concrete class."); + } + + if (groupType.GetConstructor(BindingFlags.Public | BindingFlags.Instance, Type.EmptyTypes) is null) + { + throw new InvalidOperationException( + $"Options group type '{groupType.Name}' must have a public parameterless constructor."); + } + } + + private static void AppendPropertySchemaEntries( + PropertyInfo property, + List entries, + Dictionary parameters, + List positionalPropertyNames) + { + var optionAttribute = property.GetCustomAttribute(inherit: true); + var argumentAttribute = property.GetCustomAttribute(inherit: true); + var mode = optionAttribute?.Mode + ?? argumentAttribute?.Mode + ?? ReplParameterMode.OptionOnly; + if (parameters.ContainsKey(property.Name)) + { + throw new InvalidOperationException( + $"Option token collision detected for parameter name '{property.Name}'."); + } + + parameters[property.Name] = new OptionSchemaParameter( + property.Name, + property.PropertyType, + mode, + CaseSensitivity: optionAttribute?.CaseSensitivity); + if (mode != ReplParameterMode.OptionOnly) + { + positionalPropertyNames.Add(property.Name); + } + if (mode == ReplParameterMode.ArgumentOnly) + { + return; + } + + var arity = ResolvePropertyArity(property.PropertyType, optionAttribute); + var tokenKind = ResolveTokenKind(property.PropertyType, arity); + var canonicalToken = ResolveCanonicalToken(ToCamelCase(property.Name), optionAttribute); + entries.Add(new OptionSchemaEntry( + canonicalToken, + property.Name, + tokenKind, + arity, + CaseSensitivity: optionAttribute?.CaseSensitivity)); + AppendPropertyOptionAliases(property.Name, tokenKind, arity, optionAttribute, entries); + AppendPropertyReverseAliases(property.Name, optionAttribute, entries); + AppendPropertyValueAliases(property, optionAttribute, entries); + AppendPropertyEnumAliases(property, optionAttribute, entries); + } + + private static ReplArity ResolvePropertyArity(Type propertyType, ReplOptionAttribute? optionAttribute) + { + if (optionAttribute?.Arity is { } explicitArity) + { + return explicitArity; + } + + if (IsBoolParameter(propertyType)) + { + return ReplArity.ZeroOrOne; + } + + if (IsCollection(propertyType)) + { + return ReplArity.ZeroOrMore; + } + + return ReplArity.ZeroOrOne; + } + + private static void AppendPropertyOptionAliases( + string propertyName, + OptionSchemaTokenKind tokenKind, + ReplArity arity, + ReplOptionAttribute? optionAttribute, + List entries) + { + foreach (var alias in optionAttribute?.Aliases ?? []) + { + ValidateOptionToken(alias, propertyName); + entries.Add(new OptionSchemaEntry( + alias, + propertyName, + tokenKind, + arity, + CaseSensitivity: optionAttribute?.CaseSensitivity)); + } + } + + private static void AppendPropertyReverseAliases( + string propertyName, + ReplOptionAttribute? optionAttribute, + List entries) + { + foreach (var reverseAlias in optionAttribute?.ReverseAliases ?? []) + { + ValidateOptionToken(reverseAlias, propertyName); + entries.Add(new OptionSchemaEntry( + reverseAlias, + propertyName, + OptionSchemaTokenKind.ReverseFlag, + ReplArity.ZeroOrOne, + CaseSensitivity: optionAttribute?.CaseSensitivity, + InjectedValue: "false")); + } + } + + private static void AppendPropertyValueAliases( + PropertyInfo property, + ReplOptionAttribute? optionAttribute, + List entries) + { + foreach (var valueAlias in property.GetCustomAttributes(inherit: true)) + { + ValidateOptionToken(valueAlias.Token, property.Name); + entries.Add(new OptionSchemaEntry( + valueAlias.Token, + property.Name, + OptionSchemaTokenKind.ValueAlias, + ReplArity.ZeroOrOne, + CaseSensitivity: valueAlias.CaseSensitivity ?? optionAttribute?.CaseSensitivity, + InjectedValue: valueAlias.Value)); + } + } + + private static void AppendPropertyEnumAliases( + PropertyInfo property, + ReplOptionAttribute? optionAttribute, + List entries) + { + var enumType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; + if (!enumType.IsEnum) + { + return; + } + +#pragma warning disable IL2075 + foreach (var field in enumType.GetFields(BindingFlags.Public | BindingFlags.Static)) +#pragma warning restore IL2075 + { + var enumFlag = field.GetCustomAttribute(inherit: false); + if (enumFlag is null) + { + continue; + } + + foreach (var alias in enumFlag.Aliases) + { + ValidateOptionToken(alias, property.Name); + entries.Add(new OptionSchemaEntry( + alias, + property.Name, + OptionSchemaTokenKind.EnumAlias, + ReplArity.ZeroOrOne, + CaseSensitivity: enumFlag.CaseSensitivity ?? optionAttribute?.CaseSensitivity, + InjectedValue: field.Name)); + } + } + } + private static void ValidateTokenCollisions( IReadOnlyList entries, ParsingOptions parsingOptions) diff --git a/src/Repl.Core/ParameterValueConverter.cs b/src/Repl.Core/ParameterValueConverter.cs index f57317a..4460270 100644 --- a/src/Repl.Core/ParameterValueConverter.cs +++ b/src/Repl.Core/ParameterValueConverter.cs @@ -50,6 +50,11 @@ private static bool TryConvertWellKnown( return true; } + if (TryConvertTemporalRange(value, nonNullableType, out converted)) + { + return true; + } + return false; } @@ -147,6 +152,36 @@ private static bool TryConvertTemporal(string value, Type nonNullableType, out o return false; } + private static bool TryConvertTemporalRange(string value, Type nonNullableType, out object? converted) + { + converted = null; + if (nonNullableType == typeof(ReplDateRange)) + { + converted = TemporalRangeLiteralParser.TryParseDateRange(value, out var parsed) + ? parsed + : throw new FormatException($"'{value}' is not a valid date range literal. Use start..end or start@duration with whole days."); + return true; + } + + if (nonNullableType == typeof(ReplDateTimeRange)) + { + converted = TemporalRangeLiteralParser.TryParseDateTimeRange(value, out var parsed) + ? parsed + : throw new FormatException($"'{value}' is not a valid date-time range literal. Use start..end or start@duration."); + return true; + } + + if (nonNullableType == typeof(ReplDateTimeOffsetRange)) + { + converted = TemporalRangeLiteralParser.TryParseDateTimeOffsetRange(value, out var parsed) + ? parsed + : throw new FormatException($"'{value}' is not a valid date-time-offset range literal. Use start..end or start@duration."); + return true; + } + + return false; + } + private static string NormalizeNumericLiteral(string value) => value.IndexOf('_', StringComparison.Ordinal) >= 0 ? value.Replace("_", string.Empty, StringComparison.Ordinal) diff --git a/src/Repl.Core/Parameters/Attributes/ReplArgumentAttribute.cs b/src/Repl.Core/Parameters/Attributes/ReplArgumentAttribute.cs index fdc778b..78f1eb4 100644 --- a/src/Repl.Core/Parameters/Attributes/ReplArgumentAttribute.cs +++ b/src/Repl.Core/Parameters/Attributes/ReplArgumentAttribute.cs @@ -3,7 +3,7 @@ namespace Repl.Parameters; /// /// Configures positional argument metadata for a handler parameter. /// -[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)] public sealed class ReplArgumentAttribute : Attribute { /// diff --git a/src/Repl.Core/Parameters/Attributes/ReplOptionAttribute.cs b/src/Repl.Core/Parameters/Attributes/ReplOptionAttribute.cs index 8cd8aab..3480496 100644 --- a/src/Repl.Core/Parameters/Attributes/ReplOptionAttribute.cs +++ b/src/Repl.Core/Parameters/Attributes/ReplOptionAttribute.cs @@ -3,7 +3,7 @@ namespace Repl.Parameters; /// /// Configures named option metadata for a handler parameter. /// -[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)] public sealed class ReplOptionAttribute : Attribute { /// diff --git a/src/Repl.Core/Parameters/Attributes/ReplOptionsGroupAttribute.cs b/src/Repl.Core/Parameters/Attributes/ReplOptionsGroupAttribute.cs new file mode 100644 index 0000000..3d4b1e8 --- /dev/null +++ b/src/Repl.Core/Parameters/Attributes/ReplOptionsGroupAttribute.cs @@ -0,0 +1,10 @@ +namespace Repl.Parameters; + +/// +/// Marks a class as a reusable options group whose public properties are +/// expanded into individual command options when used as a handler parameter. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] +public sealed class ReplOptionsGroupAttribute : Attribute +{ +} diff --git a/src/Repl.Core/Parameters/Attributes/ReplValueAliasAttribute.cs b/src/Repl.Core/Parameters/Attributes/ReplValueAliasAttribute.cs index 2b7efe8..0737bb7 100644 --- a/src/Repl.Core/Parameters/Attributes/ReplValueAliasAttribute.cs +++ b/src/Repl.Core/Parameters/Attributes/ReplValueAliasAttribute.cs @@ -3,7 +3,7 @@ namespace Repl.Parameters; /// /// Maps an alias token to an injected option value for a parameter. /// -[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = true, Inherited = true)] +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = true, Inherited = true)] public sealed class ReplValueAliasAttribute : Attribute { /// diff --git a/src/Repl.Core/ReplDateRange.cs b/src/Repl.Core/ReplDateRange.cs new file mode 100644 index 0000000..e7a9aaf --- /dev/null +++ b/src/Repl.Core/ReplDateRange.cs @@ -0,0 +1,7 @@ +namespace Repl; + +/// +/// Represents an inclusive date range from to . +/// Parsed from start..end or start@duration literals. +/// +public sealed record ReplDateRange(DateOnly From, DateOnly To); diff --git a/src/Repl.Core/ReplDateTimeOffsetRange.cs b/src/Repl.Core/ReplDateTimeOffsetRange.cs new file mode 100644 index 0000000..634d444 --- /dev/null +++ b/src/Repl.Core/ReplDateTimeOffsetRange.cs @@ -0,0 +1,7 @@ +namespace Repl; + +/// +/// Represents an inclusive date-time-offset range from to . +/// Parsed from start..end or start@duration literals. +/// +public sealed record ReplDateTimeOffsetRange(DateTimeOffset From, DateTimeOffset To); diff --git a/src/Repl.Core/ReplDateTimeRange.cs b/src/Repl.Core/ReplDateTimeRange.cs new file mode 100644 index 0000000..9a93cab --- /dev/null +++ b/src/Repl.Core/ReplDateTimeRange.cs @@ -0,0 +1,7 @@ +namespace Repl; + +/// +/// Represents an inclusive date-time range from to . +/// Parsed from start..end or start@duration literals. +/// +public sealed record ReplDateTimeRange(DateTime From, DateTime To); diff --git a/src/Repl.Core/TemporalRangeLiteralParser.cs b/src/Repl.Core/TemporalRangeLiteralParser.cs new file mode 100644 index 0000000..0cd7580 --- /dev/null +++ b/src/Repl.Core/TemporalRangeLiteralParser.cs @@ -0,0 +1,183 @@ +namespace Repl; + +internal static class TemporalRangeLiteralParser +{ + public static bool TryParseDateRange(string value, out ReplDateRange range) + { + range = default!; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var span = value.AsSpan(); + if (TrySplitRange(span, out var left, out var right)) + { + if (!TemporalLiteralParser.TryParseDateOnly(left.ToString(), out var from) + || !TemporalLiteralParser.TryParseDateOnly(right.ToString(), out var to)) + { + return false; + } + + if (to < from) + { + return false; + } + + range = new ReplDateRange(from, to); + return true; + } + + if (TrySplitDuration(span, out left, out var durationPart)) + { + if (!TemporalLiteralParser.TryParseDateOnly(left.ToString(), out var from) + || !TimeSpanLiteralParser.TryParse(durationPart.ToString(), out var duration) + || !IsWholeDays(duration)) + { + return false; + } + + var to = DateOnly.FromDateTime(from.ToDateTime(TimeOnly.MinValue) + duration); + if (to < from) + { + return false; + } + + range = new ReplDateRange(from, to); + return true; + } + + return false; + } + + public static bool TryParseDateTimeRange(string value, out ReplDateTimeRange range) + { + range = default!; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var span = value.AsSpan(); + if (TrySplitRange(span, out var left, out var right)) + { + if (!TemporalLiteralParser.TryParseDateTime(left.ToString(), out var from) + || !TemporalLiteralParser.TryParseDateTime(right.ToString(), out var to)) + { + return false; + } + + if (to < from) + { + return false; + } + + range = new ReplDateTimeRange(from, to); + return true; + } + + if (TrySplitDuration(span, out left, out var durationPart)) + { + if (!TemporalLiteralParser.TryParseDateTime(left.ToString(), out var from) + || !TimeSpanLiteralParser.TryParse(durationPart.ToString(), out var duration)) + { + return false; + } + + var to = from + duration; + if (to < from) + { + return false; + } + + range = new ReplDateTimeRange(from, to); + return true; + } + + return false; + } + + public static bool TryParseDateTimeOffsetRange(string value, out ReplDateTimeOffsetRange range) + { + range = default!; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var span = value.AsSpan(); + if (TrySplitRange(span, out var left, out var right)) + { + if (!TemporalLiteralParser.TryParseDateTimeOffset(left.ToString(), out var from) + || !TemporalLiteralParser.TryParseDateTimeOffset(right.ToString(), out var to)) + { + return false; + } + + if (to < from) + { + return false; + } + + range = new ReplDateTimeOffsetRange(from, to); + return true; + } + + if (TrySplitDuration(span, out left, out var durationPart)) + { + if (!TemporalLiteralParser.TryParseDateTimeOffset(left.ToString(), out var from) + || !TimeSpanLiteralParser.TryParse(durationPart.ToString(), out var duration)) + { + return false; + } + + var to = from + duration; + if (to < from) + { + return false; + } + + range = new ReplDateTimeOffsetRange(from, to); + return true; + } + + return false; + } + + private static bool TrySplitRange( + ReadOnlySpan value, + out ReadOnlySpan left, + out ReadOnlySpan right) + { + left = right = default; + var index = value.IndexOf("..".AsSpan(), StringComparison.Ordinal); + if (index <= 0 || index >= value.Length - 2) + { + return false; + } + + left = value[..index]; + right = value[(index + 2)..]; + return left.Length > 0 && right.Length > 0; + } + + private static bool TrySplitDuration( + ReadOnlySpan value, + out ReadOnlySpan left, + out ReadOnlySpan durationPart) + { + left = durationPart = default; + var index = value.LastIndexOf('@'); + if (index <= 0 || index >= value.Length - 1) + { + return false; + } + + left = value[..index]; + durationPart = value[(index + 1)..]; + return left.Length > 0 && durationPart.Length > 0; + } + + private static bool IsWholeDays(TimeSpan duration) => + duration.Ticks % TimeSpan.TicksPerDay == 0; +} diff --git a/src/Repl.IntegrationTests/Given_OptionsGroupBinding.cs b/src/Repl.IntegrationTests/Given_OptionsGroupBinding.cs new file mode 100644 index 0000000..eb0fc75 --- /dev/null +++ b/src/Repl.IntegrationTests/Given_OptionsGroupBinding.cs @@ -0,0 +1,217 @@ +namespace Repl.IntegrationTests; + +[TestClass] +[DoNotParallelize] +public sealed class Given_OptionsGroupBinding +{ + [ReplOptionsGroup] + public class TestOutputOptions + { + [ReplOption(Aliases = ["-f"])] + [System.ComponentModel.Description("Output format.")] + public string Format { get; set; } = "text"; + + [ReplOption(ReverseAliases = ["--no-verbose"])] + public bool Verbose { get; set; } + } + + [ReplOptionsGroup] + public class TestPagingOptions + { + [ReplOption] + public int Limit { get; set; } = 10; + + [ReplOption] + public int Offset { get; set; } + } + + [ReplOptionsGroup] + public class PositionalSearchOptions + { + [ReplArgument(Mode = ReplParameterMode.OptionAndPositional)] + public string Query { get; set; } = ""; + } + + [TestMethod] + [Description("Regression guard: verifies named options bind to options group properties.")] + public void When_UsingNamedOptionOnGroup_Then_PropertyBindsSuccessfully() + { + var sut = ReplApp.Create(); + sut.Map("list", (TestOutputOptions output) => output.Format); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["list", "--format", "json", "--no-logo"])); + + output.ExitCode.Should().Be(0, because: output.Text); + output.Text.Should().Contain("json"); + } + + [TestMethod] + [Description("Regression guard: verifies short alias binds to options group property.")] + public void When_UsingShortAliasOnGroup_Then_PropertyBindsSuccessfully() + { + var sut = ReplApp.Create(); + sut.Map("list", (TestOutputOptions output) => output.Format); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["list", "-f", "yaml", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("yaml"); + } + + [TestMethod] + [Description("Regression guard: verifies boolean flags bind to options group properties.")] + public void When_UsingBoolFlagOnGroup_Then_PropertyBindsSuccessfully() + { + var sut = ReplApp.Create(); + sut.Map("list", (TestOutputOptions output) => output.Verbose.ToString()); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["list", "--verbose", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("True"); + } + + [TestMethod] + [Description("Regression guard: verifies reverse aliases bind to options group properties.")] + public void When_UsingReverseAliasOnGroup_Then_PropertyBindsSuccessfully() + { + var sut = ReplApp.Create(); + sut.Map("list", (TestOutputOptions output) => output.Verbose.ToString()); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["list", "--no-verbose", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("False"); + } + + [TestMethod] + [Description("Regression guard: verifies default values are preserved when options are not provided.")] + public void When_OptionNotProvided_Then_DefaultValueIsPreserved() + { + var sut = ReplApp.Create(); + sut.Map("list", (TestOutputOptions output) => output.Format); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["list", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("text"); + } + + [TestMethod] + [Description("Regression guard: verifies options group and regular parameters bind correctly together.")] + public void When_MixingGroupAndRegularParams_Then_BothBindCorrectly() + { + var sut = ReplApp.Create(); + sut.Map("list", (TestOutputOptions output, int limit) => $"{output.Format}:{limit}"); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["list", "--format", "json", "--limit", "5", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("json:5"); + } + + [TestMethod] + [Description("Regression guard: verifies two different options groups bind independently.")] + public void When_UsingTwoGroups_Then_BothBindIndependently() + { + var sut = ReplApp.Create(); + sut.Map("list", (TestOutputOptions output, TestPagingOptions paging) => + $"{output.Format}:{paging.Limit}:{paging.Offset}"); + + var output = ConsoleCaptureHelper.Capture(() => + sut.Run(["list", "--format", "json", "--limit", "20", "--offset", "5", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("json:20:5"); + } + + [TestMethod] + [Description("Regression guard: verifies token collision between group and regular parameter fails at registration.")] + public void When_GroupPropertyCollidesWithParam_Then_MapFails() + { + var sut = ReplApp.Create(); + + var act = () => sut.Map("list", (TestOutputOptions output, string format) => format); + + act.Should().Throw() + .WithMessage("*collision*"); + } + + [TestMethod] + [Description("Regression guard: verifies abstract options group type fails at registration.")] + public void When_OptionsGroupTypeIsAbstract_Then_MapFails() + { + var sut = ReplApp.Create(); + + var act = () => sut.Map("list", (AbstractGroup group) => "ok"); + + act.Should().Throw() + .WithMessage("*concrete class*"); + } + + [TestMethod] + [Description("Regression guard: verifies the same options group reused in two commands works.")] + public void When_SameGroupReusedInTwoCommands_Then_BothWork() + { + var sut = ReplApp.Create(); + sut.Map("list", (TestOutputOptions output) => $"list:{output.Format}"); + sut.Map("show", (TestOutputOptions output) => $"show:{output.Format}"); + + var listOutput = ConsoleCaptureHelper.Capture(() => + sut.Run(["list", "--format", "json", "--no-logo"])); + var showOutput = ConsoleCaptureHelper.Capture(() => + sut.Run(["show", "--format", "xml", "--no-logo"])); + + listOutput.ExitCode.Should().Be(0); + listOutput.Text.Should().Contain("list:json"); + showOutput.ExitCode.Should().Be(0); + showOutput.Text.Should().Contain("show:xml"); + } + + [TestMethod] + [Description("Regression guard: verifies positional binding on group properties is opt-in through ReplArgument.")] + public void When_GroupPropertyUsesReplArgument_Then_PositionalBindingWorks() + { + var sut = ReplApp.Create(); + sut.Map("search", (PositionalSearchOptions options) => options.Query); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["search", "needle", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("needle"); + } + + [TestMethod] + [Description("Regression guard: verifies group property cannot receive named and positional values in one invocation.")] + public void When_GroupPropertyGetsNamedAndPositional_Then_InvocationFails() + { + var sut = ReplApp.Create(); + sut.Map("search", (PositionalSearchOptions options) => options.Query); + + var output = ConsoleCaptureHelper.Capture(() => + sut.Run(["search", "--query", "alpha", "beta", "--no-logo"])); + + output.ExitCode.Should().Be(1); + output.Text.Should().Contain("cannot receive both named and positional values"); + } + + [TestMethod] + [Description("Regression guard: verifies positional group properties cannot be mixed with positional regular parameters.")] + public void When_PositionalGroupPropertyMixedWithRegularPositional_Then_MapFails() + { + var sut = ReplApp.Create(); + + var act = () => sut.Map( + "search", + (PositionalSearchOptions options, [ReplArgument] string term) => $"{options.Query}:{term}"); + + act.Should().Throw() + .WithMessage("*Cannot mix positional options-group properties*"); + } + + [ReplOptionsGroup] + public abstract class AbstractGroup + { + public string Value { get; set; } = ""; + } +} diff --git a/src/Repl.IntegrationTests/Given_TemporalRangeTypes.cs b/src/Repl.IntegrationTests/Given_TemporalRangeTypes.cs new file mode 100644 index 0000000..0b79929 --- /dev/null +++ b/src/Repl.IntegrationTests/Given_TemporalRangeTypes.cs @@ -0,0 +1,124 @@ +namespace Repl.IntegrationTests; + +[TestClass] +[DoNotParallelize] +public sealed class Given_TemporalRangeTypes +{ + [TestMethod] + [Description("Regression guard: verifies date range with start..end syntax binds as option parameter.")] + public void When_UsingDateRangeWithDotDotSyntax_Then_RangeIsBound() + { + var sut = ReplApp.Create(); + sut.Map("report", (ReplDateRange period) => + $"{period.From:yyyy-MM-dd}|{period.To:yyyy-MM-dd}"); + + var output = ConsoleCaptureHelper.Capture(() => + sut.Run(["report", "--period", "2024-01-15..2024-02-15", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("2024-01-15|2024-02-15"); + } + + [TestMethod] + [Description("Regression guard: verifies date range with start@duration syntax binds as option parameter.")] + public void When_UsingDateRangeWithDurationSyntax_Then_RangeIsBound() + { + var sut = ReplApp.Create(); + sut.Map("report", (ReplDateRange period) => + $"{period.From:yyyy-MM-dd}|{period.To:yyyy-MM-dd}"); + + var output = ConsoleCaptureHelper.Capture(() => + sut.Run(["report", "--period", "2024-01-15@30d", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("2024-01-15|2024-02-14"); + } + + [TestMethod] + [Description("Regression guard: verifies datetime range with start..end syntax binds as option parameter.")] + public void When_UsingDateTimeRangeWithDotDotSyntax_Then_RangeIsBound() + { + var sut = ReplApp.Create(); + sut.Map("logs", (ReplDateTimeRange window) => + $"{window.From:HH:mm}|{window.To:HH:mm}"); + + var output = ConsoleCaptureHelper.Capture(() => + sut.Run(["logs", "--window", "2024-01-15T10:00..2024-01-15T18:00", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("10:00|18:00"); + } + + [TestMethod] + [Description("Regression guard: verifies datetime range with start@duration syntax binds as option parameter.")] + public void When_UsingDateTimeRangeWithDurationSyntax_Then_RangeIsBound() + { + var sut = ReplApp.Create(); + sut.Map("logs", (ReplDateTimeRange window) => + $"{window.From:HH:mm}|{window.To:HH:mm}"); + + var output = ConsoleCaptureHelper.Capture(() => + sut.Run(["logs", "--window", "2024-01-15T10:00@8h", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("10:00|18:00"); + } + + [TestMethod] + [Description("Regression guard: verifies datetimeoffset range with start..end syntax binds as option parameter.")] + public void When_UsingDateTimeOffsetRangeWithDotDotSyntax_Then_RangeIsBound() + { + var sut = ReplApp.Create(); + sut.Map("audit", (ReplDateTimeOffsetRange span) => + $"{span.From:HH:mm}|{span.To:HH:mm}"); + + var output = ConsoleCaptureHelper.Capture(() => + sut.Run(["audit", "--span", "2024-01-15T10:00+02:00..2024-01-15T18:00+02:00", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("10:00|18:00"); + } + + [TestMethod] + [Description("Regression guard: verifies datetimeoffset range with start@duration syntax binds as option parameter.")] + public void When_UsingDateTimeOffsetRangeWithDurationSyntax_Then_RangeIsBound() + { + var sut = ReplApp.Create(); + sut.Map("audit", (ReplDateTimeOffsetRange span) => + $"{span.From:HH:mm}|{span.To:HH:mm}"); + + var output = ConsoleCaptureHelper.Capture(() => + sut.Run(["audit", "--span", "2024-01-15T10:00+02:00@8h", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("10:00|18:00"); + } + + [TestMethod] + [Description("Regression guard: verifies invalid date range literal produces an error.")] + public void When_InvalidDateRangeLiteral_Then_InvocationFails() + { + var sut = ReplApp.Create(); + sut.Map("report", (ReplDateRange period) => "ok"); + + var output = ConsoleCaptureHelper.Capture(() => + sut.Run(["report", "--period", "not-a-range", "--no-logo"])); + + output.ExitCode.Should().Be(1); + output.Text.Should().Contain("not a valid date range literal"); + } + + [TestMethod] + [Description("Regression guard: verifies DateOnly range rejects sub-day durations.")] + public void When_DateRangeDurationIsSubDay_Then_InvocationFails() + { + var sut = ReplApp.Create(); + sut.Map("report", (ReplDateRange period) => "ok"); + + var output = ConsoleCaptureHelper.Capture(() => + sut.Run(["report", "--period", "2024-01-15@8h", "--no-logo"])); + + output.ExitCode.Should().Be(1); + output.Text.Should().Contain("whole days"); + } +} diff --git a/src/Repl.Tests/Given_TemporalRangeLiteralParser.cs b/src/Repl.Tests/Given_TemporalRangeLiteralParser.cs new file mode 100644 index 0000000..d612295 --- /dev/null +++ b/src/Repl.Tests/Given_TemporalRangeLiteralParser.cs @@ -0,0 +1,181 @@ +namespace Repl.Tests; + +[TestClass] +public sealed class Given_TemporalRangeLiteralParser +{ + [TestMethod] + [Description("Regression guard: verifies date range with start..end syntax parses correctly.")] + public void When_DateRangeWithDotDotSyntax_Then_RangeIsParsed() + { + var result = TemporalRangeLiteralParser.TryParseDateRange("2024-01-15..2024-02-15", out var range); + + result.Should().BeTrue(); + range.From.Should().Be(new DateOnly(2024, 1, 15)); + range.To.Should().Be(new DateOnly(2024, 2, 15)); + } + + [TestMethod] + [Description("Regression guard: verifies date range with start@duration syntax computes To correctly.")] + public void When_DateRangeWithDurationSyntax_Then_ToIsComputed() + { + var result = TemporalRangeLiteralParser.TryParseDateRange("2024-01-15@30d", out var range); + + result.Should().BeTrue(); + range.From.Should().Be(new DateOnly(2024, 1, 15)); + range.To.Should().Be(new DateOnly(2024, 2, 14)); + } + + [TestMethod] + [Description("Regression guard: verifies zero duration produces From == To.")] + public void When_DateRangeWithZeroDuration_Then_FromEqualsTo() + { + var result = TemporalRangeLiteralParser.TryParseDateRange("2024-01-15@0d", out var range); + + result.Should().BeTrue(); + range.From.Should().Be(range.To); + } + + [TestMethod] + [Description("Regression guard: verifies reversed date range returns false.")] + public void When_DateRangeIsReversed_Then_ReturnsFalse() + { + var result = TemporalRangeLiteralParser.TryParseDateRange("2024-02-15..2024-01-15", out _); + + result.Should().BeFalse(); + } + + [TestMethod] + [Description("Regression guard: verifies invalid left part returns false.")] + public void When_DateRangeHasInvalidLeft_Then_ReturnsFalse() + { + var result = TemporalRangeLiteralParser.TryParseDateRange("not-a-date..2024-01-15", out _); + + result.Should().BeFalse(); + } + + [TestMethod] + [Description("Regression guard: verifies invalid right part returns false.")] + public void When_DateRangeHasInvalidRight_Then_ReturnsFalse() + { + var result = TemporalRangeLiteralParser.TryParseDateRange("2024-01-15..not-a-date", out _); + + result.Should().BeFalse(); + } + + [TestMethod] + [Description("Regression guard: verifies invalid duration returns false.")] + public void When_DateRangeHasInvalidDuration_Then_ReturnsFalse() + { + var result = TemporalRangeLiteralParser.TryParseDateRange("2024-01-15@bad", out _); + + result.Should().BeFalse(); + } + + [TestMethod] + [Description("Regression guard: verifies DateOnly range rejects sub-day durations.")] + public void When_DateRangeHasSubDayDuration_Then_ReturnsFalse() + { + var result = TemporalRangeLiteralParser.TryParseDateRange("2024-01-15@8h", out _); + + result.Should().BeFalse(); + } + + [TestMethod] + [Description("Regression guard: verifies DateOnly range accepts whole-day durations expressed in hours.")] + public void When_DateRangeHasWholeDayDurationInHours_Then_ReturnsTrue() + { + var result = TemporalRangeLiteralParser.TryParseDateRange("2024-01-15@48h", out var range); + + result.Should().BeTrue(); + range.From.Should().Be(new DateOnly(2024, 1, 15)); + range.To.Should().Be(new DateOnly(2024, 1, 17)); + } + + [TestMethod] + [Description("Regression guard: verifies input without separator returns false.")] + public void When_DateRangeHasNoSeparator_Then_ReturnsFalse() + { + var result = TemporalRangeLiteralParser.TryParseDateRange("2024-01-15", out _); + + result.Should().BeFalse(); + } + + [TestMethod] + [Description("Regression guard: verifies empty string returns false.")] + public void When_DateRangeIsEmpty_Then_ReturnsFalse() + { + var result = TemporalRangeLiteralParser.TryParseDateRange("", out _); + + result.Should().BeFalse(); + } + + [TestMethod] + [Description("Regression guard: verifies datetime range with start..end syntax parses correctly.")] + public void When_DateTimeRangeWithDotDotSyntax_Then_RangeIsParsed() + { + var result = TemporalRangeLiteralParser.TryParseDateTimeRange( + "2024-01-15T10:00..2024-01-15T18:00", out var range); + + result.Should().BeTrue(); + range.From.Should().Be(new DateTime(2024, 1, 15, 10, 0, 0)); + range.To.Should().Be(new DateTime(2024, 1, 15, 18, 0, 0)); + } + + [TestMethod] + [Description("Regression guard: verifies datetime range with start@duration syntax computes To correctly.")] + public void When_DateTimeRangeWithDurationSyntax_Then_ToIsComputed() + { + var result = TemporalRangeLiteralParser.TryParseDateTimeRange( + "2024-01-15T10:00@1h30m", out var range); + + result.Should().BeTrue(); + range.From.Should().Be(new DateTime(2024, 1, 15, 10, 0, 0)); + range.To.Should().Be(new DateTime(2024, 1, 15, 11, 30, 0)); + } + + [TestMethod] + [Description("Regression guard: verifies reversed datetime range returns false.")] + public void When_DateTimeRangeIsReversed_Then_ReturnsFalse() + { + var result = TemporalRangeLiteralParser.TryParseDateTimeRange( + "2024-01-15T18:00..2024-01-15T10:00", out _); + + result.Should().BeFalse(); + } + + [TestMethod] + [Description("Regression guard: verifies datetimeoffset range with start..end syntax parses correctly.")] + public void When_DateTimeOffsetRangeWithDotDotSyntax_Then_RangeIsParsed() + { + var result = TemporalRangeLiteralParser.TryParseDateTimeOffsetRange( + "2024-01-15T10:00+02:00..2024-01-15T18:00+02:00", out var range); + + result.Should().BeTrue(); + range.From.Should().Be(new DateTimeOffset(2024, 1, 15, 10, 0, 0, TimeSpan.FromHours(2))); + range.To.Should().Be(new DateTimeOffset(2024, 1, 15, 18, 0, 0, TimeSpan.FromHours(2))); + } + + [TestMethod] + [Description("Regression guard: verifies datetimeoffset range with start@duration syntax computes To correctly.")] + public void When_DateTimeOffsetRangeWithDurationSyntax_Then_ToIsComputed() + { + var result = TemporalRangeLiteralParser.TryParseDateTimeOffsetRange( + "2024-01-15T10:00+02:00@8h", out var range); + + result.Should().BeTrue(); + range.From.Should().Be(new DateTimeOffset(2024, 1, 15, 10, 0, 0, TimeSpan.FromHours(2))); + range.To.Should().Be(new DateTimeOffset(2024, 1, 15, 18, 0, 0, TimeSpan.FromHours(2))); + } + + [TestMethod] + [Description("Regression guard: verifies datetimeoffset range with UTC offsets parses correctly.")] + public void When_DateTimeOffsetRangeWithUtcOffsets_Then_RangeIsParsed() + { + var result = TemporalRangeLiteralParser.TryParseDateTimeOffsetRange( + "2024-01-15T10:00Z..2024-01-15T18:00Z", out var range); + + result.Should().BeTrue(); + range.From.Offset.Should().Be(TimeSpan.Zero); + range.To.Offset.Should().Be(TimeSpan.Zero); + } +}