From 0f782feb1a297c030448a589bcd6197efd3afefe Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 3 Mar 2026 20:42:29 -0500 Subject: [PATCH 1/3] Add temporal range types with start..end and start@duration parsing Introduce ReplDateRange, ReplDateTimeRange, and ReplDateTimeOffsetRange as first-class parameter types with two literal syntaxes: range (start..end) and duration (start@duration). Parser uses ReadOnlySpan internally to minimize string allocations during split operations. --- src/Repl.Core/ParameterValueConverter.cs | 35 ++++ src/Repl.Core/ReplDateRange.cs | 7 + src/Repl.Core/ReplDateTimeOffsetRange.cs | 7 + src/Repl.Core/ReplDateTimeRange.cs | 7 + src/Repl.Core/TemporalRangeLiteralParser.cs | 179 ++++++++++++++++++ .../Given_TemporalRangeTypes.cs | 110 +++++++++++ .../Given_TemporalRangeLiteralParser.cs | 161 ++++++++++++++++ 7 files changed, 506 insertions(+) create mode 100644 src/Repl.Core/ReplDateRange.cs create mode 100644 src/Repl.Core/ReplDateTimeOffsetRange.cs create mode 100644 src/Repl.Core/ReplDateTimeRange.cs create mode 100644 src/Repl.Core/TemporalRangeLiteralParser.cs create mode 100644 src/Repl.IntegrationTests/Given_TemporalRangeTypes.cs create mode 100644 src/Repl.Tests/Given_TemporalRangeLiteralParser.cs diff --git a/src/Repl.Core/ParameterValueConverter.cs b/src/Repl.Core/ParameterValueConverter.cs index f57317a..5d58229 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."); + 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/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..8d9e3d9 --- /dev/null +++ b/src/Repl.Core/TemporalRangeLiteralParser.cs @@ -0,0 +1,179 @@ +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)) + { + 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; + } +} diff --git a/src/Repl.IntegrationTests/Given_TemporalRangeTypes.cs b/src/Repl.IntegrationTests/Given_TemporalRangeTypes.cs new file mode 100644 index 0000000..0fc40bd --- /dev/null +++ b/src/Repl.IntegrationTests/Given_TemporalRangeTypes.cs @@ -0,0 +1,110 @@ +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"); + } +} diff --git a/src/Repl.Tests/Given_TemporalRangeLiteralParser.cs b/src/Repl.Tests/Given_TemporalRangeLiteralParser.cs new file mode 100644 index 0000000..546a924 --- /dev/null +++ b/src/Repl.Tests/Given_TemporalRangeLiteralParser.cs @@ -0,0 +1,161 @@ +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 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); + } +} From 6b2858b4d48386078045e2df334e235d789af4fd Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 3 Mar 2026 20:42:52 -0500 Subject: [PATCH 2/3] Add [ReplOptionsGroup] for reusable parameter groups Introduce ReplOptionsGroupAttribute to mark classes whose public writable properties expand into individual command options. Property names are auto-lowered from PascalCase to camelCase for canonical tokens. Groups support aliases, reverse aliases, value aliases, and all existing option attributes on properties. Name collisions are detected at registration. Also adds documentation for both options groups and temporal ranges, and friendly type names for range types in help/docs output. --- docs/commands.md | 64 +++++ docs/parameter-system.md | 25 ++ src/Repl.Core/CoreReplApp.Documentation.cs | 82 ++++++- src/Repl.Core/HandlerArgumentBinder.cs | 146 +++++++++++ src/Repl.Core/HelpTextBuilder.cs | 116 ++++++++- .../Internal/Options/OptionSchemaBuilder.cs | 231 +++++++++++++++++- .../Attributes/ReplArgumentAttribute.cs | 2 +- .../Attributes/ReplOptionAttribute.cs | 2 +- .../Attributes/ReplOptionsGroupAttribute.cs | 10 + .../Attributes/ReplValueAliasAttribute.cs | 2 +- .../Given_OptionsGroupBinding.cs | 169 +++++++++++++ 11 files changed, 828 insertions(+), 21 deletions(-) create mode 100644 src/Repl.Core/Parameters/Attributes/ReplOptionsGroupAttribute.cs create mode 100644 src/Repl.IntegrationTests/Given_OptionsGroupBinding.cs diff --git a/docs/commands.md b/docs/commands.md index 04aba31..54ed9c6 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -95,6 +95,70 @@ 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 +- parameter name collisions between group properties and regular parameters cause an `InvalidOperationException` at registration +- 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. + ## Supported parameter conversions Handler parameters support native conversion for: diff --git a/docs/parameter-system.md b/docs/parameter-system.md index 5cf717a..7d606b7 100644 --- a/docs/parameter-system.md +++ b/docs/parameter-system.md @@ -42,6 +42,31 @@ 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`) +- properties with initializer values serve as defaults (no `HasDefaultValue` on `PropertyInfo`, so arity defaults to `ZeroOrOne`) +- 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 + +### 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. + These public types live under `Repl.Parameters`. Typical app code starts with: 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..9c57ae0 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,142 @@ 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)) + { + 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; + } } diff --git a/src/Repl.Core/HelpTextBuilder.cs b/src/Repl.Core/HelpTextBuilder.cs index 8eb21b9..08a57e4 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,27 @@ 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)) + { + if (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 +374,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 +396,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 +431,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 +504,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 +817,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..5e52ec0 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; @@ -27,7 +28,16 @@ public static OptionSchema Build( continue; } - AppendParameterSchemaEntries(parameter, entries, parameters); +#pragma warning disable IL2072 + if (IsOptionsGroupParameter(parameter)) + { + AppendOptionsGroupSchemaEntries(parameter.ParameterType, entries, parameters); + } +#pragma warning restore IL2072 + else + { + AppendParameterSchemaEntries(parameter, entries, parameters); + } } ValidateTokenCollisions(entries, parsingOptions); @@ -73,6 +83,12 @@ private static void AppendParameterSchemaEntries( var mode = optionAttribute?.Mode ?? argumentAttribute?.Mode ?? ReplParameterMode.OptionAndPositional; + 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 +224,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 +282,210 @@ private static void AppendEnumAliases( } } + private static bool IsOptionsGroupParameter(ParameterInfo parameter) => + Attribute.IsDefined(parameter.ParameterType, typeof(ReplOptionsGroupAttribute), inherit: true); + + [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) + { + 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); + } + } + + [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) + { + var optionAttribute = property.GetCustomAttribute(inherit: true); + var argumentAttribute = property.GetCustomAttribute(inherit: true); + var mode = optionAttribute?.Mode + ?? argumentAttribute?.Mode + ?? ReplParameterMode.OptionAndPositional; + 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.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/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.IntegrationTests/Given_OptionsGroupBinding.cs b/src/Repl.IntegrationTests/Given_OptionsGroupBinding.cs new file mode 100644 index 0000000..3f018a9 --- /dev/null +++ b/src/Repl.IntegrationTests/Given_OptionsGroupBinding.cs @@ -0,0 +1,169 @@ +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; } + } + + [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"); + } + + [ReplOptionsGroup] + public abstract class AbstractGroup + { + public string Value { get; set; } = ""; + } +} From 2b6fefa9c9871546519eeb703af277cedfa35286 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 3 Mar 2026 22:20:43 -0500 Subject: [PATCH 3/3] Harden options groups and date range behavior --- docs/commands.md | 4 ++ docs/comparison.md | 4 ++ docs/parameter-system.md | 5 ++ samples/01-core-basics/Program.cs | 33 ++++++++- samples/01-core-basics/README.md | 31 ++++++++ samples/README.md | 2 +- src/Repl.Core/HandlerArgumentBinder.cs | 20 ++++++ src/Repl.Core/HelpTextBuilder.cs | 8 +-- .../Internal/Options/OptionSchemaBuilder.cs | 70 ++++++++++++++++--- src/Repl.Core/ParameterValueConverter.cs | 2 +- src/Repl.Core/TemporalRangeLiteralParser.cs | 6 +- .../Given_OptionsGroupBinding.cs | 48 +++++++++++++ .../Given_TemporalRangeTypes.cs | 14 ++++ .../Given_TemporalRangeLiteralParser.cs | 20 ++++++ 14 files changed, 247 insertions(+), 20 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 54ed9c6..4836a52 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -125,7 +125,10 @@ Options group behavior: - `[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 @@ -158,6 +161,7 @@ Available types: 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 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 7d606b7..1a1190e 100644 --- a/docs/parameter-system.md +++ b/docs/parameter-system.md @@ -48,9 +48,12 @@ Supporting enums: - 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 @@ -66,6 +69,7 @@ These types live under `Repl` namespace and support two parsing syntaxes: - 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: @@ -126,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/HandlerArgumentBinder.cs b/src/Repl.Core/HandlerArgumentBinder.cs index 9c57ae0..0a9e0b9 100644 --- a/src/Repl.Core/HandlerArgumentBinder.cs +++ b/src/Repl.Core/HandlerArgumentBinder.cs @@ -549,6 +549,13 @@ private static object BindOptionsGroup( 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, @@ -657,4 +664,17 @@ private static bool TryConsumePositionalForProperty( 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 08a57e4..023fa3e 100644 --- a/src/Repl.Core/HelpTextBuilder.cs +++ b/src/Repl.Core/HelpTextBuilder.cs @@ -335,12 +335,10 @@ private static string BuildOptionSection(RouteDefinition route, bool useAnsi, An } var defaultInstance = CreateOptionsGroupDefault(methodParam.ParameterType); - foreach (var prop in GetOptionsGroupProperties(methodParam.ParameterType)) + foreach (var prop in GetOptionsGroupProperties(methodParam.ParameterType) + .Where(prop => prop.CanWrite && !groupProperties.ContainsKey(prop.Name))) { - if (prop.CanWrite && !groupProperties.ContainsKey(prop.Name)) - { - groupProperties[prop.Name] = (prop, defaultInstance); - } + groupProperties[prop.Name] = (prop, defaultInstance); } } diff --git a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs index 5e52ec0..77a7ca6 100644 --- a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs +++ b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs @@ -21,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)) @@ -31,15 +33,24 @@ public static OptionSchema Build( #pragma warning disable IL2072 if (IsOptionsGroupParameter(parameter)) { - AppendOptionsGroupSchemaEntries(parameter.ParameterType, entries, parameters); + 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); } @@ -79,10 +90,7 @@ 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( @@ -285,6 +293,44 @@ 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", @@ -293,7 +339,8 @@ private static void AppendOptionsGroupSchemaEntries( [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type groupType, List entries, - Dictionary parameters) + Dictionary parameters, + List positionalPropertyNames) { ValidateOptionsGroupType(groupType); @@ -310,7 +357,7 @@ private static void AppendOptionsGroupSchemaEntries( $"Nested options groups are not supported. Property '{property.Name}' on '{groupType.Name}' is itself an options group."); } - AppendPropertySchemaEntries(property, entries, parameters); + AppendPropertySchemaEntries(property, entries, parameters, positionalPropertyNames); } } @@ -338,13 +385,14 @@ private static void ValidateOptionsGroupType( private static void AppendPropertySchemaEntries( PropertyInfo property, List entries, - Dictionary parameters) + Dictionary parameters, + List positionalPropertyNames) { var optionAttribute = property.GetCustomAttribute(inherit: true); var argumentAttribute = property.GetCustomAttribute(inherit: true); var mode = optionAttribute?.Mode ?? argumentAttribute?.Mode - ?? ReplParameterMode.OptionAndPositional; + ?? ReplParameterMode.OptionOnly; if (parameters.ContainsKey(property.Name)) { throw new InvalidOperationException( @@ -356,6 +404,10 @@ private static void AppendPropertySchemaEntries( property.PropertyType, mode, CaseSensitivity: optionAttribute?.CaseSensitivity); + if (mode != ReplParameterMode.OptionOnly) + { + positionalPropertyNames.Add(property.Name); + } if (mode == ReplParameterMode.ArgumentOnly) { return; diff --git a/src/Repl.Core/ParameterValueConverter.cs b/src/Repl.Core/ParameterValueConverter.cs index 5d58229..4460270 100644 --- a/src/Repl.Core/ParameterValueConverter.cs +++ b/src/Repl.Core/ParameterValueConverter.cs @@ -159,7 +159,7 @@ private static bool TryConvertTemporalRange(string value, Type nonNullableType, { 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."); + : throw new FormatException($"'{value}' is not a valid date range literal. Use start..end or start@duration with whole days."); return true; } diff --git a/src/Repl.Core/TemporalRangeLiteralParser.cs b/src/Repl.Core/TemporalRangeLiteralParser.cs index 8d9e3d9..0cd7580 100644 --- a/src/Repl.Core/TemporalRangeLiteralParser.cs +++ b/src/Repl.Core/TemporalRangeLiteralParser.cs @@ -31,7 +31,8 @@ public static bool TryParseDateRange(string value, out ReplDateRange range) if (TrySplitDuration(span, out left, out var durationPart)) { if (!TemporalLiteralParser.TryParseDateOnly(left.ToString(), out var from) - || !TimeSpanLiteralParser.TryParse(durationPart.ToString(), out var duration)) + || !TimeSpanLiteralParser.TryParse(durationPart.ToString(), out var duration) + || !IsWholeDays(duration)) { return false; } @@ -176,4 +177,7 @@ private static bool TrySplitDuration( 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 index 3f018a9..eb0fc75 100644 --- a/src/Repl.IntegrationTests/Given_OptionsGroupBinding.cs +++ b/src/Repl.IntegrationTests/Given_OptionsGroupBinding.cs @@ -25,6 +25,13 @@ public class TestPagingOptions 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() @@ -161,6 +168,47 @@ public void When_SameGroupReusedInTwoCommands_Then_BothWork() 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 { diff --git a/src/Repl.IntegrationTests/Given_TemporalRangeTypes.cs b/src/Repl.IntegrationTests/Given_TemporalRangeTypes.cs index 0fc40bd..0b79929 100644 --- a/src/Repl.IntegrationTests/Given_TemporalRangeTypes.cs +++ b/src/Repl.IntegrationTests/Given_TemporalRangeTypes.cs @@ -107,4 +107,18 @@ public void When_InvalidDateRangeLiteral_Then_InvocationFails() 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 index 546a924..d612295 100644 --- a/src/Repl.Tests/Given_TemporalRangeLiteralParser.cs +++ b/src/Repl.Tests/Given_TemporalRangeLiteralParser.cs @@ -71,6 +71,26 @@ public void When_DateRangeHasInvalidDuration_Then_ReturnsFalse() 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()