diff --git a/README.md b/README.md index 4413e0b..a862a25 100644 --- a/README.md +++ b/README.md @@ -233,8 +233,11 @@ ws-7c650a64 websocket [::1]:60288 301x31 xterm-256color 1m 34s 1s - **Hierarchical contexts** (scopes) with validation and navigation results (`NavigateUp`, `NavigateTo`) - **Routing constraints** (`{id:int}`, `{when:date}`, `{x:guid}`…) plus custom constraints - **Parsing and binding** for named options, positional args, route values, and injected services +- **Strict option validation by default** (unknown options fail fast; configurable) +- **Response files** with `@file.rsp` expansion for complex invocations - **Output pipeline** with transformers and aliases (`--output:`, `--json`, `--yaml`, `--markdown`, …) +- **Extensible global options** via `options.Parsing.AddGlobalOption(...)` - **Typed result model** (`Results.Ok/Error/Validation/NotFound/Cancelled`, etc.) - **Protocol passthrough mode** for stdio transports (`AsProtocolPassthrough()`), keeping `stdout` reserved for protocol payloads - **Typed interactions**: prompts, progress, status, timeouts, cancellation @@ -279,6 +282,7 @@ Package details: - Architecture blueprint: [`docs/architecture.md`](docs/architecture.md) - Command reference: [`docs/commands.md`](docs/commands.md) +- Parameter system notes: [`docs/parameter-system.md`](docs/parameter-system.md) - Terminal/session metadata: [`docs/terminal-metadata.md`](docs/terminal-metadata.md) - Testing toolkit: [`docs/testing-toolkit.md`](docs/testing-toolkit.md) - Conditional module presence: [`docs/module-presence.md`](docs/module-presence.md) diff --git a/docs/architecture.md b/docs/architecture.md index c6c8c2d..3bc9f67 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -70,6 +70,12 @@ - Transport-native signaling (DTTERM push, Telnet NAWS/TERMINAL-TYPE) is preferred. - `@@repl:*` control messages and out-of-band metadata are supported extension patterns. +## Related docs + +- Command reference: `docs/commands.md` +- Parameter system: `docs/parameter-system.md` +- Shell completion: `docs/shell-completion.md` + ## Branching and versioning - NBGV (`version.json`) drives package/release versioning. diff --git a/docs/commands.md b/docs/commands.md index 5e9980f..04aba31 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -20,6 +20,89 @@ These flags are parsed before route execution: - `--output:` - output aliases mapped by `OutputOptions.Aliases` (defaults include `--json`, `--xml`, `--yaml`, `--yml`, `--markdown`) - `--answer:[=value]` for non-interactive prompt answers +- custom global options registered via `options.Parsing.AddGlobalOption(...)` + +Global parsing notes: + +- unknown command options are validation errors by default (`options.Parsing.AllowUnknownOptions = false`) +- option name matching is case-sensitive by default (`options.Parsing.OptionCaseSensitivity = CaseSensitive`) +- option value syntaxes accepted by command parsing: `--name value`, `--name=value`, `--name:value` +- use `--` to stop option parsing and force remaining tokens to positional arguments +- response files are supported with `@file.rsp` (enabled by default); nested `@` expansion is not supported + +## Declaring command options + +Handler parameters can declare explicit option behavior with attributes: + +- `[ReplOption]` for named options +- `[ReplArgument]` for positional behavior +- `[ReplValueAlias]` for token-to-value injection +- `[ReplEnumFlag]` on enum members for enum-token aliases + +Example: + +```csharp +using Repl.Parameters; + +app.Map( + "render", + ([ReplOption(Aliases = ["-m"])] RenderMode mode = RenderMode.Fast, + [ReplOption(ReverseAliases = ["--no-verbose"])] bool verbose = false) => + $"{mode}:{verbose}"); +``` + +Root help now includes a dedicated `Global Options:` section with built-ins plus custom options registered through `options.Parsing.AddGlobalOption(...)`. + +## Parse diagnostics model + +Command option parsing returns structured diagnostics through the internal `OptionParsingResult` model: + +- `Diagnostics`: list of `ParseDiagnostic` +- `HasErrors`: true when any diagnostic has `Severity = Error` +- `ParseDiagnostic` fields: + - `Severity`: `Error` or `Warning` + - `Message`: user-facing explanation + - `Token`: source token when available + - `Suggestion`: optional typo hint (for example `--output`) + +Runtime behavior: + +- when at least one parsing error is present, command execution stops and the first error is rendered as a validation result +- warnings do not block execution + +## Response file examples + +`@file.rsp` is expanded before command option parsing. + +Example file: + +```text +--output json +# comments are ignored outside quoted sections +"two words" +``` + +Command: + +```text +myapp echo @args.rsp +``` + +Notes: + +- quotes and escapes are supported by the response-file tokenizer +- a standalone `@` token is treated as a normal positional token +- 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 + +## Supported parameter conversions + +Handler parameters support native conversion for: + +- `FileInfo` from string path tokens (for example `--path ./file.txt`) +- `DirectoryInfo` from string path tokens (for example `--path ./folder`) + +Path existence is not validated at parse time; handlers decide validation policy. ## Ambient commands diff --git a/docs/parameter-system.md b/docs/parameter-system.md new file mode 100644 index 0000000..5cf717a --- /dev/null +++ b/docs/parameter-system.md @@ -0,0 +1,108 @@ +# Parameter System + +This document describes Repl Toolkit's parameter/option model and key design decisions. + +## Goals + +- keep app-side declaration simple (handler-first, low ceremony) +- keep runtime parsing strict and predictable by default +- share one option model across parsing, help, completion, and docs + +## Current behavior highlights + +- unknown command options are validation errors by default +- option value syntaxes: `--name value`, `--name=value`, `--name:value` +- option parsing is case-sensitive by default, configurable via `ParsingOptions.OptionCaseSensitivity` +- response files are supported with `@file.rsp` (non-recursive) +- custom global options can be registered via `ParsingOptions.AddGlobalOption(...)` +- signed numeric literals (`-1`, `-0.5`, `-1e3`) are treated as positional values, not options + +## Public declaration API + +Application-facing parameter DSL: + +- `ReplOptionAttribute` + - canonical `Name` + - explicit `Aliases` (full tokens, for example `-m`, `--mode`) + - explicit `ReverseAliases` (for example `--no-verbose`) + - `Mode` (`OptionOnly`, `ArgumentOnly`, `OptionAndPositional`) + - optional per-parameter `CaseSensitivity` + - optional `Arity` +- `ReplArgumentAttribute` + - optional positional `Position` + - `Mode` +- `ReplValueAliasAttribute` + - maps a token to an injected parameter value (for example `--json` -> `output=json`) +- `ReplEnumFlagAttribute` + - maps enum members to explicit alias tokens + +Supporting enums: + +- `ReplCaseSensitivity` +- `ReplParameterMode` +- `ReplArity` + +These public types live under `Repl.Parameters`. +Typical app code starts with: + +```csharp +using Repl.Parameters; +``` + +## Public namespace map + +The public API is grouped by concern: + +- `Repl.Parameters` for option/argument declaration attributes +- `Repl.Documentation` for documentation export contracts +- `Repl.ShellCompletion` for shell completion setup/runtime options +- `Repl.Terminal` for terminal metadata/control contracts +- `Repl.Interaction` for prompt/progress/status interaction contracts +- `Repl.Autocomplete` for interactive autocomplete options +- `Repl.Rendering` for ANSI rendering/palette contracts + +## Internal architecture boundary + +The option engine internals are intentionally not public: + +- schema model and runtime parser internals live under `src/Repl.Core/Internal/Options` +- these internals are consumed by command parsing, help rendering, shell completion, and documentation export +- only the declaration DSL above is public for application code + +Documentation-export contracts are also separated from the root namespace: + +- `DocumentationExportOptions` +- `ReplDocumentationModel` +- `ReplDoc*` records + +These types now live under `Repl.Documentation`. + +## Help/completion/doc consistency + +Command option metadata is generated from one internal schema per route. +This same schema drives: + +- runtime parsing and diagnostics +- command help option sections +- shell option completion candidates +- exported documentation option metadata + +## System.CommandLine comparison + +### Similarities + +- modern long-option syntaxes and `--` sentinel semantics +- structured parsing errors and typo suggestions +- explicit aliases and discoverable command surfaces + +### Differences + +- Repl Toolkit is handler-first and REPL/session-aware by design +- 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 + +## Notes + +- this document is intentionally focused on parameter-system behavior and tradeoffs +- API-level and phased implementation details remain tracked in active engineering tasks diff --git a/docs/terminal-metadata.md b/docs/terminal-metadata.md index 15db003..0b7a246 100644 --- a/docs/terminal-metadata.md +++ b/docs/terminal-metadata.md @@ -2,6 +2,14 @@ This file is the canonical behavior document for terminal/session metadata in Repl Toolkit. +## Public API Namespace + +Terminal control and capability contracts are exposed from `Repl.Terminal`: + +```csharp +using Repl.Terminal; +``` + ## Boundary model (A-D) with real OSI references | Alias | Real OSI layer(s) | Scope in this project | Notes | diff --git a/docs/testing-toolkit.md b/docs/testing-toolkit.md index ede0407..9dc6c9c 100644 --- a/docs/testing-toolkit.md +++ b/docs/testing-toolkit.md @@ -12,6 +12,8 @@ It is test-framework-agnostic and assertion-library-agnostic. ```csharp using Repl.Testing; +using Repl.Interaction; +using Repl.Terminal; await using var host = ReplTestHost.Create(() => { diff --git a/samples/04-interactive-ops/GlobalUsings.cs b/samples/04-interactive-ops/GlobalUsings.cs new file mode 100644 index 0000000..cdf3a76 --- /dev/null +++ b/samples/04-interactive-ops/GlobalUsings.cs @@ -0,0 +1 @@ +global using Repl.Interaction; diff --git a/samples/05-hosting-remote/GlobalUsings.cs b/samples/05-hosting-remote/GlobalUsings.cs new file mode 100644 index 0000000..8087f8a --- /dev/null +++ b/samples/05-hosting-remote/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Repl.Interaction; +global using Repl.Terminal; diff --git a/samples/06-testing/GlobalUsings.cs b/samples/06-testing/GlobalUsings.cs index 81a954f..2bbb11c 100644 --- a/samples/06-testing/GlobalUsings.cs +++ b/samples/06-testing/GlobalUsings.cs @@ -1,5 +1,6 @@ global using AwesomeAssertions; global using Microsoft.VisualStudio.TestTools.UnitTesting; +global using Repl.Interaction; global using Repl; global using Repl.Testing; global using System; diff --git a/src/Repl.Core/AutocompleteMode.cs b/src/Repl.Core/Autocomplete/Public/AutocompleteMode.cs similarity index 93% rename from src/Repl.Core/AutocompleteMode.cs rename to src/Repl.Core/Autocomplete/Public/AutocompleteMode.cs index 021a812..e36c102 100644 --- a/src/Repl.Core/AutocompleteMode.cs +++ b/src/Repl.Core/Autocomplete/Public/AutocompleteMode.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Autocomplete; /// /// Configures interactive autocomplete behavior. diff --git a/src/Repl.Core/AutocompleteOptions.cs b/src/Repl.Core/Autocomplete/Public/AutocompleteOptions.cs similarity index 98% rename from src/Repl.Core/AutocompleteOptions.cs rename to src/Repl.Core/Autocomplete/Public/AutocompleteOptions.cs index d9df320..c7aea75 100644 --- a/src/Repl.Core/AutocompleteOptions.cs +++ b/src/Repl.Core/Autocomplete/Public/AutocompleteOptions.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Autocomplete; /// /// Interactive autocomplete options. diff --git a/src/Repl.Core/AutocompletePresentation.cs b/src/Repl.Core/Autocomplete/Public/AutocompletePresentation.cs similarity index 94% rename from src/Repl.Core/AutocompletePresentation.cs rename to src/Repl.Core/Autocomplete/Public/AutocompletePresentation.cs index cf1088f..ba9ad38 100644 --- a/src/Repl.Core/AutocompletePresentation.cs +++ b/src/Repl.Core/Autocomplete/Public/AutocompletePresentation.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Autocomplete; /// /// Defines high-level autocomplete interaction style. diff --git a/src/Repl.Core/CoreReplApp.Documentation.cs b/src/Repl.Core/CoreReplApp.Documentation.cs index 74dc25e..fd01997 100644 --- a/src/Repl.Core/CoreReplApp.Documentation.cs +++ b/src/Repl.Core/CoreReplApp.Documentation.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Reflection; +using Repl.Internal.Options; namespace Repl; @@ -150,11 +151,7 @@ private ReplDocCommand BuildDocumentationCommand(RouteDefinition route) && parameter.ParameterType != typeof(CancellationToken) && !routeParameterNames.Contains(parameter.Name!) && !IsFrameworkInjectedParameter(parameter.ParameterType)) - .Select(parameter => new ReplDocOption( - Name: parameter.Name!, - Type: GetFriendlyTypeName(parameter.ParameterType), - Required: IsRequiredParameter(parameter), - Description: parameter.GetCustomAttribute()?.Description)) + .Select(parameter => BuildDocumentationOption(route.OptionSchema, parameter)) .ToArray(); return new ReplDocCommand( @@ -233,6 +230,11 @@ private static string GetFriendlyTypeName(Type type) return $"{GetFriendlyTypeName(underlying)}?"; } + if (type.IsEnum) + { + return string.Join('|', Enum.GetNames(type)); + } + if (!type.IsGenericType) { return type.Name.ToLowerInvariant() switch @@ -256,4 +258,44 @@ private static string GetFriendlyTypeName(Type type) var genericArgs = string.Join(", ", type.GetGenericArguments().Select(GetFriendlyTypeName)); return $"{genericName}<{genericArgs}>"; } + + private static ReplDocOption BuildDocumentationOption(OptionSchema schema, ParameterInfo parameter) + { + var entries = schema.Entries + .Where(entry => string.Equals(entry.ParameterName, parameter.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(parameter.ParameterType) ?? parameter.ParameterType; + var enumValues = effectiveType.IsEnum + ? Enum.GetNames(effectiveType) + : []; + var defaultValue = parameter.HasDefaultValue && parameter.DefaultValue is not null + ? parameter.DefaultValue.ToString() + : null; + return new ReplDocOption( + Name: parameter.Name!, + Type: GetFriendlyTypeName(parameter.ParameterType), + Required: IsRequiredParameter(parameter), + Description: parameter.GetCustomAttribute()?.Description, + Aliases: aliases, + ReverseAliases: reverseAliases, + ValueAliases: valueAliases, + EnumValues: enumValues, + DefaultValue: defaultValue); + } } diff --git a/src/Repl.Core/CoreReplApp.Interactive.cs b/src/Repl.Core/CoreReplApp.Interactive.cs index 9867d59..cc25400 100644 --- a/src/Repl.Core/CoreReplApp.Interactive.cs +++ b/src/Repl.Core/CoreReplApp.Interactive.cs @@ -149,7 +149,7 @@ or AmbientCommandOutcome.Handled } var invocationTokens = scopeTokens.Concat(inputTokens).ToArray(); - var globalOptions = GlobalOptionParser.Parse(invocationTokens, _options.Output); + var globalOptions = GlobalOptionParser.Parse(invocationTokens, _options.Output, _options.Parsing); var prefixResolution = ResolveUniquePrefixes(globalOptions.RemainingTokens); if (prefixResolution.IsAmbiguous) { diff --git a/src/Repl.Core/CoreReplApp.OptionParsing.cs b/src/Repl.Core/CoreReplApp.OptionParsing.cs new file mode 100644 index 0000000..bb100e7 --- /dev/null +++ b/src/Repl.Core/CoreReplApp.OptionParsing.cs @@ -0,0 +1,62 @@ +namespace Repl; + +public sealed partial class CoreReplApp +{ + private static bool TryFindGlobalCommandOptionCollision( + GlobalInvocationOptions globalOptions, + HashSet knownOptionNames, + out string collidingOption) + { + foreach (var globalOption in globalOptions.CustomGlobalNamedOptions.Keys) + { + if (!knownOptionNames.Contains(globalOption)) + { + continue; + } + + collidingOption = $"--{globalOption}"; + return true; + } + + collidingOption = string.Empty; + return false; + } + + private static IReadOnlyDictionary> MergeNamedOptions( + IReadOnlyDictionary> commandNamedOptions, + IReadOnlyDictionary> globalNamedOptions) + { + if (globalNamedOptions.Count == 0) + { + return commandNamedOptions; + } + + var merged = new Dictionary>( + commandNamedOptions, + StringComparer.OrdinalIgnoreCase); + foreach (var pair in globalNamedOptions) + { + if (merged.TryGetValue(pair.Key, out var existing)) + { + var appended = existing.Concat(pair.Value).ToArray(); + merged[pair.Key] = appended; + continue; + } + + merged[pair.Key] = pair.Value; + } + + return merged; + } + + private ParsingOptions BuildEffectiveCommandParsingOptions() + { + var isInteractiveSession = _runtimeState.Value?.IsInteractiveSession == true; + return new ParsingOptions + { + AllowUnknownOptions = _options.Parsing.AllowUnknownOptions, + OptionCaseSensitivity = _options.Parsing.OptionCaseSensitivity, + AllowResponseFiles = !isInteractiveSession && _options.Parsing.AllowResponseFiles, + }; + } +} diff --git a/src/Repl.Core/CoreReplApp.Routing.cs b/src/Repl.Core/CoreReplApp.Routing.cs index d7a6545..7d59ada 100644 --- a/src/Repl.Core/CoreReplApp.Routing.cs +++ b/src/Repl.Core/CoreReplApp.Routing.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Reflection; +using Repl.Internal.Options; namespace Repl; @@ -178,6 +179,8 @@ private async ValueTask ValidateContextAsync( contextMatch.RouteValues, new Dictionary>(StringComparer.OrdinalIgnoreCase), [], + OptionSchema.Empty, + _options.Parsing.OptionCaseSensitivity, [], _options.Parsing.NumericFormatProvider, serviceProvider, @@ -327,6 +330,8 @@ private async ValueTask InvokeBannerAsync( routeValues: new Dictionary(StringComparer.OrdinalIgnoreCase), namedOptions: new Dictionary>(StringComparer.OrdinalIgnoreCase), positionalArguments: [], + optionSchema: OptionSchema.Empty, + optionCaseSensitivity: _options.Parsing.OptionCaseSensitivity, contextValues: [ReplSessionIO.Output], numericFormatProvider: _options.Parsing.NumericFormatProvider, serviceProvider: serviceProvider, diff --git a/src/Repl.Core/CoreReplApp.ShellCompletion.cs b/src/Repl.Core/CoreReplApp.ShellCompletion.cs index 58a6f22..a930cb8 100644 --- a/src/Repl.Core/CoreReplApp.ShellCompletion.cs +++ b/src/Repl.Core/CoreReplApp.ShellCompletion.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Reflection; +using Repl.Internal.Options; namespace Repl; @@ -36,6 +37,19 @@ private string[] ResolveShellCompletionCandidates(string line, int cursor) var hasTerminalRoute = routeMatch is not null && routeMatch.RemainingTokens.Count == 0; var dedupe = new HashSet(StringComparer.OrdinalIgnoreCase); var candidates = new List(capacity: 16); + if (!currentTokenIsOption + && hasTerminalRoute + && TryAddRouteEnumValueCandidates( + routeMatch!.Route, + state.PriorTokens, + currentTokenPrefix, + dedupe, + candidates)) + { + candidates.Sort(StringComparer.OrdinalIgnoreCase); + return [.. candidates]; + } + if (!currentTokenIsOption) { AddShellCommandCandidates( @@ -60,6 +74,91 @@ private string[] ResolveShellCompletionCandidates(string line, int cursor) return [.. candidates]; } + private bool TryAddRouteEnumValueCandidates( + RouteDefinition route, + string[] priorTokens, + string currentTokenPrefix, + HashSet dedupe, + List candidates) + { + if (!TryResolvePendingRouteOption(route, priorTokens, out var entry)) + { + return false; + } + + if (!route.OptionSchema.TryGetParameter(entry.ParameterName, out var parameter)) + { + return false; + } + + var enumType = Nullable.GetUnderlyingType(parameter.ParameterType) ?? parameter.ParameterType; + if (!enumType.IsEnum) + { + return false; + } + + var effectiveCaseSensitivity = parameter.CaseSensitivity ?? _options.Parsing.OptionCaseSensitivity; + var comparison = effectiveCaseSensitivity == ReplCaseSensitivity.CaseInsensitive + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + var beforeCount = candidates.Count; + foreach (var enumName in Enum + .GetNames(enumType) + .Where(name => name.StartsWith(currentTokenPrefix, comparison))) + { + TryAddShellCompletionCandidate(enumName, dedupe, candidates); + } + + return candidates.Count > beforeCount; + } + + private bool TryResolvePendingRouteOption( + RouteDefinition route, + string[] priorTokens, + out OptionSchemaEntry entry) + { + entry = default!; + if (priorTokens.Length <= 1) + { + return false; + } + + var commandTokens = priorTokens[1..]; + if (commandTokens.Length == 0) + { + return false; + } + + var previousToken = commandTokens[^1]; + if (!IsGlobalOptionToken(previousToken)) + { + return false; + } + + var separatorIndex = previousToken.IndexOfAny(['=', ':']); + if (separatorIndex >= 0) + { + return false; + } + + var matches = route.OptionSchema.ResolveToken(previousToken, _options.Parsing.OptionCaseSensitivity); + var distinct = matches + .DistinctBy(candidate => (candidate.ParameterName, candidate.TokenKind, candidate.InjectedValue), ShellOptionSchemaEntryComparer.Instance) + .ToArray(); + if (distinct.Length != 1) + { + return false; + } + + if (distinct[0].TokenKind is not (OptionSchemaTokenKind.NamedOption or OptionSchemaTokenKind.BoolFlag)) + { + return false; + } + + entry = distinct[0]; + return true; + } + private static void TryAddShellCompletionCandidate( string candidate, HashSet dedupe, @@ -120,9 +219,12 @@ private void AddGlobalShellOptionCandidates( HashSet dedupe, List candidates) { + var comparison = _options.Parsing.OptionCaseSensitivity == ReplCaseSensitivity.CaseInsensitive + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; foreach (var option in StaticShellGlobalOptions) { - if (option.StartsWith(currentTokenPrefix, StringComparison.OrdinalIgnoreCase)) + if (option.StartsWith(currentTokenPrefix, comparison)) { TryAddShellCompletionCandidate(option, dedupe, candidates); } @@ -131,7 +233,7 @@ private void AddGlobalShellOptionCandidates( foreach (var alias in _options.Output.Aliases.Keys) { var option = $"--{alias}"; - if (option.StartsWith(currentTokenPrefix, StringComparison.OrdinalIgnoreCase)) + if (option.StartsWith(currentTokenPrefix, comparison)) { TryAddShellCompletionCandidate(option, dedupe, candidates); } @@ -140,44 +242,43 @@ private void AddGlobalShellOptionCandidates( foreach (var format in _options.Output.Transformers.Keys) { var option = $"--output:{format}"; - if (option.StartsWith(currentTokenPrefix, StringComparison.OrdinalIgnoreCase)) + if (option.StartsWith(currentTokenPrefix, comparison)) { TryAddShellCompletionCandidate(option, dedupe, candidates); } } - } - private static void AddRouteShellOptionCandidates( - RouteDefinition route, - string currentTokenPrefix, - HashSet dedupe, - List candidates) - { - var routeParameterNames = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var segment in route.Template.Segments) + foreach (var custom in _options.Parsing.GlobalOptions.Values) { - if (segment is DynamicRouteSegment dynamicSegment) + if (custom.CanonicalToken.StartsWith(currentTokenPrefix, comparison)) { - routeParameterNames.Add(dynamicSegment.Name); + TryAddShellCompletionCandidate(custom.CanonicalToken, dedupe, candidates); } - } - foreach (var parameter in route.Command.Handler.Method.GetParameters()) - { - if (string.IsNullOrWhiteSpace(parameter.Name) - || parameter.ParameterType == typeof(CancellationToken) - || routeParameterNames.Contains(parameter.Name) - || IsFrameworkInjectedParameter(parameter.ParameterType) - || parameter.GetCustomAttribute() is not null - || parameter.GetCustomAttribute() is not null) + foreach (var alias in custom.Aliases) { - continue; + if (alias.StartsWith(currentTokenPrefix, comparison)) + { + TryAddShellCompletionCandidate(alias, dedupe, candidates); + } } + } + } - var option = $"--{parameter.Name}"; - if (option.StartsWith(currentTokenPrefix, StringComparison.OrdinalIgnoreCase)) + private void AddRouteShellOptionCandidates( + RouteDefinition route, + string currentTokenPrefix, + HashSet dedupe, + List candidates) + { + var comparison = _options.Parsing.OptionCaseSensitivity == ReplCaseSensitivity.CaseInsensitive + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + foreach (var token in route.OptionSchema.KnownTokens) + { + if (token.StartsWith(currentTokenPrefix, comparison)) { - TryAddShellCompletionCandidate(option, dedupe, candidates); + TryAddShellCompletionCandidate(token, dedupe, candidates); } } } @@ -302,4 +403,25 @@ private string ResolveShellCompletionCommandName() Environment.ProcessPath, app.Name); } + + private sealed class ShellOptionSchemaEntryComparer : IEqualityComparer<(string ParameterName, OptionSchemaTokenKind TokenKind, string? InjectedValue)> + { + public static ShellOptionSchemaEntryComparer Instance { get; } = new(); + + public bool Equals( + (string ParameterName, OptionSchemaTokenKind TokenKind, string? InjectedValue) x, + (string ParameterName, OptionSchemaTokenKind TokenKind, string? InjectedValue) y) => + string.Equals(x.ParameterName, y.ParameterName, StringComparison.OrdinalIgnoreCase) + && x.TokenKind == y.TokenKind + && string.Equals(x.InjectedValue, y.InjectedValue, StringComparison.Ordinal); + + public int GetHashCode((string ParameterName, OptionSchemaTokenKind TokenKind, string? InjectedValue) obj) + { + var parameterHash = StringComparer.OrdinalIgnoreCase.GetHashCode(obj.ParameterName); + var injectedHash = obj.InjectedValue is null + ? 0 + : StringComparer.Ordinal.GetHashCode(obj.InjectedValue); + return HashCode.Combine(parameterHash, (int)obj.TokenKind, injectedHash); + } + } } diff --git a/src/Repl.Core/CoreReplApp.cs b/src/Repl.Core/CoreReplApp.cs index 150bcde..058d2f2 100644 --- a/src/Repl.Core/CoreReplApp.cs +++ b/src/Repl.Core/CoreReplApp.cs @@ -5,6 +5,7 @@ using System.Reflection; using System.Runtime.CompilerServices; using Repl.ShellCompletion; +using Repl.Internal.Options; namespace Repl; @@ -153,7 +154,8 @@ public CommandBuilder Map(string route, Delegate handler) .Select(existingRoute => existingRoute.Template)); _commands.Add(command); - var routeDefinition = new RouteDefinition(template, command, moduleId); + var optionSchema = OptionSchemaBuilder.Build(template, command, _options.Parsing); + var routeDefinition = new RouteDefinition(template, command, moduleId, optionSchema); _routes.Add(routeDefinition); InvalidateRouting(); return command; @@ -403,7 +405,7 @@ private async ValueTask ExecuteCoreAsync( _options.Interaction.SetObserver(observer: ExecutionObserver); try { - var globalOptions = GlobalOptionParser.Parse(args, _options.Output); + var globalOptions = GlobalOptionParser.Parse(args, _options.Output, _options.Parsing); using var runtimeStateScope = PushRuntimeState(serviceProvider, isInteractiveSession: false); var prefixResolution = ResolveUniquePrefixes(globalOptions.RemainingTokens); var resolvedGlobalOptions = globalOptions with { RemainingTokens = prefixResolution.Tokens }; @@ -925,12 +927,43 @@ private async ValueTask ExecuteMatchedCommandAsync( { var activeGraph = ResolveActiveRoutingGraph(); _options.Interaction.SetPrefilledAnswers(globalOptions.PromptAnswers); - var parsedOptions = InvocationOptionParser.Parse(match.RemainingTokens); + var commandParsingOptions = BuildEffectiveCommandParsingOptions(); + var optionComparer = commandParsingOptions.OptionCaseSensitivity == ReplCaseSensitivity.CaseInsensitive + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + var knownOptionNames = new HashSet(match.Route.OptionSchema.Parameters.Keys, optionComparer); + if (TryFindGlobalCommandOptionCollision(globalOptions, knownOptionNames, out var collidingOption)) + { + _ = await RenderOutputAsync( + Results.Validation($"Ambiguous option '{collidingOption}'. It is defined as both global and command option."), + globalOptions.OutputFormat, + cancellationToken) + .ConfigureAwait(false); + return 1; + } + + var parsedOptions = InvocationOptionParser.Parse( + match.RemainingTokens, + match.Route.OptionSchema, + commandParsingOptions); + if (parsedOptions.HasErrors) + { + var firstError = parsedOptions.Diagnostics + .First(diagnostic => diagnostic.Severity == ParseDiagnosticSeverity.Error); + _ = await RenderOutputAsync( + Results.Validation(firstError.Message), + globalOptions.OutputFormat, + cancellationToken) + .ConfigureAwait(false); + return 1; + } var matchedPathLength = globalOptions.RemainingTokens.Count - match.RemainingTokens.Count; var matchedPathTokens = globalOptions.RemainingTokens.Take(matchedPathLength).ToArray(); var bindingContext = CreateInvocationBindingContext( match, parsedOptions, + globalOptions, + commandParsingOptions, matchedPathTokens, activeGraph.Contexts, serviceProvider, @@ -1422,16 +1455,23 @@ private sealed class DefaultServiceProvider(IReadOnlyDictionary se private InvocationBindingContext CreateInvocationBindingContext( RouteMatch match, OptionParsingResult parsedOptions, + GlobalInvocationOptions globalOptions, + ParsingOptions commandParsingOptions, string[] matchedPathTokens, IReadOnlyList contexts, IServiceProvider serviceProvider, CancellationToken cancellationToken) { var contextValues = BuildContextHierarchyValues(match.Route.Template, matchedPathTokens, contexts); + var mergedNamedOptions = MergeNamedOptions( + parsedOptions.NamedOptions, + globalOptions.CustomGlobalNamedOptions); return new InvocationBindingContext( match.Values, - parsedOptions.NamedOptions, + mergedNamedOptions, parsedOptions.PositionalArguments, + match.Route.OptionSchema, + commandParsingOptions.OptionCaseSensitivity, contextValues, _options.Parsing.NumericFormatProvider, serviceProvider, diff --git a/src/Repl.Core/DocumentationExportOptions.cs b/src/Repl.Core/Documentation/DocumentationExportOptions.cs similarity index 93% rename from src/Repl.Core/DocumentationExportOptions.cs rename to src/Repl.Core/Documentation/DocumentationExportOptions.cs index d509d61..cc476df 100644 --- a/src/Repl.Core/DocumentationExportOptions.cs +++ b/src/Repl.Core/Documentation/DocumentationExportOptions.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Documentation; /// /// Options for the opt-in documentation export ambient command. @@ -14,4 +14,4 @@ public sealed class DocumentationExportOptions /// Gets or sets a value indicating whether the command is hidden by default. /// public bool HiddenByDefault { get; set; } = true; -} \ No newline at end of file +} diff --git a/src/Repl.Core/ReplDocApp.cs b/src/Repl.Core/Documentation/ReplDocApp.cs similarity index 69% rename from src/Repl.Core/ReplDocApp.cs rename to src/Repl.Core/Documentation/ReplDocApp.cs index ac57b7a..6ba7b79 100644 --- a/src/Repl.Core/ReplDocApp.cs +++ b/src/Repl.Core/Documentation/ReplDocApp.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Documentation; /// /// Application metadata. @@ -6,4 +6,4 @@ namespace Repl; public sealed record ReplDocApp( string Name, string? Version, - string? Description); \ No newline at end of file + string? Description); diff --git a/src/Repl.Core/ReplDocArgument.cs b/src/Repl.Core/Documentation/ReplDocArgument.cs similarity index 71% rename from src/Repl.Core/ReplDocArgument.cs rename to src/Repl.Core/Documentation/ReplDocArgument.cs index 40ff890..ddf49ab 100644 --- a/src/Repl.Core/ReplDocArgument.cs +++ b/src/Repl.Core/Documentation/ReplDocArgument.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Documentation; /// /// Argument metadata. @@ -7,4 +7,4 @@ public sealed record ReplDocArgument( string Name, string Type, bool Required, - string? Description); \ No newline at end of file + string? Description); diff --git a/src/Repl.Core/ReplDocCommand.cs b/src/Repl.Core/Documentation/ReplDocCommand.cs similarity index 75% rename from src/Repl.Core/ReplDocCommand.cs rename to src/Repl.Core/Documentation/ReplDocCommand.cs index 859c4b1..f73600a 100644 --- a/src/Repl.Core/ReplDocCommand.cs +++ b/src/Repl.Core/Documentation/ReplDocCommand.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Documentation; /// /// Command metadata. @@ -9,4 +9,4 @@ public sealed record ReplDocCommand( IReadOnlyList Aliases, bool IsHidden, IReadOnlyList Arguments, - IReadOnlyList Options); \ No newline at end of file + IReadOnlyList Options); diff --git a/src/Repl.Core/ReplDocContext.cs b/src/Repl.Core/Documentation/ReplDocContext.cs similarity index 75% rename from src/Repl.Core/ReplDocContext.cs rename to src/Repl.Core/Documentation/ReplDocContext.cs index 4b68df7..8761fac 100644 --- a/src/Repl.Core/ReplDocContext.cs +++ b/src/Repl.Core/Documentation/ReplDocContext.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Documentation; /// /// Context metadata. @@ -7,4 +7,4 @@ public sealed record ReplDocContext( string Path, string? Description, bool IsDynamic, - bool IsHidden); \ No newline at end of file + bool IsHidden); diff --git a/src/Repl.Core/Documentation/ReplDocOption.cs b/src/Repl.Core/Documentation/ReplDocOption.cs new file mode 100644 index 0000000..e150811 --- /dev/null +++ b/src/Repl.Core/Documentation/ReplDocOption.cs @@ -0,0 +1,15 @@ +namespace Repl.Documentation; + +/// +/// Option metadata. +/// +public sealed record ReplDocOption( + string Name, + string Type, + bool Required, + string? Description, + IReadOnlyList Aliases, + IReadOnlyList ReverseAliases, + IReadOnlyList ValueAliases, + IReadOnlyList EnumValues, + string? DefaultValue); diff --git a/src/Repl.Core/Documentation/ReplDocValueAlias.cs b/src/Repl.Core/Documentation/ReplDocValueAlias.cs new file mode 100644 index 0000000..42f2119 --- /dev/null +++ b/src/Repl.Core/Documentation/ReplDocValueAlias.cs @@ -0,0 +1,8 @@ +namespace Repl.Documentation; + +/// +/// Value-alias metadata for documentation. +/// +public sealed record ReplDocValueAlias( + string Token, + string Value); diff --git a/src/Repl.Core/ReplDocumentationModel.cs b/src/Repl.Core/Documentation/ReplDocumentationModel.cs similarity index 72% rename from src/Repl.Core/ReplDocumentationModel.cs rename to src/Repl.Core/Documentation/ReplDocumentationModel.cs index 2f54f5d..b324439 100644 --- a/src/Repl.Core/ReplDocumentationModel.cs +++ b/src/Repl.Core/Documentation/ReplDocumentationModel.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Documentation; /// /// Structured documentation payload for the command graph. @@ -6,4 +6,4 @@ namespace Repl; public sealed record ReplDocumentationModel( ReplDocApp App, IReadOnlyList Contexts, - IReadOnlyList Commands); \ No newline at end of file + IReadOnlyList Commands); diff --git a/src/Repl.Core/GlobalInvocationOptions.cs b/src/Repl.Core/GlobalInvocationOptions.cs index f6cc169..718079c 100644 --- a/src/Repl.Core/GlobalInvocationOptions.cs +++ b/src/Repl.Core/GlobalInvocationOptions.cs @@ -15,4 +15,7 @@ internal sealed record GlobalInvocationOptions( public IReadOnlyDictionary PromptAnswers { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyDictionary> CustomGlobalNamedOptions { get; init; } = + new Dictionary>(StringComparer.OrdinalIgnoreCase); } diff --git a/src/Repl.Core/GlobalOptionDefinition.cs b/src/Repl.Core/GlobalOptionDefinition.cs new file mode 100644 index 0000000..e4fa338 --- /dev/null +++ b/src/Repl.Core/GlobalOptionDefinition.cs @@ -0,0 +1,7 @@ +namespace Repl; + +internal sealed record GlobalOptionDefinition( + string Name, + string CanonicalToken, + IReadOnlyList Aliases, + string? DefaultValue); diff --git a/src/Repl.Core/GlobalOptionParser.cs b/src/Repl.Core/GlobalOptionParser.cs index 0aaea5a..d7a39c5 100644 --- a/src/Repl.Core/GlobalOptionParser.cs +++ b/src/Repl.Core/GlobalOptionParser.cs @@ -1,35 +1,62 @@ +using System.Diagnostics.CodeAnalysis; + namespace Repl; internal static class GlobalOptionParser { - public static GlobalInvocationOptions Parse(IReadOnlyList args, OutputOptions outputOptions) + [SuppressMessage( + "Maintainability", + "MA0051:Method is too long", + Justification = "Global token scanning keeps precedence explicit so built-ins and custom options compose predictably.")] + public static GlobalInvocationOptions Parse( + IReadOnlyList args, + OutputOptions outputOptions, + ParsingOptions parsingOptions) { ArgumentNullException.ThrowIfNull(args); ArgumentNullException.ThrowIfNull(outputOptions); + ArgumentNullException.ThrowIfNull(parsingOptions); + var tokenComparer = parsingOptions.OptionCaseSensitivity == ReplCaseSensitivity.CaseInsensitive + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; var remaining = new List(args.Count); var promptAnswers = new Dictionary(StringComparer.OrdinalIgnoreCase); + var customGlobalValues = new Dictionary>(tokenComparer); + var customTokenMap = BuildCustomTokenMap(parsingOptions.GlobalOptions, tokenComparer); var options = new GlobalInvocationOptions(remaining); + var optionComparison = parsingOptions.OptionCaseSensitivity == ReplCaseSensitivity.CaseInsensitive + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; - foreach (var argument in args) + for (var index = 0; index < args.Count; index++) { - switch (argument) + var argument = args[index]; + if (string.Equals(argument, "--help", optionComparison)) + { + options = options with { HelpRequested = true }; + continue; + } + + if (string.Equals(argument, "--interactive", optionComparison)) { - case "--help": - options = options with { HelpRequested = true }; - continue; - case "--interactive": - options = options with { InteractiveForced = true }; - continue; - case "--no-interactive": - options = options with { InteractivePrevented = true }; - continue; - case "--no-logo": - options = options with { LogoSuppressed = true }; - continue; + options = options with { InteractiveForced = true }; + continue; } - if (argument.StartsWith("--output:", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(argument, "--no-interactive", optionComparison)) + { + options = options with { InteractivePrevented = true }; + continue; + } + + if (string.Equals(argument, "--no-logo", optionComparison)) + { + options = options with { LogoSuppressed = true }; + continue; + } + + if (argument.StartsWith("--output:", optionComparison)) { options = options with { OutputFormat = argument["--output:".Length..] }; continue; @@ -46,10 +73,28 @@ public static GlobalInvocationOptions Parse(IReadOnlyList args, OutputOp continue; } + if (TryParseCustomGlobalOption( + args, + ref index, + argument, + customTokenMap, + customGlobalValues)) + { + continue; + } + remaining.Add(argument); } - return options with { PromptAnswers = promptAnswers }; + var readonlyCustomGlobalValues = customGlobalValues.ToDictionary( + pair => pair.Key, + pair => (IReadOnlyList)pair.Value, + StringComparer.OrdinalIgnoreCase); + return options with + { + PromptAnswers = promptAnswers, + CustomGlobalNamedOptions = readonlyCustomGlobalValues, + }; } private static bool TryParseOutputAlias( @@ -96,4 +141,114 @@ private static bool TryParsePromptAnswer( return true; } + + private static Dictionary BuildCustomTokenMap( + IReadOnlyDictionary definitions, + StringComparer comparer) + { + var tokenMap = new Dictionary(comparer); + foreach (var definition in definitions.Values) + { + tokenMap[definition.CanonicalToken] = definition.Name; + foreach (var alias in definition.Aliases) + { + tokenMap[alias] = definition.Name; + } + } + + return tokenMap; + } + + private static bool TryParseCustomGlobalOption( + IReadOnlyList args, + ref int index, + string argument, + IReadOnlyDictionary tokenMap, + Dictionary> customGlobalValues) + { + if (!TryResolveCustomGlobalName(argument, tokenMap, out var optionName, out var inlineValue)) + { + return false; + } + + var value = inlineValue; + if (value is null + && index + 1 < args.Count + && (!args[index + 1].StartsWith('-') || IsSignedNumericLiteral(args[index + 1]))) + { + index++; + value = args[index]; + } + + if (!customGlobalValues.TryGetValue(optionName, out var values)) + { + values = []; + customGlobalValues[optionName] = values; + } + + values.Add(value ?? "true"); + return true; + } + + private static bool TryResolveCustomGlobalName( + string argument, + IReadOnlyDictionary tokenMap, + out string optionName, + out string? inlineValue) + { + optionName = string.Empty; + inlineValue = null; + if (!argument.StartsWith('-')) + { + return false; + } + + var optionToken = argument; + if (TrySplitToken(argument, '=', out var namePart, out var valuePart) + || TrySplitToken(argument, ':', out namePart, out valuePart)) + { + optionToken = namePart; + inlineValue = valuePart; + } + + if (!tokenMap.TryGetValue(optionToken, out optionName!)) + { + return false; + } + + return true; + } + + private static bool IsSignedNumericLiteral(string token) + { + if (token.Length < 2 || token[0] != '-') + { + return false; + } + + return double.TryParse( + token, + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, + out _); + } + + private static bool TrySplitToken( + string token, + char separator, + out string namePart, + out string valuePart) + { + var separatorIndex = token.IndexOf(separator, StringComparison.Ordinal); + if (separatorIndex <= 0) + { + namePart = string.Empty; + valuePart = string.Empty; + return false; + } + + namePart = token[..separatorIndex]; + valuePart = token[(separatorIndex + 1)..]; + return true; + } } diff --git a/src/Repl.Core/GlobalUsings.cs b/src/Repl.Core/GlobalUsings.cs new file mode 100644 index 0000000..ccc745d --- /dev/null +++ b/src/Repl.Core/GlobalUsings.cs @@ -0,0 +1,7 @@ +global using Repl.Parameters; +global using Repl.Documentation; +global using Repl.Autocomplete; +global using Repl.Interaction; +global using Repl.Rendering; +global using Repl.ShellCompletion; +global using Repl.Terminal; diff --git a/src/Repl.Core/HandlerArgumentBinder.cs b/src/Repl.Core/HandlerArgumentBinder.cs index 1843a82..d628679 100644 --- a/src/Repl.Core/HandlerArgumentBinder.cs +++ b/src/Repl.Core/HandlerArgumentBinder.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using Repl.Internal.Options; namespace Repl; @@ -22,11 +23,19 @@ internal static class HandlerArgumentBinder return values; } + [SuppressMessage( + "Maintainability", + "MA0051:Method is too long", + Justification = "Binding precedence must stay explicit to preserve predictable runtime behavior.")] private static object? BindParameter( System.Reflection.ParameterInfo parameter, InvocationBindingContext context, ref int positionalIndex) { + var parameterName = parameter.Name ?? string.Empty; + var bindingMode = ResolveBindingMode(parameter, context.OptionSchema); + var enumIgnoreCase = ResolveEnumIgnoreCase(parameterName, context); + if (parameter.ParameterType == typeof(CancellationToken)) { return context.CancellationToken; @@ -43,28 +52,44 @@ internal static class HandlerArgumentBinder $"Unable to bind parameter '{parameter.Name}' ({parameter.ParameterType.Name})."); } - if (context.RouteValues.TryGetValue(parameter.Name ?? string.Empty, out var routeValue)) + if (context.RouteValues.TryGetValue(parameterName, out var routeValue)) { return ParameterValueConverter.ConvertSingle( routeValue, parameter.ParameterType, - context.NumericFormatProvider); + context.NumericFormatProvider, + enumIgnoreCase); } - if (context.NamedOptions.TryGetValue(parameter.Name ?? string.Empty, out var namedValues)) + if (bindingMode != ReplParameterMode.ArgumentOnly + && context.NamedOptions.TryGetValue(parameterName, out var namedValues)) { - return ConvertManyOrSingle(namedValues, parameter.ParameterType, context.NumericFormatProvider); - } + if (bindingMode == ReplParameterMode.OptionAndPositional + && CanConsumePositional(parameter, context.PositionalArguments, positionalIndex)) + { + throw new InvalidOperationException( + $"Parameter '{parameterName}' cannot receive both named and positional values in the same invocation."); + } + + return ConvertManyOrSingle( + parameter, + namedValues, + parameter.ParameterType, + context.NumericFormatProvider, + enumIgnoreCase); + } if (TryResolveFromContextOrServices(parameter, context, out var resolved)) { return resolved; } - if (TryConsumePositional( + if (bindingMode != ReplParameterMode.OptionOnly + && TryConsumePositional( parameter, context.PositionalArguments, context.NumericFormatProvider, + enumIgnoreCase, ref positionalIndex, out var positionalValue)) { @@ -289,6 +314,7 @@ private static bool TryConsumePositional( System.Reflection.ParameterInfo parameter, IReadOnlyList positionalArguments, IFormatProvider numericFormatProvider, + bool enumIgnoreCase, ref int positionalIndex, out object? value) { @@ -303,7 +329,7 @@ private static bool TryConsumePositional( } positionalIndex = positionalArguments.Count; - value = ConvertMany(remaining, targetType, elementType, numericFormatProvider); + value = ConvertMany(remaining, targetType, elementType, numericFormatProvider, enumIgnoreCase); return true; } @@ -316,35 +342,46 @@ private static bool TryConsumePositional( value = ParameterValueConverter.ConvertSingle( positionalArguments[positionalIndex], targetType, - numericFormatProvider); + numericFormatProvider, + enumIgnoreCase); positionalIndex++; return true; } private static object? ConvertManyOrSingle( + System.Reflection.ParameterInfo parameter, IReadOnlyList values, Type targetType, - IFormatProvider numericFormatProvider) + IFormatProvider numericFormatProvider, + bool enumIgnoreCase) { if (!TryGetCollectionElementType(targetType, out var elementType)) { + if (values.Count > 1) + { + throw new InvalidOperationException( + $"Parameter '{parameter.Name}' received multiple values but does not support repeated occurrences."); + } + return ParameterValueConverter.ConvertSingle( values.Count == 0 ? null : values[0], targetType, - numericFormatProvider); + numericFormatProvider, + enumIgnoreCase); } - return ConvertMany(values, targetType, elementType, numericFormatProvider); + return ConvertMany(values, targetType, elementType, numericFormatProvider, enumIgnoreCase); } private static object ConvertMany( IEnumerable values, Type targetType, Type elementType, - IFormatProvider numericFormatProvider) + IFormatProvider numericFormatProvider, + bool enumIgnoreCase) { var converted = values - .Select(value => ParameterValueConverter.ConvertSingle(value, elementType, numericFormatProvider)) + .Select(value => ParameterValueConverter.ConvertSingle(value, elementType, numericFormatProvider, enumIgnoreCase)) .ToArray(); if (targetType.IsArray) @@ -368,6 +405,54 @@ private static object ConvertMany( return list; } + private static bool CanConsumePositional( + System.Reflection.ParameterInfo parameter, + IReadOnlyList positionalArguments, + int positionalIndex) + { + if (TryGetCollectionElementType(parameter.ParameterType, out _)) + { + return positionalIndex < positionalArguments.Count; + } + + return positionalIndex < positionalArguments.Count; + } + + private static ReplParameterMode ResolveBindingMode( + System.Reflection.ParameterInfo parameter, + OptionSchema optionSchema) + { + if (!string.IsNullOrWhiteSpace(parameter.Name) + && optionSchema.TryGetParameter(parameter.Name!, out var schemaParameter)) + { + return schemaParameter.Mode; + } + + var optionAttribute = parameter.GetCustomAttributes(typeof(ReplOptionAttribute), inherit: true) + .Cast() + .SingleOrDefault(); + if (optionAttribute is not null) + { + return optionAttribute.Mode; + } + + var argumentAttribute = parameter.GetCustomAttributes(typeof(ReplArgumentAttribute), inherit: true) + .Cast() + .SingleOrDefault(); + return argumentAttribute?.Mode ?? ReplParameterMode.OptionAndPositional; + } + + private static bool ResolveEnumIgnoreCase(string parameterName, InvocationBindingContext context) + { + if (!context.OptionSchema.TryGetParameter(parameterName, out var schemaParameter)) + { + return context.OptionCaseSensitivity == ReplCaseSensitivity.CaseInsensitive; + } + + var effectiveCaseSensitivity = schemaParameter.CaseSensitivity ?? context.OptionCaseSensitivity; + return effectiveCaseSensitivity == ReplCaseSensitivity.CaseInsensitive; + } + private static bool TryGetCollectionElementType(Type parameterType, out Type elementType) { if (parameterType.IsArray) diff --git a/src/Repl.Core/HelpTextBuilder.cs b/src/Repl.Core/HelpTextBuilder.cs index a493a04..8eb21b9 100644 --- a/src/Repl.Core/HelpTextBuilder.cs +++ b/src/Repl.Core/HelpTextBuilder.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.Reflection; using System.Text; +using Repl.Internal.Options; namespace Repl; @@ -11,6 +12,15 @@ internal static class HelpTextBuilder private static readonly string[] ExitRow = ["exit", "Leave interactive mode."]; private static readonly string[] HistoryRow = ["history [--limit ]", "Show recent interactive commands."]; private static readonly string[] CompleteRow = ["complete --target [--input ] ", "Resolve completions."]; + private static readonly string[][] BuiltInGlobalOptionRows = + [ + ["--help", "Show help for current scope or command."], + ["--interactive", "Force interactive mode."], + ["--no-interactive", "Prevent interactive mode."], + ["--no-logo", "Disable banner rendering."], + ["--output:", "Set output format (for example json, yaml, xml, markdown)."], + ["--answer:[=value]", "Provide prompt answers in non-interactive execution."], + ]; public static HelpDocumentModel BuildModel( IReadOnlyList routes, @@ -252,10 +262,11 @@ private static string BuildSingleCommandHelp(RouteDefinition route, bool useAnsi var aliases = route.Command.Aliases.Count == 0 ? string.Empty : $"{Environment.NewLine}Aliases: {string.Join(", ", route.Command.Aliases)}"; - var paramSection = BuildParameterSection(route, useAnsi, palette); + var argumentSection = BuildArgumentSection(route, useAnsi, palette); + var optionSection = BuildOptionSection(route, useAnsi, palette); if (!useAnsi) { - return $"Usage: {displayTemplate}{Environment.NewLine}Description: {description}{aliases}{paramSection}"; + return $"Usage: {displayTemplate}{Environment.NewLine}Description: {description}{aliases}{argumentSection}{optionSection}"; } var usage = $"{AnsiText.Apply("Usage:", palette.SectionStyle)} {AnsiText.Apply(displayTemplate, palette.CommandStyle)}"; @@ -263,10 +274,10 @@ private static string BuildSingleCommandHelp(RouteDefinition route, bool useAnsi var aliasText = route.Command.Aliases.Count == 0 ? string.Empty : $"{Environment.NewLine}{AnsiText.Apply("Aliases:", palette.SectionStyle)} {AnsiText.Apply(string.Join(", ", route.Command.Aliases), palette.CommandStyle)}"; - return $"{usage}{Environment.NewLine}{desc}{aliasText}{paramSection}"; + return $"{usage}{Environment.NewLine}{desc}{aliasText}{argumentSection}{optionSection}"; } - private static string BuildParameterSection(RouteDefinition route, bool useAnsi, AnsiPalette palette) + private static string BuildArgumentSection(RouteDefinition route, bool useAnsi, AnsiPalette palette) { var dynamicSegments = route.Template.Segments.OfType().ToList(); if (dynamicSegments.Count == 0) @@ -296,8 +307,8 @@ private static string BuildParameterSection(RouteDefinition route, bool useAnsi, var builder = new StringBuilder(); builder.AppendLine(); builder.Append(useAnsi - ? AnsiText.Apply("Parameters:", palette.SectionStyle) - : "Parameters:"); + ? AnsiText.Apply("Arguments:", palette.SectionStyle) + : "Arguments:"); foreach (var row in rows) { builder.AppendLine(); @@ -309,6 +320,130 @@ private static string BuildParameterSection(RouteDefinition route, bool useAnsi, return builder.ToString(); } + private static string BuildOptionSection(RouteDefinition route, bool useAnsi, AnsiPalette palette) + { + var parameters = route.Command.Handler.Method.GetParameters() + .Where(parameter => !string.IsNullOrWhiteSpace(parameter.Name)) + .ToDictionary(parameter => parameter.Name!, StringComparer.OrdinalIgnoreCase); + var optionRows = route.OptionSchema.Parameters.Values + .Where(parameter => parameter.Mode != ReplParameterMode.ArgumentOnly) + .Select(parameter => BuildOptionRow(route.OptionSchema, parameter, parameters)) + .Where(row => row is not null) + .Select(row => row!) + .ToArray(); + if (optionRows.Length == 0) + { + return string.Empty; + } + + var builder = new StringBuilder(); + builder.AppendLine(); + builder.Append(useAnsi + ? AnsiText.Apply("Options:", palette.SectionStyle) + : "Options:"); + foreach (var row in optionRows) + { + builder.AppendLine(); + builder.Append(useAnsi + ? $" {AnsiText.Apply(row[0], palette.CommandStyle)} {AnsiText.Apply(row[1], palette.DescriptionStyle)}" + : $" {row[0]} {row[1]}"); + } + + return builder.ToString(); + } + + private static string[]? BuildOptionRow( + OptionSchema schema, + OptionSchemaParameter schemaParameter, + Dictionary parameters) + { + if (!parameters.TryGetValue(schemaParameter.Name, out var parameter)) + { + return null; + } + + var entries = schema.Entries + .Where(entry => + string.Equals(entry.ParameterName, schemaParameter.Name, StringComparison.OrdinalIgnoreCase) + && entry.TokenKind is OptionSchemaTokenKind.NamedOption + or OptionSchemaTokenKind.BoolFlag + or OptionSchemaTokenKind.ReverseFlag + or OptionSchemaTokenKind.ValueAlias + or OptionSchemaTokenKind.EnumAlias) + .ToArray(); + if (entries.Length == 0) + { + return null; + } + + var visibleTokens = entries + .Select(entry => entry.Token) + .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; + var left = string.IsNullOrWhiteSpace(placeholder) + ? tokenDisplay + : $"{tokenDisplay} {placeholder}"; + var right = $"{description}{defaultValue}".Trim(); + return [left, right]; + } + + private static string ResolveOptionPlaceholder(Type parameterType) + { + var effectiveType = Nullable.GetUnderlyingType(parameterType) ?? parameterType; + if (effectiveType == typeof(bool)) + { + return string.Empty; + } + + if (effectiveType.IsEnum) + { + return $"<{string.Join('|', Enum.GetNames(effectiveType))}>"; + } + + return $"<{GetTypePlaceholderName(effectiveType)}>"; + } + + private static string GetTypePlaceholderName(Type type) + { + if (type == typeof(string)) + { + return "string"; + } + + if (type == typeof(int)) + { + return "int"; + } + + if (type == typeof(long)) + { + return "long"; + } + + if (type == typeof(Guid)) + { + return "guid"; + } + + if (type == typeof(FileInfo)) + { + return "file"; + } + + if (type == typeof(DirectoryInfo)) + { + return "directory"; + } + + return type.Name.ToLowerInvariant(); + } + private static string BuildScopeHelp( IReadOnlyList scopeTokens, RouteDefinition[] routes, @@ -346,6 +481,14 @@ private static string BuildScopeHelp( AppendIndentedRows(builder, scopeRows, renderWidth, GetCommandRowsStyle(useAnsi, palette)); } + var globalOptionRows = BuildGlobalOptionRows(parsingOptions); + if (globalOptionRows.Length > 0) + { + builder.AppendLine(); + AppendSectionLine(builder, "Global Options:", useAnsi, palette); + AppendIndentedRows(builder, globalOptionRows, renderWidth, GetCommandRowsStyle(useAnsi, palette)); + } + builder.AppendLine(); AppendSectionLine(builder, "Global Commands:", useAnsi, palette); AppendIndentedRows(builder, BuildGlobalCommandRows(ambientOptions), renderWidth, GetCommandRowsStyle(useAnsi, palette)); @@ -503,6 +646,25 @@ private static string[][] BuildGlobalCommandRows(AmbientCommandOptions ambientOp return [.. rows]; } + private static string[][] BuildGlobalOptionRows(ParsingOptions parsingOptions) + { + ArgumentNullException.ThrowIfNull(parsingOptions); + var customRows = parsingOptions.GlobalOptions.Values + .OrderBy(option => option.Name, StringComparer.OrdinalIgnoreCase) + .Select(option => + { + var aliases = option.Aliases.Count == 0 + ? string.Empty + : $", {string.Join(", ", option.Aliases)}"; + return new[] + { + $"{option.CanonicalToken}{aliases}", + "Custom global option.", + }; + }); + return [.. BuiltInGlobalOptionRows.Concat(customRows)]; + } + private static TextTableStyle GetCommandRowsStyle(bool useAnsi, AnsiPalette palette) { if (!useAnsi) diff --git a/src/Repl.Core/AskOptions.cs b/src/Repl.Core/Interaction/Public/AskOptions.cs similarity index 96% rename from src/Repl.Core/AskOptions.cs rename to src/Repl.Core/Interaction/Public/AskOptions.cs index fa1533d..25a20dd 100644 --- a/src/Repl.Core/AskOptions.cs +++ b/src/Repl.Core/Interaction/Public/AskOptions.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Interaction; /// /// Extensible options for Ask prompts. Groups diff --git a/src/Repl.Core/IReplInteractionChannel.cs b/src/Repl.Core/Interaction/Public/IReplInteractionChannel.cs similarity index 98% rename from src/Repl.Core/IReplInteractionChannel.cs rename to src/Repl.Core/Interaction/Public/IReplInteractionChannel.cs index fff8ae8..5dbb216 100644 --- a/src/Repl.Core/IReplInteractionChannel.cs +++ b/src/Repl.Core/Interaction/Public/IReplInteractionChannel.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Interaction; /// /// Provides bidirectional interaction during command execution. @@ -45,7 +45,7 @@ ValueTask AskChoiceAsync( /// /// Prompts the user for confirmation. - /// q + /// /// /// Prompt name. /// Prompt text. diff --git a/src/Repl.Core/IReplInteractionPresenter.cs b/src/Repl.Core/Interaction/Public/IReplInteractionPresenter.cs similarity index 94% rename from src/Repl.Core/IReplInteractionPresenter.cs rename to src/Repl.Core/Interaction/Public/IReplInteractionPresenter.cs index d5a39c9..1d0275f 100644 --- a/src/Repl.Core/IReplInteractionPresenter.cs +++ b/src/Repl.Core/Interaction/Public/IReplInteractionPresenter.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Interaction; /// /// Renders semantic interaction events to an output target. diff --git a/src/Repl.Core/PromptFallback.cs b/src/Repl.Core/Interaction/Public/PromptFallback.cs similarity index 90% rename from src/Repl.Core/PromptFallback.cs rename to src/Repl.Core/Interaction/Public/PromptFallback.cs index 41f72f1..3659246 100644 --- a/src/Repl.Core/PromptFallback.cs +++ b/src/Repl.Core/Interaction/Public/PromptFallback.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Interaction; /// /// Defines fallback policy for unanswered prompts. @@ -14,4 +14,4 @@ public enum PromptFallback /// Fails when no answer is available. /// Fail, -} \ No newline at end of file +} diff --git a/src/Repl.Core/ReplInteractionEvent.cs b/src/Repl.Core/Interaction/Public/ReplInteractionEvent.cs similarity index 85% rename from src/Repl.Core/ReplInteractionEvent.cs rename to src/Repl.Core/Interaction/Public/ReplInteractionEvent.cs index c4b1ad3..0053415 100644 --- a/src/Repl.Core/ReplInteractionEvent.cs +++ b/src/Repl.Core/Interaction/Public/ReplInteractionEvent.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Interaction; /// /// Base semantic interaction event emitted during command execution. diff --git a/src/Repl.Core/ReplProgressEvent.cs b/src/Repl.Core/Interaction/Public/ReplProgressEvent.cs similarity index 94% rename from src/Repl.Core/ReplProgressEvent.cs rename to src/Repl.Core/Interaction/Public/ReplProgressEvent.cs index 0268890..8453ae6 100644 --- a/src/Repl.Core/ReplProgressEvent.cs +++ b/src/Repl.Core/Interaction/Public/ReplProgressEvent.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Interaction; /// /// Semantic progress event. diff --git a/src/Repl.Core/ReplPromptEvent.cs b/src/Repl.Core/Interaction/Public/ReplPromptEvent.cs similarity index 86% rename from src/Repl.Core/ReplPromptEvent.cs rename to src/Repl.Core/Interaction/Public/ReplPromptEvent.cs index daa0e5a..917f576 100644 --- a/src/Repl.Core/ReplPromptEvent.cs +++ b/src/Repl.Core/Interaction/Public/ReplPromptEvent.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Interaction; /// /// Semantic prompt event. diff --git a/src/Repl.Core/ReplStatusEvent.cs b/src/Repl.Core/Interaction/Public/ReplStatusEvent.cs similarity index 85% rename from src/Repl.Core/ReplStatusEvent.cs rename to src/Repl.Core/Interaction/Public/ReplStatusEvent.cs index d24333d..71793ff 100644 --- a/src/Repl.Core/ReplStatusEvent.cs +++ b/src/Repl.Core/Interaction/Public/ReplStatusEvent.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Interaction; /// /// Semantic status line event. diff --git a/src/Repl.Core/Internal/Options/OptionSchema.cs b/src/Repl.Core/Internal/Options/OptionSchema.cs new file mode 100644 index 0000000..e1a56ad --- /dev/null +++ b/src/Repl.Core/Internal/Options/OptionSchema.cs @@ -0,0 +1,48 @@ +namespace Repl.Internal.Options; + +internal sealed class OptionSchema +{ + public static OptionSchema Empty { get; } = + new([], new Dictionary(StringComparer.OrdinalIgnoreCase)); + + public OptionSchema( + IReadOnlyList entries, + IReadOnlyDictionary parameters) + { + Entries = entries; + Parameters = parameters; + } + + public IReadOnlyList Entries { get; } + + public IReadOnlyDictionary Parameters { get; } + + public IReadOnlyCollection KnownTokens => + Entries.Select(entry => entry.Token).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + + public IReadOnlyList ResolveToken(string token, ReplCaseSensitivity globalCaseSensitivity) + { + if (string.IsNullOrWhiteSpace(token)) + { + return []; + } + + var matches = new List(); + foreach (var entry in Entries) + { + var effectiveCase = entry.CaseSensitivity ?? globalCaseSensitivity; + var comparison = effectiveCase == ReplCaseSensitivity.CaseInsensitive + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (string.Equals(entry.Token, token, comparison)) + { + matches.Add(entry); + } + } + + return matches; + } + + public bool TryGetParameter(string parameterName, out OptionSchemaParameter parameter) => + Parameters.TryGetValue(parameterName, out parameter!); +} diff --git a/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs new file mode 100644 index 0000000..2b861bf --- /dev/null +++ b/src/Repl.Core/Internal/Options/OptionSchemaBuilder.cs @@ -0,0 +1,287 @@ +using System.Reflection; +using Repl; + +namespace Repl.Internal.Options; + +internal static class OptionSchemaBuilder +{ + public static OptionSchema Build( + RouteTemplate template, + CommandBuilder command, + ParsingOptions parsingOptions) + { + ArgumentNullException.ThrowIfNull(template); + ArgumentNullException.ThrowIfNull(command); + ArgumentNullException.ThrowIfNull(parsingOptions); + + var routeParameterNames = template.Segments + .OfType() + .Select(segment => segment.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var entries = new List(); + var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var parameter in command.Handler.Method.GetParameters()) + { + if (ShouldSkipSchemaParameter(parameter, routeParameterNames)) + { + continue; + } + + AppendParameterSchemaEntries(parameter, entries, parameters); + } + + ValidateTokenCollisions(entries, parsingOptions); + return new OptionSchema(entries, parameters); + } + + private static bool ShouldSkipSchemaParameter( + ParameterInfo parameter, + HashSet routeParameterNames) + { + if (string.IsNullOrWhiteSpace(parameter.Name) + || parameter.ParameterType == typeof(CancellationToken) + || IsFrameworkInjectedParameter(parameter) + || parameter.GetCustomAttribute() is not null + || parameter.GetCustomAttribute() is not null) + { + return true; + } + + if (!routeParameterNames.Contains(parameter.Name)) + { + return false; + } + + var optionAttribute = parameter.GetCustomAttribute(inherit: true); + var argumentAttribute = parameter.GetCustomAttribute(inherit: true); + if (optionAttribute is null && argumentAttribute is null) + { + return true; + } + + throw new InvalidOperationException( + $"Route parameter '{parameter.Name}' cannot declare ReplOption/ReplArgument attributes."); + } + + private static void AppendParameterSchemaEntries( + ParameterInfo parameter, + List entries, + Dictionary parameters) + { + var optionAttribute = parameter.GetCustomAttribute(inherit: true); + var argumentAttribute = parameter.GetCustomAttribute(inherit: true); + var mode = optionAttribute?.Mode + ?? argumentAttribute?.Mode + ?? ReplParameterMode.OptionAndPositional; + parameters[parameter.Name!] = new OptionSchemaParameter( + parameter.Name!, + parameter.ParameterType, + mode, + CaseSensitivity: optionAttribute?.CaseSensitivity); + if (mode == ReplParameterMode.ArgumentOnly) + { + return; + } + + var arity = ResolveArity(parameter, optionAttribute); + var tokenKind = ResolveTokenKind(parameter.ParameterType, arity); + var canonicalToken = ResolveCanonicalToken(parameter.Name!, optionAttribute); + entries.Add(new OptionSchemaEntry( + canonicalToken, + parameter.Name!, + tokenKind, + arity, + CaseSensitivity: optionAttribute?.CaseSensitivity)); + AppendOptionAliases(parameter, tokenKind, arity, optionAttribute, entries); + AppendReverseAliases(parameter, optionAttribute, entries); + AppendValueAliases(parameter, optionAttribute, entries); + AppendEnumAliases(parameter, optionAttribute, entries); + } + + private static OptionSchemaTokenKind ResolveTokenKind(Type parameterType, ReplArity arity) => + IsBoolParameter(parameterType) && arity != ReplArity.ExactlyOne + ? OptionSchemaTokenKind.BoolFlag + : OptionSchemaTokenKind.NamedOption; + + private static string ResolveCanonicalToken(string parameterName, ReplOptionAttribute? optionAttribute) + { + var canonicalName = string.IsNullOrWhiteSpace(optionAttribute?.Name) + ? parameterName + : optionAttribute!.Name!; + return EnsureLongPrefix(canonicalName); + } + + private static void AppendOptionAliases( + ParameterInfo parameter, + OptionSchemaTokenKind tokenKind, + ReplArity arity, + ReplOptionAttribute? optionAttribute, + List entries) + { + foreach (var alias in optionAttribute?.Aliases ?? []) + { + ValidateOptionToken(alias, parameter.Name!); + entries.Add(new OptionSchemaEntry( + alias, + parameter.Name!, + tokenKind, + arity, + CaseSensitivity: optionAttribute?.CaseSensitivity)); + } + } + + private static void AppendReverseAliases( + ParameterInfo parameter, + ReplOptionAttribute? optionAttribute, + List entries) + { + foreach (var reverseAlias in optionAttribute?.ReverseAliases ?? []) + { + ValidateOptionToken(reverseAlias, parameter.Name!); + entries.Add(new OptionSchemaEntry( + reverseAlias, + parameter.Name!, + OptionSchemaTokenKind.ReverseFlag, + ReplArity.ZeroOrOne, + CaseSensitivity: optionAttribute?.CaseSensitivity, + InjectedValue: "false")); + } + } + + private static void AppendValueAliases( + ParameterInfo parameter, + ReplOptionAttribute? optionAttribute, + List entries) + { + foreach (var valueAlias in parameter.GetCustomAttributes(inherit: true)) + { + ValidateOptionToken(valueAlias.Token, parameter.Name!); + entries.Add(new OptionSchemaEntry( + valueAlias.Token, + parameter.Name!, + OptionSchemaTokenKind.ValueAlias, + ReplArity.ZeroOrOne, + CaseSensitivity: valueAlias.CaseSensitivity ?? optionAttribute?.CaseSensitivity, + InjectedValue: valueAlias.Value)); + } + } + + private static bool IsFrameworkInjectedParameter(ParameterInfo parameter) => + parameter.ParameterType == typeof(IServiceProvider) + || parameter.ParameterType == typeof(ICoreReplApp) + || parameter.ParameterType == typeof(CoreReplApp) + || parameter.ParameterType == typeof(IReplSessionState) + || parameter.ParameterType == typeof(IReplInteractionChannel) + || parameter.ParameterType == typeof(IReplIoContext) + || parameter.ParameterType == typeof(IReplKeyReader); + + private static ReplArity ResolveArity(ParameterInfo parameter, ReplOptionAttribute? optionAttribute) + { + if (optionAttribute?.Arity is { } explicitArity) + { + return explicitArity; + } + + if (IsBoolParameter(parameter.ParameterType)) + { + return ReplArity.ZeroOrOne; + } + + if (IsCollection(parameter.ParameterType)) + { + return ReplArity.ZeroOrMore; + } + + if (Nullable.GetUnderlyingType(parameter.ParameterType) is not null || parameter.HasDefaultValue) + { + return ReplArity.ZeroOrOne; + } + + return ReplArity.ExactlyOne; + } + + private static bool IsCollection(Type type) => + type.IsArray + || (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>)) + || (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IReadOnlyList<>)); + + private static bool IsBoolParameter(Type type) => + (Nullable.GetUnderlyingType(type) ?? type) == typeof(bool); + + private static string EnsureLongPrefix(string name) => + name.StartsWith("--", StringComparison.Ordinal) ? name : $"--{name}"; + + private static void ValidateOptionToken(string token, string parameterName) + { + if (string.IsNullOrWhiteSpace(token) + || token.Contains(' ', StringComparison.Ordinal) + || (!token.StartsWith("--", StringComparison.Ordinal) && !token.StartsWith('-'))) + { + throw new InvalidOperationException( + $"Invalid option token '{token}' declared on parameter '{parameterName}'."); + } + } + + private static void AppendEnumAliases( + ParameterInfo parameter, + ReplOptionAttribute? optionAttribute, + List entries) + { + var enumType = Nullable.GetUnderlyingType(parameter.ParameterType) ?? parameter.ParameterType; + 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, parameter.Name ?? enumType.Name); + entries.Add(new OptionSchemaEntry( + alias, + parameter.Name!, + OptionSchemaTokenKind.EnumAlias, + ReplArity.ZeroOrOne, + CaseSensitivity: enumFlag.CaseSensitivity ?? optionAttribute?.CaseSensitivity, + InjectedValue: field.Name)); + } + } + } + + private static void ValidateTokenCollisions( + IReadOnlyList entries, + ParsingOptions parsingOptions) + { + var comparer = parsingOptions.OptionCaseSensitivity == ReplCaseSensitivity.CaseInsensitive + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + var map = new Dictionary(comparer); + foreach (var entry in entries) + { + if (!map.TryGetValue(entry.Token, out var existing)) + { + map[entry.Token] = entry; + continue; + } + + if (string.Equals(existing.ParameterName, entry.ParameterName, StringComparison.OrdinalIgnoreCase) + && existing.TokenKind == entry.TokenKind + && string.Equals(existing.InjectedValue, entry.InjectedValue, StringComparison.Ordinal)) + { + continue; + } + + throw new InvalidOperationException( + $"Option token collision detected for '{entry.Token}' between '{existing.ParameterName}' and '{entry.ParameterName}'."); + } + } +} diff --git a/src/Repl.Core/Internal/Options/OptionSchemaEntry.cs b/src/Repl.Core/Internal/Options/OptionSchemaEntry.cs new file mode 100644 index 0000000..62e69b7 --- /dev/null +++ b/src/Repl.Core/Internal/Options/OptionSchemaEntry.cs @@ -0,0 +1,11 @@ +using Repl; + +namespace Repl.Internal.Options; + +internal sealed record OptionSchemaEntry( + string Token, + string ParameterName, + OptionSchemaTokenKind TokenKind, + ReplArity Arity, + ReplCaseSensitivity? CaseSensitivity = null, + string? InjectedValue = null); diff --git a/src/Repl.Core/Internal/Options/OptionSchemaParameter.cs b/src/Repl.Core/Internal/Options/OptionSchemaParameter.cs new file mode 100644 index 0000000..3bf6714 --- /dev/null +++ b/src/Repl.Core/Internal/Options/OptionSchemaParameter.cs @@ -0,0 +1,9 @@ +using Repl; + +namespace Repl.Internal.Options; + +internal sealed record OptionSchemaParameter( + string Name, + Type ParameterType, + ReplParameterMode Mode, + ReplCaseSensitivity? CaseSensitivity = null); diff --git a/src/Repl.Core/Internal/Options/OptionSchemaTokenKind.cs b/src/Repl.Core/Internal/Options/OptionSchemaTokenKind.cs new file mode 100644 index 0000000..d523526 --- /dev/null +++ b/src/Repl.Core/Internal/Options/OptionSchemaTokenKind.cs @@ -0,0 +1,10 @@ +namespace Repl.Internal.Options; + +internal enum OptionSchemaTokenKind +{ + NamedOption = 0, + BoolFlag = 1, + ReverseFlag = 2, + ValueAlias = 3, + EnumAlias = 4, +} diff --git a/src/Repl.Core/InvocationBindingContext.cs b/src/Repl.Core/InvocationBindingContext.cs index 11c874c..64f311c 100644 --- a/src/Repl.Core/InvocationBindingContext.cs +++ b/src/Repl.Core/InvocationBindingContext.cs @@ -1,9 +1,13 @@ +using Repl.Internal.Options; + namespace Repl; internal sealed class InvocationBindingContext( IReadOnlyDictionary routeValues, IReadOnlyDictionary> namedOptions, IReadOnlyList positionalArguments, + OptionSchema optionSchema, + ReplCaseSensitivity optionCaseSensitivity, IReadOnlyList contextValues, IFormatProvider numericFormatProvider, IServiceProvider serviceProvider, @@ -16,6 +20,10 @@ internal sealed class InvocationBindingContext( public IReadOnlyList PositionalArguments { get; } = positionalArguments; + public OptionSchema OptionSchema { get; } = optionSchema; + + public ReplCaseSensitivity OptionCaseSensitivity { get; } = optionCaseSensitivity; + public IReadOnlyList ContextValues { get; } = contextValues; public IFormatProvider NumericFormatProvider { get; } = numericFormatProvider; diff --git a/src/Repl.Core/InvocationOptionParser.cs b/src/Repl.Core/InvocationOptionParser.cs index d251614..c91eb8d 100644 --- a/src/Repl.Core/InvocationOptionParser.cs +++ b/src/Repl.Core/InvocationOptionParser.cs @@ -1,3 +1,7 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using Repl.Internal.Options; + namespace Repl; internal static class InvocationOptionParser @@ -5,14 +9,45 @@ internal static class InvocationOptionParser public static OptionParsingResult Parse(IReadOnlyList tokens) { ArgumentNullException.ThrowIfNull(tokens); + return Parse( + tokens, + new ParsingOptions + { + AllowUnknownOptions = true, + }, + knownOptionNames: null); + } - var namedOptions = new Dictionary>(StringComparer.OrdinalIgnoreCase); + [SuppressMessage( + "Maintainability", + "MA0051:Method is too long", + Justification = "Legacy parser preserves prior permissive behavior for compatibility callers.")] + public static OptionParsingResult Parse( + IReadOnlyList tokens, + ParsingOptions options, + IReadOnlyCollection? knownOptionNames) + { + ArgumentNullException.ThrowIfNull(tokens); + ArgumentNullException.ThrowIfNull(options); + + var diagnostics = new List(); + var ignoreCase = options.OptionCaseSensitivity == ReplCaseSensitivity.CaseInsensitive; + var tokenComparer = ignoreCase + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + var effectiveTokens = options.AllowResponseFiles + ? ExpandResponseFiles(tokens, diagnostics) + : tokens; + var namedOptions = new Dictionary>(tokenComparer); var positionalArguments = new List(tokens.Count); + var knownOptions = knownOptionNames is null + ? null + : new HashSet(knownOptionNames, tokenComparer); var parseAsPositional = false; - for (var index = 0; index < tokens.Count; index++) + for (var index = 0; index < effectiveTokens.Count; index++) { - var token = tokens[index]; + var token = effectiveTokens[index]; if (parseAsPositional) { positionalArguments.Add(token); @@ -39,11 +74,33 @@ public static OptionParsingResult Parse(IReadOnlyList tokens) optionName = parts[0]; optionValue = parts[1]; } - else if (index + 1 < tokens.Count - && !tokens[index + 1].StartsWith("--", StringComparison.Ordinal)) + else if (optionName.Contains(':', StringComparison.Ordinal)) + { + var parts = optionName.Split(':', 2, StringSplitOptions.TrimEntries); + optionName = parts[0]; + optionValue = parts[1]; + } + else if (index + 1 < effectiveTokens.Count + && !effectiveTokens[index + 1].StartsWith("--", StringComparison.Ordinal)) { index++; - optionValue = tokens[index]; + optionValue = effectiveTokens[index]; + } + + if (knownOptions is not null + && !knownOptions.Contains(optionName) + && !options.AllowUnknownOptions) + { + var suggestion = TryResolveSuggestion(optionName, knownOptions, ignoreCase); + var message = suggestion is null + ? $"Unknown option '--{optionName}'." + : $"Unknown option '--{optionName}'. Did you mean '--{suggestion}'?"; + diagnostics.Add(new ParseDiagnostic( + ParseDiagnosticSeverity.Error, + message, + Token: token, + Suggestion: suggestion is null ? null : $"--{suggestion}")); + continue; } if (!namedOptions.TryGetValue(optionName, out var values)) @@ -58,8 +115,558 @@ public static OptionParsingResult Parse(IReadOnlyList tokens) var readonlyNamedOptions = namedOptions.ToDictionary( pair => pair.Key, pair => (IReadOnlyList)pair.Value, - StringComparer.OrdinalIgnoreCase); + tokenComparer); + + return new OptionParsingResult(readonlyNamedOptions, positionalArguments, diagnostics); + } + + [SuppressMessage( + "Maintainability", + "MA0051:Method is too long", + Justification = "Schema-aware parsing keeps token precedence and diagnostics explicit.")] + public static OptionParsingResult Parse( + IReadOnlyList tokens, + OptionSchema schema, + ParsingOptions options) + { + ArgumentNullException.ThrowIfNull(tokens); + ArgumentNullException.ThrowIfNull(schema); + ArgumentNullException.ThrowIfNull(options); + + var diagnostics = new List(); + var tokenComparer = options.OptionCaseSensitivity == ReplCaseSensitivity.CaseInsensitive + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + var effectiveTokens = options.AllowResponseFiles + ? ExpandResponseFiles(tokens, diagnostics) + : tokens; + var namedOptions = new Dictionary>(tokenComparer); + var positionalArguments = new List(tokens.Count); + var parseAsPositional = false; + + for (var index = 0; index < effectiveTokens.Count; index++) + { + var token = effectiveTokens[index]; + if (parseAsPositional) + { + positionalArguments.Add(token); + continue; + } + + if (string.Equals(token, "--", StringComparison.Ordinal)) + { + parseAsPositional = true; + continue; + } + + if (!LooksLikeOptionToken(token) || IsSignedNumericLiteral(token)) + { + positionalArguments.Add(token); + continue; + } + + TrySplitOptionToken(token, out var optionToken, out var inlineValue); + var matches = schema.ResolveToken(optionToken, options.OptionCaseSensitivity); + var resolution = ResolveSingleSchemaEntry(matches); + if (resolution.IsAmbiguous) + { + diagnostics.Add(new ParseDiagnostic( + ParseDiagnosticSeverity.Error, + $"Ambiguous option '{optionToken}'.", + Token: token)); + continue; + } + + if (resolution.Entry is null) + { + HandleUnknownOption( + effectiveTokens, + ref index, + token, + optionToken, + inlineValue, + schema, + options, + namedOptions, + diagnostics); + continue; + } + + ApplyResolvedSchemaEntry( + effectiveTokens, + ref index, + token, + inlineValue, + resolution.Entry, + namedOptions, + diagnostics); + } + + ValidateArityAndConflicts(schema, namedOptions, diagnostics); + var readonlyNamedOptions = namedOptions.ToDictionary( + pair => pair.Key, + pair => (IReadOnlyList)pair.Value, + tokenComparer); + return new OptionParsingResult(readonlyNamedOptions, positionalArguments, diagnostics); + } + + private static bool LooksLikeOptionToken(string token) => + token.Length >= 2 && token[0] == '-'; + + private static bool IsSignedNumericLiteral(string token) + { + if (token.Length < 2 || token[0] != '-') + { + return false; + } + + return double.TryParse( + token, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out _); + } + + private static void TrySplitOptionToken( + string token, + out string optionToken, + out string? inlineValue) + { + optionToken = token; + inlineValue = null; + var separatorIndex = token.IndexOfAny(['=', ':']); + if (separatorIndex <= 0) + { + return; + } + + optionToken = token[..separatorIndex]; + inlineValue = token[(separatorIndex + 1)..]; + } + + private static (OptionSchemaEntry? Entry, bool IsAmbiguous) ResolveSingleSchemaEntry( + IReadOnlyList matches) + { + if (matches.Count == 0) + { + return (null, false); + } + + var distinct = matches + .DistinctBy(match => (match.ParameterName, match.TokenKind, match.InjectedValue), StringTupleComparer.Instance) + .ToArray(); + return distinct.Length == 1 + ? (distinct[0], false) + : (null, true); + } + + private static void HandleUnknownOption( + IReadOnlyList effectiveTokens, + ref int index, + string token, + string optionToken, + string? inlineValue, + OptionSchema schema, + ParsingOptions options, + Dictionary> namedOptions, + List diagnostics) + { + if (!options.AllowUnknownOptions) + { + var suggestion = TryResolveSuggestion( + optionToken, + schema.KnownTokens, + options.OptionCaseSensitivity == ReplCaseSensitivity.CaseInsensitive); + var message = suggestion is null + ? $"Unknown option '{optionToken}'." + : $"Unknown option '{optionToken}'. Did you mean '{suggestion}'?"; + diagnostics.Add(new ParseDiagnostic( + ParseDiagnosticSeverity.Error, + message, + Token: token, + Suggestion: suggestion)); + return; + } + + var optionName = TrimOptionPrefix(optionToken); + var value = inlineValue; + if (value is null + && index + 1 < effectiveTokens.Count + && ShouldConsumeFollowingTokenAsValue(effectiveTokens[index + 1])) + { + index++; + value = effectiveTokens[index]; + } + + AddNamedValue(namedOptions, optionName, value ?? "true"); + } + + private static string TrimOptionPrefix(string token) => + token.StartsWith("--", StringComparison.Ordinal) + ? token[2..] + : token[1..]; + + private static bool ShouldConsumeFollowingTokenAsValue(string token) + { + if (string.Equals(token, "--", StringComparison.Ordinal)) + { + return false; + } + + return !LooksLikeOptionToken(token) || IsSignedNumericLiteral(token); + } + + private static void ApplyResolvedSchemaEntry( + IReadOnlyList effectiveTokens, + ref int index, + string originalToken, + string? inlineValue, + OptionSchemaEntry entry, + Dictionary> namedOptions, + List diagnostics) + { + switch (entry.TokenKind) + { + case OptionSchemaTokenKind.NamedOption: + ApplyNamedOptionValue( + effectiveTokens, + ref index, + originalToken, + inlineValue, + entry, + namedOptions, + diagnostics); + return; + case OptionSchemaTokenKind.BoolFlag: + ApplyBoolFlagValue( + effectiveTokens, + ref index, + inlineValue, + entry, + namedOptions); + return; + case OptionSchemaTokenKind.ReverseFlag: + if (inlineValue is not null) + { + diagnostics.Add(new ParseDiagnostic( + ParseDiagnosticSeverity.Error, + $"Option '{entry.Token}' does not accept an inline value.", + Token: originalToken)); + return; + } + + AddNamedValue(namedOptions, entry.ParameterName, "false"); + return; + case OptionSchemaTokenKind.ValueAlias: + case OptionSchemaTokenKind.EnumAlias: + AddNamedValue(namedOptions, entry.ParameterName, entry.InjectedValue ?? "true"); + return; + default: + return; + } + } + + private static void ApplyNamedOptionValue( + IReadOnlyList effectiveTokens, + ref int index, + string originalToken, + string? inlineValue, + OptionSchemaEntry entry, + Dictionary> namedOptions, + List diagnostics) + { + if (inlineValue is not null) + { + AddNamedValue(namedOptions, entry.ParameterName, inlineValue); + return; + } + + if (index + 1 >= effectiveTokens.Count + || !ShouldConsumeFollowingTokenAsValue(effectiveTokens[index + 1])) + { + diagnostics.Add(new ParseDiagnostic( + ParseDiagnosticSeverity.Error, + $"Option '{entry.Token}' is missing a value.", + Token: originalToken)); + return; + } + + index++; + AddNamedValue(namedOptions, entry.ParameterName, effectiveTokens[index]); + } + + private static void ApplyBoolFlagValue( + IReadOnlyList effectiveTokens, + ref int index, + string? inlineValue, + OptionSchemaEntry entry, + Dictionary> namedOptions) + { + if (inlineValue is not null) + { + AddNamedValue(namedOptions, entry.ParameterName, inlineValue); + return; + } + + if (index + 1 < effectiveTokens.Count + && ShouldConsumeFollowingTokenAsValue(effectiveTokens[index + 1])) + { + index++; + AddNamedValue(namedOptions, entry.ParameterName, effectiveTokens[index]); + return; + } + + AddNamedValue(namedOptions, entry.ParameterName, "true"); + } + + private static void AddNamedValue( + Dictionary> namedOptions, + string parameterName, + string value) + { + if (!namedOptions.TryGetValue(parameterName, out var values)) + { + values = []; + namedOptions[parameterName] = values; + } + + values.Add(value); + } + + private static void ValidateArityAndConflicts( + OptionSchema schema, + Dictionary> namedOptions, + List diagnostics) + { + foreach (var parameter in schema.Parameters.Values) + { + if (!namedOptions.TryGetValue(parameter.Name, out var values)) + { + continue; + } + + ValidateTooManyValues(schema, parameter, values, diagnostics); + ValidateBooleanConflicts(parameter, values, diagnostics); + ValidateEnumConflicts(parameter, values, diagnostics); + } + } + + private static void ValidateTooManyValues( + OptionSchema schema, + OptionSchemaParameter parameter, + List values, + List diagnostics) + { + var arity = ResolveParameterArity(schema, parameter.Name); + if (arity == ReplArity.ZeroOrOne && values.Count > 1) + { + diagnostics.Add(new ParseDiagnostic( + ParseDiagnosticSeverity.Error, + $"Option '--{parameter.Name}' accepts at most one value.")); + return; + } + + if (arity == ReplArity.ExactlyOne && values.Count != 1) + { + diagnostics.Add(new ParseDiagnostic( + ParseDiagnosticSeverity.Error, + $"Option '--{parameter.Name}' requires exactly one value.")); + } + } + + private static void ValidateBooleanConflicts( + OptionSchemaParameter parameter, + List values, + List diagnostics) + { + var effectiveType = Nullable.GetUnderlyingType(parameter.ParameterType) ?? parameter.ParameterType; + if (effectiveType != typeof(bool) || values.Count <= 1) + { + return; + } - return new OptionParsingResult(readonlyNamedOptions, positionalArguments); + var hasTrue = values.Exists(value => bool.TryParse(value, out var parsed) && parsed); + var hasFalse = values.Exists(value => bool.TryParse(value, out var parsed) && !parsed); + if (!hasTrue || !hasFalse) + { + return; + } + + diagnostics.Add(new ParseDiagnostic( + ParseDiagnosticSeverity.Error, + $"Option '--{parameter.Name}' cannot receive both positive and reverse values in the same invocation.")); + } + + private static void ValidateEnumConflicts( + OptionSchemaParameter parameter, + List values, + List diagnostics) + { + var effectiveType = Nullable.GetUnderlyingType(parameter.ParameterType) ?? parameter.ParameterType; + if (!effectiveType.IsEnum || values.Count <= 1) + { + return; + } + + var comparer = parameter.CaseSensitivity == ReplCaseSensitivity.CaseSensitive + ? StringComparer.Ordinal + : StringComparer.OrdinalIgnoreCase; + if (!values.Distinct(comparer).Skip(1).Any()) + { + return; + } + + diagnostics.Add(new ParseDiagnostic( + ParseDiagnosticSeverity.Error, + $"Option '--{parameter.Name}' received multiple enum values in a single invocation.")); + } + + private static ReplArity ResolveParameterArity(OptionSchema schema, string parameterName) + { + var entry = schema.Entries.FirstOrDefault(candidate => + string.Equals(candidate.ParameterName, parameterName, StringComparison.OrdinalIgnoreCase) + && candidate.TokenKind is OptionSchemaTokenKind.NamedOption or OptionSchemaTokenKind.BoolFlag); + return entry?.Arity ?? ReplArity.ZeroOrMore; + } + + private static List ExpandResponseFiles( + IReadOnlyList tokens, + List diagnostics) + { + var expanded = new List(tokens.Count); + foreach (var token in tokens) + { + if (!token.StartsWith('@') || token.Length == 1) + { + expanded.Add(token); + continue; + } + + var path = token[1..]; + if (!File.Exists(path)) + { + diagnostics.Add(new ParseDiagnostic( + ParseDiagnosticSeverity.Error, + $"Response file '{path}' was not found.", + Token: token)); + expanded.Add(token); + continue; + } + + var content = File.ReadAllText(path); + var tokenization = ResponseFileTokenizer.Tokenize(content); + expanded.AddRange(tokenization.Tokens); + if (tokenization.HasTrailingEscape) + { + diagnostics.Add(new ParseDiagnostic( + ParseDiagnosticSeverity.Warning, + $"Response file '{path}' ends with a trailing escape character '\\'.", + Token: token)); + } + } + + return expanded; + } + + private static string? TryResolveSuggestion( + string optionName, + IReadOnlyCollection knownOptions, + bool ignoreCase) + { + var bestDistance = int.MaxValue; + string? bestMatch = null; + foreach (var candidate in knownOptions) + { + var distance = ComputeLevenshteinDistance( + optionName, + candidate, + ignoreCase); + if (distance >= bestDistance) + { + continue; + } + + bestDistance = distance; + bestMatch = candidate; + } + + return bestDistance <= 2 ? bestMatch : null; + } + + private static int ComputeLevenshteinDistance(string source, string target, bool ignoreCase) + { + var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + if (string.Equals(source, target, comparison)) + { + return 0; + } + + if (source.Length == 0) + { + return target.Length; + } + + if (target.Length == 0) + { + return source.Length; + } + + if (target.Length > source.Length) + { + (source, target) = (target, source); + } + + var previous = new int[target.Length + 1]; + var current = new int[target.Length + 1]; + for (var column = 0; column <= target.Length; column++) + { + previous[column] = column; + } + + for (var row = 1; row <= source.Length; row++) + { + current[0] = row; + for (var column = 1; column <= target.Length; column++) + { + var sourceChar = source[row - 1]; + var targetChar = target[column - 1]; + if (ignoreCase) + { + sourceChar = char.ToLowerInvariant(sourceChar); + targetChar = char.ToLowerInvariant(targetChar); + } + + var cost = sourceChar == targetChar ? 0 : 1; + var deletion = previous[column] + 1; + var insertion = current[column - 1] + 1; + var substitution = previous[column - 1] + cost; + current[column] = Math.Min(Math.Min(deletion, insertion), substitution); + } + + (previous, current) = (current, previous); + } + + return previous[target.Length]; + } + + private sealed class StringTupleComparer : IEqualityComparer<(string ParameterName, OptionSchemaTokenKind TokenKind, string? InjectedValue)> + { + public static StringTupleComparer Instance { get; } = new(); + + public bool Equals( + (string ParameterName, OptionSchemaTokenKind TokenKind, string? InjectedValue) x, + (string ParameterName, OptionSchemaTokenKind TokenKind, string? InjectedValue) y) => + string.Equals(x.ParameterName, y.ParameterName, StringComparison.OrdinalIgnoreCase) + && x.TokenKind == y.TokenKind + && string.Equals(x.InjectedValue, y.InjectedValue, StringComparison.Ordinal); + + public int GetHashCode((string ParameterName, OptionSchemaTokenKind TokenKind, string? InjectedValue) obj) + { + var parameterHash = StringComparer.OrdinalIgnoreCase.GetHashCode(obj.ParameterName); + var injectedHash = obj.InjectedValue is null + ? 0 + : StringComparer.Ordinal.GetHashCode(obj.InjectedValue); + return HashCode.Combine(parameterHash, (int)obj.TokenKind, injectedHash); + } } } diff --git a/src/Repl.Core/OptionParsingResult.cs b/src/Repl.Core/OptionParsingResult.cs index 2510319..041f6de 100644 --- a/src/Repl.Core/OptionParsingResult.cs +++ b/src/Repl.Core/OptionParsingResult.cs @@ -2,9 +2,14 @@ namespace Repl; internal sealed class OptionParsingResult( IReadOnlyDictionary> namedOptions, - IReadOnlyList positionalArguments) + IReadOnlyList positionalArguments, + IReadOnlyList? diagnostics = null) { public IReadOnlyDictionary> NamedOptions { get; } = namedOptions; public IReadOnlyList PositionalArguments { get; } = positionalArguments; + + public IReadOnlyList Diagnostics { get; } = diagnostics ?? []; + + public bool HasErrors => Diagnostics.Any(d => d.Severity == ParseDiagnosticSeverity.Error); } diff --git a/src/Repl.Core/ParameterValueConverter.cs b/src/Repl.Core/ParameterValueConverter.cs index 5f907b7..f57317a 100644 --- a/src/Repl.Core/ParameterValueConverter.cs +++ b/src/Repl.Core/ParameterValueConverter.cs @@ -4,7 +4,11 @@ namespace Repl; internal static class ParameterValueConverter { - public static object? ConvertSingle(string? value, Type targetType, IFormatProvider numericFormatProvider) + public static object? ConvertSingle( + string? value, + Type targetType, + IFormatProvider numericFormatProvider, + bool enumIgnoreCase = true) { ArgumentNullException.ThrowIfNull(targetType); ArgumentNullException.ThrowIfNull(numericFormatProvider); @@ -22,7 +26,7 @@ internal static class ParameterValueConverter if (nonNullableType.IsEnum) { - return Enum.Parse(nonNullableType, value, ignoreCase: true); + return Enum.Parse(nonNullableType, value, ignoreCase: enumIgnoreCase); } return Convert.ChangeType(value, nonNullableType, numericFormatProvider); @@ -61,6 +65,8 @@ private static bool TryConvertCore( _ when nonNullableType == typeof(bool) => bool.Parse(value), _ when nonNullableType == typeof(Guid) => Guid.Parse(value), _ when nonNullableType == typeof(Uri) => new Uri(value, UriKind.RelativeOrAbsolute), + _ when nonNullableType == typeof(FileInfo) => new FileInfo(value), + _ when nonNullableType == typeof(DirectoryInfo) => new DirectoryInfo(value), _ when nonNullableType == typeof(double) => double.Parse( NormalizeNumericLiteral(value), NumberStyles.Float | NumberStyles.AllowThousands, diff --git a/src/Repl.Core/Parameters/Attributes/ReplArgumentAttribute.cs b/src/Repl.Core/Parameters/Attributes/ReplArgumentAttribute.cs new file mode 100644 index 0000000..fdc778b --- /dev/null +++ b/src/Repl.Core/Parameters/Attributes/ReplArgumentAttribute.cs @@ -0,0 +1,18 @@ +namespace Repl.Parameters; + +/// +/// Configures positional argument metadata for a handler parameter. +/// +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] +public sealed class ReplArgumentAttribute : Attribute +{ + /// + /// Optional explicit position for positional binding. + /// + public int? Position { get; set; } + + /// + /// Binding mode for the parameter. + /// + public ReplParameterMode Mode { get; set; } = ReplParameterMode.OptionAndPositional; +} diff --git a/src/Repl.Core/Parameters/Attributes/ReplEnumFlagAttribute.cs b/src/Repl.Core/Parameters/Attributes/ReplEnumFlagAttribute.cs new file mode 100644 index 0000000..5c5f716 --- /dev/null +++ b/src/Repl.Core/Parameters/Attributes/ReplEnumFlagAttribute.cs @@ -0,0 +1,27 @@ +namespace Repl.Parameters; + +/// +/// Declares alias tokens on enum members for flag-like option aliases. +/// +[AttributeUsage(AttributeTargets.Field, AllowMultiple = false, Inherited = false)] +public sealed class ReplEnumFlagAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// Alias tokens, including prefixes. + public ReplEnumFlagAttribute(params string[] aliases) + { + Aliases = aliases ?? throw new ArgumentNullException(nameof(aliases)); + } + + /// + /// Alias tokens for the enum member. + /// + public string[] Aliases { get; } + + /// + /// Optional case-sensitivity override for these aliases. + /// + public ReplCaseSensitivity? CaseSensitivity { get; set; } +} diff --git a/src/Repl.Core/Parameters/Attributes/ReplOptionAttribute.cs b/src/Repl.Core/Parameters/Attributes/ReplOptionAttribute.cs new file mode 100644 index 0000000..8cd8aab --- /dev/null +++ b/src/Repl.Core/Parameters/Attributes/ReplOptionAttribute.cs @@ -0,0 +1,38 @@ +namespace Repl.Parameters; + +/// +/// Configures named option metadata for a handler parameter. +/// +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] +public sealed class ReplOptionAttribute : Attribute +{ + /// + /// Canonical option name without prefix. + /// + public string? Name { get; set; } + + /// + /// Additional option aliases as full tokens (for example: --mode, -m). + /// + public string[] Aliases { get; set; } = []; + + /// + /// Reverse aliases as full tokens (for example: --no-verbose). + /// + public string[] ReverseAliases { get; set; } = []; + + /// + /// Binding mode for the parameter. + /// + public ReplParameterMode Mode { get; set; } = ReplParameterMode.OptionAndPositional; + + /// + /// Optional case-sensitivity override for this option. + /// + public ReplCaseSensitivity? CaseSensitivity { get; set; } + + /// + /// Optional arity override. + /// + public ReplArity? Arity { get; set; } +} diff --git a/src/Repl.Core/Parameters/Attributes/ReplValueAliasAttribute.cs b/src/Repl.Core/Parameters/Attributes/ReplValueAliasAttribute.cs new file mode 100644 index 0000000..2b7efe8 --- /dev/null +++ b/src/Repl.Core/Parameters/Attributes/ReplValueAliasAttribute.cs @@ -0,0 +1,36 @@ +namespace Repl.Parameters; + +/// +/// Maps an alias token to an injected option value for a parameter. +/// +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = true, Inherited = true)] +public sealed class ReplValueAliasAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// Alias token, including prefix. + /// Injected option value. + public ReplValueAliasAttribute(string token, string value) + { + Token = string.IsNullOrWhiteSpace(token) + ? throw new ArgumentException("Token cannot be empty.", nameof(token)) + : token; + Value = value ?? throw new ArgumentNullException(nameof(value)); + } + + /// + /// Alias token, including prefix. + /// + public string Token { get; } + + /// + /// Injected option value. + /// + public string Value { get; } + + /// + /// Optional case-sensitivity override for this alias. + /// + public ReplCaseSensitivity? CaseSensitivity { get; set; } +} diff --git a/src/Repl.Core/Parameters/ReplArity.cs b/src/Repl.Core/Parameters/ReplArity.cs new file mode 100644 index 0000000..50dd70b --- /dev/null +++ b/src/Repl.Core/Parameters/ReplArity.cs @@ -0,0 +1,27 @@ +namespace Repl.Parameters; + +/// +/// Declares option value cardinality. +/// +public enum ReplArity +{ + /// + /// Option may appear zero or one time. + /// + ZeroOrOne = 0, + + /// + /// Option must appear exactly one time. + /// + ExactlyOne = 1, + + /// + /// Option may appear zero to many times. + /// + ZeroOrMore = 2, + + /// + /// Option must appear one or more times. + /// + OneOrMore = 3, +} diff --git a/src/Repl.Core/Parameters/ReplCaseSensitivity.cs b/src/Repl.Core/Parameters/ReplCaseSensitivity.cs new file mode 100644 index 0000000..d699dcf --- /dev/null +++ b/src/Repl.Core/Parameters/ReplCaseSensitivity.cs @@ -0,0 +1,17 @@ +namespace Repl.Parameters; + +/// +/// Case-sensitivity behavior for option-token matching. +/// +public enum ReplCaseSensitivity +{ + /// + /// Option tokens must match exact casing. + /// + CaseSensitive = 0, + + /// + /// Option tokens are compared ignoring character casing. + /// + CaseInsensitive = 1, +} diff --git a/src/Repl.Core/Parameters/ReplParameterMode.cs b/src/Repl.Core/Parameters/ReplParameterMode.cs new file mode 100644 index 0000000..f8dde81 --- /dev/null +++ b/src/Repl.Core/Parameters/ReplParameterMode.cs @@ -0,0 +1,22 @@ +namespace Repl.Parameters; + +/// +/// Controls whether a handler parameter accepts named options, positional arguments, or both. +/// +public enum ReplParameterMode +{ + /// + /// Parameter can bind from named option first, then positional fallback. + /// + OptionAndPositional = 0, + + /// + /// Parameter can bind only from named options. + /// + OptionOnly = 1, + + /// + /// Parameter can bind only from positional arguments. + /// + ArgumentOnly = 2, +} diff --git a/src/Repl.Core/ParseDiagnostic.cs b/src/Repl.Core/ParseDiagnostic.cs new file mode 100644 index 0000000..ddfa010 --- /dev/null +++ b/src/Repl.Core/ParseDiagnostic.cs @@ -0,0 +1,7 @@ +namespace Repl; + +internal sealed record ParseDiagnostic( + ParseDiagnosticSeverity Severity, + string Message, + string? Token = null, + string? Suggestion = null); diff --git a/src/Repl.Core/ParseDiagnosticSeverity.cs b/src/Repl.Core/ParseDiagnosticSeverity.cs new file mode 100644 index 0000000..105e4bf --- /dev/null +++ b/src/Repl.Core/ParseDiagnosticSeverity.cs @@ -0,0 +1,7 @@ +namespace Repl; + +internal enum ParseDiagnosticSeverity +{ + Error = 0, + Warning = 1, +} diff --git a/src/Repl.Core/ParsingOptions.cs b/src/Repl.Core/ParsingOptions.cs index 0c548e5..e3bdb10 100644 --- a/src/Repl.Core/ParsingOptions.cs +++ b/src/Repl.Core/ParsingOptions.cs @@ -34,17 +34,31 @@ public sealed class ParsingOptions ]; private readonly Dictionary> _customRouteConstraints = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _globalOptions = + new(StringComparer.OrdinalIgnoreCase); /// /// Gets or sets a value indicating whether unknown options are allowed. /// public bool AllowUnknownOptions { get; set; } + /// + /// Gets or sets option-name case-sensitivity mode. + /// + public ReplCaseSensitivity OptionCaseSensitivity { get; set; } = ReplCaseSensitivity.CaseSensitive; + + /// + /// Gets or sets a value indicating whether response files (for example: @args.rsp) are expanded. + /// + public bool AllowResponseFiles { get; set; } = true; + /// /// Gets or sets the culture mode used for numeric conversions. /// public NumericParsingCulture NumericCulture { get; set; } = NumericParsingCulture.Invariant; + internal IReadOnlyDictionary GlobalOptions => _globalOptions; + internal IFormatProvider NumericFormatProvider => NumericCulture == NumericParsingCulture.Current ? CultureInfo.CurrentCulture : CultureInfo.InvariantCulture; @@ -78,4 +92,52 @@ public void AddRouteConstraint(string name, Func predicate) internal bool TryGetRouteConstraint(string name, out Func predicate) => _customRouteConstraints.TryGetValue(name, out predicate!); + + /// + /// Registers a custom global option consumed before command routing. + /// + /// Declared value type (metadata only for now). + /// Canonical name without prefix (for example: "tenant"). + /// Optional aliases. Values without prefix are normalized to --alias. + /// Optional default value metadata. + public void AddGlobalOption(string name, string[]? aliases = null, T? defaultValue = default) + { + name = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Global option name cannot be empty.", nameof(name)) + : name.Trim(); + + var normalizedCanonical = NormalizeLongToken(name); + if (_globalOptions.ContainsKey(name)) + { + throw new InvalidOperationException($"A global option named '{name}' is already registered."); + } + + var normalizedAliases = (aliases ?? []) + .Where(alias => !string.IsNullOrWhiteSpace(alias)) + .Select(alias => NormalizeAliasToken(alias.Trim())) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Where(alias => !string.Equals(alias, normalizedCanonical, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + _globalOptions[name] = new GlobalOptionDefinition( + Name: name, + CanonicalToken: normalizedCanonical, + Aliases: normalizedAliases, + DefaultValue: defaultValue?.ToString()); + } + + private static string NormalizeLongToken(string name) => + name.StartsWith("--", StringComparison.Ordinal) + ? name + : $"--{name}"; + + private static string NormalizeAliasToken(string alias) + { + if (alias.StartsWith("--", StringComparison.Ordinal) || alias.StartsWith('-')) + { + return alias; + } + + return $"--{alias}"; + } } diff --git a/src/Repl.Core/AnsiMode.cs b/src/Repl.Core/Rendering/Public/AnsiMode.cs similarity index 91% rename from src/Repl.Core/AnsiMode.cs rename to src/Repl.Core/Rendering/Public/AnsiMode.cs index 08debb2..2717722 100644 --- a/src/Repl.Core/AnsiMode.cs +++ b/src/Repl.Core/Rendering/Public/AnsiMode.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Rendering; /// /// Defines ANSI rendering behavior. @@ -19,4 +19,4 @@ public enum AnsiMode /// Disable ANSI output. /// Never, -} \ No newline at end of file +} diff --git a/src/Repl.Core/AnsiPalette.cs b/src/Repl.Core/Rendering/Public/AnsiPalette.cs similarity index 96% rename from src/Repl.Core/AnsiPalette.cs rename to src/Repl.Core/Rendering/Public/AnsiPalette.cs index 0f734c5..593185d 100644 --- a/src/Repl.Core/AnsiPalette.cs +++ b/src/Repl.Core/Rendering/Public/AnsiPalette.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Rendering; /// /// Semantic ANSI style palette used by human-oriented renderers. diff --git a/src/Repl.Core/IAnsiPaletteProvider.cs b/src/Repl.Core/Rendering/Public/IAnsiPaletteProvider.cs similarity index 93% rename from src/Repl.Core/IAnsiPaletteProvider.cs rename to src/Repl.Core/Rendering/Public/IAnsiPaletteProvider.cs index 8c29010..dac5d96 100644 --- a/src/Repl.Core/IAnsiPaletteProvider.cs +++ b/src/Repl.Core/Rendering/Public/IAnsiPaletteProvider.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Rendering; /// /// Produces ANSI palettes for a given terminal theme mode. diff --git a/src/Repl.Core/ReplDocOption.cs b/src/Repl.Core/ReplDocOption.cs deleted file mode 100644 index 3d61004..0000000 --- a/src/Repl.Core/ReplDocOption.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Repl; - -/// -/// Option metadata. -/// -public sealed record ReplDocOption( - string Name, - string Type, - bool Required, - string? Description); \ No newline at end of file diff --git a/src/Repl.Core/ReplDocumentationExtensions.cs b/src/Repl.Core/ReplDocumentationExtensions.cs index 453169d..36b0e3c 100644 --- a/src/Repl.Core/ReplDocumentationExtensions.cs +++ b/src/Repl.Core/ReplDocumentationExtensions.cs @@ -1,3 +1,5 @@ +using Repl.Documentation; + namespace Repl; /// diff --git a/src/Repl.Core/ResponseFileTokenizationResult.cs b/src/Repl.Core/ResponseFileTokenizationResult.cs new file mode 100644 index 0000000..bad95e0 --- /dev/null +++ b/src/Repl.Core/ResponseFileTokenizationResult.cs @@ -0,0 +1,5 @@ +namespace Repl; + +internal sealed record ResponseFileTokenizationResult( + IReadOnlyList Tokens, + bool HasTrailingEscape); diff --git a/src/Repl.Core/ResponseFileTokenizer.cs b/src/Repl.Core/ResponseFileTokenizer.cs new file mode 100644 index 0000000..13d8163 --- /dev/null +++ b/src/Repl.Core/ResponseFileTokenizer.cs @@ -0,0 +1,79 @@ +using System.Text; + +namespace Repl; + +internal static class ResponseFileTokenizer +{ + public static ResponseFileTokenizationResult Tokenize(string content) + { + ArgumentNullException.ThrowIfNull(content); + + var tokens = new List(); + var current = new StringBuilder(); + var inSingleQuote = false; + var inDoubleQuote = false; + var escaping = false; + + for (var index = 0; index < content.Length; index++) + { + var ch = content[index]; + if (escaping) + { + current.Append(ch); + escaping = false; + continue; + } + + if (ch == '\\') + { + escaping = true; + continue; + } + + if (!inSingleQuote && ch == '"') + { + inDoubleQuote = !inDoubleQuote; + continue; + } + + if (!inDoubleQuote && ch == '\'') + { + inSingleQuote = !inSingleQuote; + continue; + } + + if (!inSingleQuote && !inDoubleQuote && ch == '#') + { + FinalizeToken(tokens, current); + while (index + 1 < content.Length && content[index + 1] is not '\r' and not '\n') + { + index++; + } + + continue; + } + + if (!inSingleQuote && !inDoubleQuote && char.IsWhiteSpace(ch)) + { + FinalizeToken(tokens, current); + continue; + } + + current.Append(ch); + } + + FinalizeToken(tokens, current); + return new ResponseFileTokenizationResult(tokens, HasTrailingEscape: escaping); + } + + private static void FinalizeToken(List tokens, StringBuilder current) + { + if (current.Length == 0) + { + return; + } + + tokens.Add(current.ToString()); + current.Clear(); + } +} diff --git a/src/Repl.Core/RouteDefinition.cs b/src/Repl.Core/RouteDefinition.cs index 1bcb0f9..5cdcac5 100644 --- a/src/Repl.Core/RouteDefinition.cs +++ b/src/Repl.Core/RouteDefinition.cs @@ -1,13 +1,18 @@ +using Repl.Internal.Options; + namespace Repl; internal sealed class RouteDefinition( RouteTemplate template, CommandBuilder command, - int moduleId) + int moduleId, + OptionSchema optionSchema) { public RouteTemplate Template { get; } = template; public CommandBuilder Command { get; } = command; public int ModuleId { get; } = moduleId; + + public OptionSchema OptionSchema { get; } = optionSchema; } diff --git a/src/Repl.Core/ShellCompletionOptions.cs b/src/Repl.Core/ShellCompletion/Public/ShellCompletionOptions.cs similarity index 98% rename from src/Repl.Core/ShellCompletionOptions.cs rename to src/Repl.Core/ShellCompletion/Public/ShellCompletionOptions.cs index 0259467..c128ee7 100644 --- a/src/Repl.Core/ShellCompletionOptions.cs +++ b/src/Repl.Core/ShellCompletion/Public/ShellCompletionOptions.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.ShellCompletion; /// /// Shell completion setup options. diff --git a/src/Repl.Core/ShellCompletionSetupMode.cs b/src/Repl.Core/ShellCompletion/Public/ShellCompletionSetupMode.cs similarity index 94% rename from src/Repl.Core/ShellCompletionSetupMode.cs rename to src/Repl.Core/ShellCompletion/Public/ShellCompletionSetupMode.cs index 1234e97..8f82bd6 100644 --- a/src/Repl.Core/ShellCompletionSetupMode.cs +++ b/src/Repl.Core/ShellCompletion/Public/ShellCompletionSetupMode.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.ShellCompletion; /// /// Configures how shell completion installation is handled at runtime. diff --git a/src/Repl.Core/ShellKind.cs b/src/Repl.Core/ShellCompletion/Public/ShellKind.cs similarity index 95% rename from src/Repl.Core/ShellKind.cs rename to src/Repl.Core/ShellCompletion/Public/ShellKind.cs index f720414..b0b20df 100644 --- a/src/Repl.Core/ShellKind.cs +++ b/src/Repl.Core/ShellCompletion/Public/ShellKind.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.ShellCompletion; /// /// Shell kind used by shell completion setup and detection logic. diff --git a/src/Repl.Core/TerminalCapabilities.cs b/src/Repl.Core/Terminal/Public/TerminalCapabilities.cs similarity index 96% rename from src/Repl.Core/TerminalCapabilities.cs rename to src/Repl.Core/Terminal/Public/TerminalCapabilities.cs index aed302a..0ea3b9e 100644 --- a/src/Repl.Core/TerminalCapabilities.cs +++ b/src/Repl.Core/Terminal/Public/TerminalCapabilities.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Terminal; /// /// Capability flags reported or inferred for the active terminal session. diff --git a/src/Repl.Core/TerminalControlMessage.cs b/src/Repl.Core/Terminal/Public/TerminalControlMessage.cs similarity index 92% rename from src/Repl.Core/TerminalControlMessage.cs rename to src/Repl.Core/Terminal/Public/TerminalControlMessage.cs index 0ebb7f9..53604e0 100644 --- a/src/Repl.Core/TerminalControlMessage.cs +++ b/src/Repl.Core/Terminal/Public/TerminalControlMessage.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Terminal; /// /// Parsed remote terminal metadata/control payload. diff --git a/src/Repl.Core/TerminalControlMessageKind.cs b/src/Repl.Core/Terminal/Public/TerminalControlMessageKind.cs similarity index 93% rename from src/Repl.Core/TerminalControlMessageKind.cs rename to src/Repl.Core/Terminal/Public/TerminalControlMessageKind.cs index 23d7061..d392fe8 100644 --- a/src/Repl.Core/TerminalControlMessageKind.cs +++ b/src/Repl.Core/Terminal/Public/TerminalControlMessageKind.cs @@ -1,4 +1,4 @@ -namespace Repl; +namespace Repl.Terminal; /// /// Indicates the kind of terminal control message received from a remote client. diff --git a/src/Repl.Core/TerminalControlProtocol.cs b/src/Repl.Core/Terminal/Public/TerminalControlProtocol.cs similarity index 99% rename from src/Repl.Core/TerminalControlProtocol.cs rename to src/Repl.Core/Terminal/Public/TerminalControlProtocol.cs index 3d015dc..f6c298f 100644 --- a/src/Repl.Core/TerminalControlProtocol.cs +++ b/src/Repl.Core/Terminal/Public/TerminalControlProtocol.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace Repl; +namespace Repl.Terminal; /// /// Parses and formats lightweight terminal control messages transported over text channels. diff --git a/src/Repl.Defaults/GlobalUsings.cs b/src/Repl.Defaults/GlobalUsings.cs new file mode 100644 index 0000000..2af0191 --- /dev/null +++ b/src/Repl.Defaults/GlobalUsings.cs @@ -0,0 +1,3 @@ +global using Repl.Interaction; +global using Repl.Rendering; +global using Repl.Terminal; diff --git a/src/Repl.Defaults/ReplAppDocumentationExtensions.cs b/src/Repl.Defaults/ReplAppDocumentationExtensions.cs index e1d1ac2..19f08d8 100644 --- a/src/Repl.Defaults/ReplAppDocumentationExtensions.cs +++ b/src/Repl.Defaults/ReplAppDocumentationExtensions.cs @@ -1,3 +1,5 @@ +using Repl.Documentation; + namespace Repl; /// diff --git a/src/Repl.IntegrationTests/Given_Completions.cs b/src/Repl.IntegrationTests/Given_Completions.cs index cfa246f..e3f2022 100644 --- a/src/Repl.IntegrationTests/Given_Completions.cs +++ b/src/Repl.IntegrationTests/Given_Completions.cs @@ -1,3 +1,5 @@ +using System.Globalization; + namespace Repl.IntegrationTests; [TestClass] @@ -85,6 +87,32 @@ public void When_AutocompleteModeCommandIsUsed_Then_SessionOverrideIsApplied() output.Text.Should().Contain("Autocomplete mode set to Off"); output.Text.Should().Contain("override=Off"); } + + [TestMethod] + [Description("Regression guard: verifies shell completion suggests custom global options so app-registered globals remain discoverable from tab completion.")] + public void When_CompletingGlobalPrefix_Then_CustomGlobalOptionsAreSuggested() + { + var sut = ReplApp.Create() + .Options(options => options.Parsing.AddGlobalOption("tenant", aliases: ["-t"])); + sut.Map("ping", () => "pong"); + const string line = "repl --te"; + + var output = ConsoleCaptureHelper.Capture(() => sut.Run( + [ + "completion", + "__complete", + "--shell", + "bash", + "--line", + line, + "--cursor", + line.Length.ToString(CultureInfo.InvariantCulture), + "--no-logo", + ])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("--tenant"); + } } diff --git a/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs b/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs new file mode 100644 index 0000000..f77b163 --- /dev/null +++ b/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs @@ -0,0 +1,49 @@ +namespace Repl.IntegrationTests; + +[TestClass] +[DoNotParallelize] +public sealed class Given_CustomGlobalOptions +{ + [TestMethod] + [Description("Regression guard: verifies custom global options are consumed before command parsing so strict command option validation does not reject recognized global tokens.")] + public void When_CustomGlobalOptionIsProvided_Then_CommandStillExecutesInStrictMode() + { + var sut = ReplApp.Create() + .Options(options => options.Parsing.AddGlobalOption("tenant")); + sut.Map("ping", () => "ok"); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["ping", "--tenant", "acme", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("ok"); + } + + [TestMethod] + [Description("Regression guard: verifies global and command options with the same name are rejected so invocation behavior is never ambiguous.")] + public void When_GlobalAndCommandOptionCollide_Then_ValidationErrorIsReturned() + { + var sut = ReplApp.Create() + .Options(options => options.Parsing.AddGlobalOption("tenant")); + sut.Map("ping", (string tenant) => tenant); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["ping", "--tenant", "acme", "--no-logo"])); + + output.ExitCode.Should().Be(1); + output.Text.Should().Contain("Ambiguous option '--tenant'"); + } + + [TestMethod] + [Description("Regression guard: verifies root help includes custom global options so registered global flags remain discoverable to users.")] + public void When_RequestingRootHelp_Then_CustomGlobalOptionIsListedInGlobalOptionsSection() + { + var sut = ReplApp.Create() + .Options(options => options.Parsing.AddGlobalOption("tenant", aliases: ["-t"])); + sut.Map("ping", () => "ok"); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["--help", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("Global Options:"); + output.Text.Should().Contain("--tenant, -t"); + } +} diff --git a/src/Repl.IntegrationTests/Given_DocumentationExport.cs b/src/Repl.IntegrationTests/Given_DocumentationExport.cs index c70a1a7..fff64ac 100644 --- a/src/Repl.IntegrationTests/Given_DocumentationExport.cs +++ b/src/Repl.IntegrationTests/Given_DocumentationExport.cs @@ -127,4 +127,35 @@ public void When_ExportingDocumentationAsJson_Then_TemporalConstraintTypesAreInc output.Text.Should().Contain("\"type\": \"date\""); output.Text.Should().Contain("\"type\": \"timespan\""); } + + [TestMethod] + [Description("Regression guard: verifies option metadata export includes aliases, reverse aliases, enum values, and defaults so external tooling can reconstruct invocation UX.")] + public void When_ExportingDocumentationAsJson_Then_OptionMetadataIncludesSchemaDetails() + { + var sut = ReplApp.Create() + .UseDocumentationExport(); + sut.Map( + "render", + ([ReplOption(Aliases = ["-m"])] ExportMode mode = ExportMode.Fast, + [ReplOption(ReverseAliases = ["--no-verbose"])] bool verbose = false) => $"{mode}:{verbose}"); + + var output = ConsoleCaptureHelper.Capture( + () => sut.Run(["doc", "export", "--json", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("\"aliases\": ["); + output.Text.Should().Contain("\"-m\""); + output.Text.Should().Contain("\"reverseAliases\": ["); + output.Text.Should().Contain("\"--no-verbose\""); + output.Text.Should().Contain("\"enumValues\": ["); + output.Text.Should().Contain("\"Fast\""); + output.Text.Should().Contain("\"Slow\""); + output.Text.Should().Contain("\"defaultValue\": \"Fast\""); + } + + private enum ExportMode + { + Fast, + Slow, + } } diff --git a/src/Repl.IntegrationTests/Given_FileSystemParameterBinding.cs b/src/Repl.IntegrationTests/Given_FileSystemParameterBinding.cs new file mode 100644 index 0000000..653fc13 --- /dev/null +++ b/src/Repl.IntegrationTests/Given_FileSystemParameterBinding.cs @@ -0,0 +1,34 @@ +namespace Repl.IntegrationTests; + +[TestClass] +[DoNotParallelize] +public sealed class Given_FileSystemParameterBinding +{ + [TestMethod] + [Description("Regression guard: verifies FileInfo parameters bind from named options so handlers can receive file-path objects without manual conversion.")] + public void When_FileInfoParameterIsBound_Then_HandlerReceivesExpectedPath() + { + var sut = ReplApp.Create(); + sut.Map("inspect", (FileInfo path) => path.FullName); + var tempFile = Path.Join(Path.GetTempPath(), $"repl-file-{Guid.NewGuid():N}.txt"); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["inspect", "--path", tempFile, "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain(Path.GetFileName(tempFile)); + } + + [TestMethod] + [Description("Regression guard: verifies DirectoryInfo parameters bind from named options so directory paths flow through handler typing consistently with other scalar conversions.")] + public void When_DirectoryInfoParameterIsBound_Then_HandlerReceivesExpectedPath() + { + var sut = ReplApp.Create(); + sut.Map("inspect", (DirectoryInfo path) => path.FullName); + var tempDirectory = Path.Join(Path.GetTempPath(), $"repl-dir-{Guid.NewGuid():N}"); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["inspect", "--path", tempDirectory, "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain(Path.GetFileName(tempDirectory)); + } +} diff --git a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs index 21ce402..5eb65e0 100644 --- a/src/Repl.IntegrationTests/Given_HelpDiscovery.cs +++ b/src/Repl.IntegrationTests/Given_HelpDiscovery.cs @@ -359,7 +359,7 @@ public void When_RequestingCommandHelpWithParameterDescriptions_Then_ParameterSe var output = ConsoleCaptureHelper.Capture(() => sut.Run(["send", "--help", "--no-logo"])); output.ExitCode.Should().Be(0); - output.Text.Should().Contain("Parameters:"); + output.Text.Should().Contain("Arguments:"); output.Text.Should().Contain(""); output.Text.Should().Contain("Message to send to all watching sessions"); } @@ -376,8 +376,32 @@ public void When_RequestingCommandHelpWithoutParameterDescriptions_Then_NoParame output.ExitCode.Should().Be(0); output.Text.Should().Contain("Usage: ping "); - output.Text.Should().NotContain("Parameters:"); + output.Text.Should().NotContain("Arguments:"); + } + + [TestMethod] + [Description("Regression guard: verifies command help renders schema-derived options with aliases and enum placeholders so documentation matches parser capabilities.")] + public void When_RequestingCommandHelpWithDeclaredOptions_Then_OptionsSectionIncludesAliasesAndEnumValues() + { + var sut = ReplApp.Create(); + sut.Map( + "render", + ([ReplOption(Aliases = ["-m"])] HelpMode mode = HelpMode.Fast, + [ReplOption(ReverseAliases = ["--no-verbose"])] bool verbose = false) => $"{mode}:{verbose}"); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["render", "--help", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("Options:"); + output.Text.Should().Contain("--mode, -m "); + output.Text.Should().Contain("--verbose, --no-verbose"); } private static string SendHandler([ComponentDescriptionAttribute("Message to send to all watching sessions")] string message) => message; + + private enum HelpMode + { + Fast, + Slow, + } } diff --git a/src/Repl.IntegrationTests/Given_OptionParsingDiagnostics.cs b/src/Repl.IntegrationTests/Given_OptionParsingDiagnostics.cs new file mode 100644 index 0000000..8a8930d --- /dev/null +++ b/src/Repl.IntegrationTests/Given_OptionParsingDiagnostics.cs @@ -0,0 +1,61 @@ +namespace Repl.IntegrationTests; + +[TestClass] +[DoNotParallelize] +public sealed class Given_OptionParsingDiagnostics +{ + [TestMethod] + [Description("Regression guard: verifies unknown command option fails in strict mode so typos are surfaced as validation errors instead of being silently accepted.")] + public void When_UnknownCommandOptionInStrictMode_Then_ValidationErrorIsReturned() + { + var sut = ReplApp.Create(); + sut.Map("echo", (string text) => text); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["echo", "--txet", "hello", "--no-logo"])); + + output.ExitCode.Should().Be(1); + output.Text.Should().Contain("Unknown option '--txet'"); + output.Text.Should().Contain("--text"); + } + + [TestMethod] + [Description("Regression guard: verifies permissive mode keeps unknown options bindable so legacy handlers depending on implicit option names continue to work during migration.")] + public void When_UnknownCommandOptionInPermissiveMode_Then_HandlerStillReceivesValue() + { + var sut = ReplApp.Create() + .Options(options => options.Parsing.AllowUnknownOptions = true); + sut.Map("echo", (string mystery) => mystery); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["echo", "--mystery", "hello", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("hello"); + } + + [TestMethod] + [Description("Regression guard: verifies command option matching is case-sensitive by default so accidental casing drift is caught early.")] + public void When_OptionNameCaseDiffersAndDefaultSensitivity_Then_ValidationErrorIsReturned() + { + var sut = ReplApp.Create(); + sut.Map("echo", (string text) => text); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["echo", "--Text", "hello", "--no-logo"])); + + output.ExitCode.Should().Be(1); + output.Text.Should().Contain("Unknown option '--Text'"); + } + + [TestMethod] + [Description("Regression guard: verifies case-insensitive parsing mode accepts option-name casing variants so apps can opt into compatibility behavior.")] + public void When_OptionNameCaseDiffersAndCaseInsensitiveMode_Then_CommandExecutes() + { + var sut = ReplApp.Create() + .Options(options => options.Parsing.OptionCaseSensitivity = ReplCaseSensitivity.CaseInsensitive); + sut.Map("echo", (string text) => text); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["echo", "--Text", "hello", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("hello"); + } +} diff --git a/src/Repl.IntegrationTests/Given_ParameterSchemaBinding.cs b/src/Repl.IntegrationTests/Given_ParameterSchemaBinding.cs new file mode 100644 index 0000000..969869a --- /dev/null +++ b/src/Repl.IntegrationTests/Given_ParameterSchemaBinding.cs @@ -0,0 +1,78 @@ +namespace Repl.IntegrationTests; + +[TestClass] +[DoNotParallelize] +public sealed class Given_ParameterSchemaBinding +{ + [TestMethod] + [Description("Regression guard: verifies explicit short aliases are parsed through the command schema so single-dash tokens can bind typed handler parameters.")] + public void When_UsingDeclaredShortAlias_Then_ParameterBindsSuccessfully() + { + var sut = ReplApp.Create(); + sut.Map( + "run", + ([ReplOption(Aliases = ["-m"])] string mode) => mode); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["run", "-m", "fast", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("fast"); + } + + [TestMethod] + [Description("Regression guard: verifies route parameters cannot opt into option metadata so misconfigured handlers fail during command registration.")] + public void When_RouteParameterDeclaresOptionAttribute_Then_MapFailsWithConfigurationError() + { + var sut = ReplApp.Create(); + + var act = () => sut.Map( + "show {id:int}", + ([ReplOption] int id) => id); + + act.Should().Throw() + .WithMessage("*Route parameter*cannot declare ReplOption/ReplArgument attributes*"); + } + + [TestMethod] + [Description("Regression guard: verifies OptionOnly mode blocks positional fallback so handlers cannot accidentally consume unnamed user input.")] + public void When_ParameterIsOptionOnlyAndOnlyPositionalValueIsProvided_Then_InvocationFails() + { + var sut = ReplApp.Create(); + sut.Map( + "set", + ([ReplOption(Mode = ReplParameterMode.OptionOnly)] int value) => value); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["set", "42", "--no-logo"])); + + output.ExitCode.Should().Be(1); + output.Text.Should().Contain("Unable to bind parameter 'value'"); + } + + [TestMethod] + [Description("Regression guard: verifies OptionAndPositional mode reports source conflicts so named and positional values cannot target the same parameter in one call.")] + public void When_ParameterReceivesNamedAndPositionalValues_Then_InvocationFailsWithConflict() + { + var sut = ReplApp.Create(); + sut.Map( + "set", + ([ReplOption(Mode = ReplParameterMode.OptionAndPositional)] string value) => value); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["set", "--value", "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 signed numeric literals remain positional so negative numbers are not misparsed as short options.")] + public void When_PositionalValueIsSignedNumericLiteral_Then_ValueIsNotTreatedAsOptionToken() + { + var sut = ReplApp.Create(); + sut.Map("echo", (double value) => value.ToString(System.Globalization.CultureInfo.InvariantCulture)); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["echo", "-1.5", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("-1.5"); + } +} diff --git a/src/Repl.IntegrationTests/Given_ResponseFiles.cs b/src/Repl.IntegrationTests/Given_ResponseFiles.cs new file mode 100644 index 0000000..79e9a2e --- /dev/null +++ b/src/Repl.IntegrationTests/Given_ResponseFiles.cs @@ -0,0 +1,53 @@ +namespace Repl.IntegrationTests; + +[TestClass] +[DoNotParallelize] +public sealed class Given_ResponseFiles +{ + [TestMethod] + [Description("Regression guard: verifies CLI mode expands @response files so multi-token command invocations can be externalized safely.")] + public void When_RunningInCliMode_Then_ResponseFileIsExpanded() + { + var sut = ReplApp.Create(); + sut.Map("echo", (string text) => text); + var responseFile = Path.Join(Path.GetTempPath(), $"repl-response-{Guid.NewGuid():N}.rsp"); + File.WriteAllText(responseFile, "--text hello-from-rsp"); + + try + { + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["echo", $"@{responseFile}", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("hello-from-rsp"); + } + finally + { + File.Delete(responseFile); + } + } + + [TestMethod] + [Description("Regression guard: verifies interactive command execution does not expand @response files by default so raw @tokens remain user-controlled in REPL sessions.")] + public void When_RunningInInteractiveMode_Then_ResponseFileExpansionIsDisabledByDefault() + { + var sut = ReplApp.Create().UseDefaultInteractive(); + sut.Map("echo", (string text) => text); + var responseFile = Path.Join(Path.GetTempPath(), $"repl-response-{Guid.NewGuid():N}.rsp"); + File.WriteAllText(responseFile, "--text hello-from-rsp"); + + try + { + var output = ConsoleCaptureHelper.CaptureWithInput( + $"echo @{responseFile}{Environment.NewLine}exit{Environment.NewLine}", + () => sut.Run(["--interactive", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain($"@{responseFile}"); + output.Text.Should().NotContain("hello-from-rsp"); + } + finally + { + File.Delete(responseFile); + } + } +} diff --git a/src/Repl.IntegrationTests/Given_ShellCompletion.cs b/src/Repl.IntegrationTests/Given_ShellCompletion.cs index df1ec53..10f5aa2 100644 --- a/src/Repl.IntegrationTests/Given_ShellCompletion.cs +++ b/src/Repl.IntegrationTests/Given_ShellCompletion.cs @@ -118,8 +118,13 @@ public void When_CompletingNestedPathInPowerShell_Then_NextLiteralsAreReturned() } [TestMethod] - [Description("Regression guard: verifies option completion on resolved command route so that static handler options are suggested.")] - public void When_CompletingCommandOptionPrefix_Then_StaticRouteOptionsAreReturned() + [DataRow("bash")] + [DataRow("powershell")] + [DataRow("zsh")] + [DataRow("fish")] + [DataRow("nu")] + [Description("Regression guard: verifies option completion on resolved command routes across all supported shells so static typed parameters and flags remain discoverable.")] + public void When_CompletingCommandOptionPrefix_Then_StaticRouteOptionsAreReturnedForAllShells(string shell) { const string line = "repl contact show 42 --v"; var output = Run( @@ -127,7 +132,7 @@ public void When_CompletingCommandOptionPrefix_Then_StaticRouteOptionsAreReturne "completion", "__complete", "--shell", - "bash", + shell, "--line", line, "--cursor", @@ -140,8 +145,39 @@ public void When_CompletingCommandOptionPrefix_Then_StaticRouteOptionsAreReturne } [TestMethod] - [Description("Regression guard: verifies global option completion so that known static global options are suggested.")] - public void When_CompletingGlobalOptionPrefix_Then_GlobalOptionsAreReturned() + [DataRow("bash")] + [DataRow("powershell")] + [DataRow("zsh")] + [DataRow("fish")] + [DataRow("nu")] + [Description("Regression guard: verifies named value-parameter option completion across all supported shells so typed option parameters remain discoverable.")] + public void When_CompletingValueOptionPrefix_Then_NamedValueOptionIsReturnedForAllShells(string shell) + { + const string line = "repl contact show 42 --l"; + var output = Run( + [ + "completion", + "__complete", + "--shell", + shell, + "--line", + line, + "--cursor", + line.Length.ToString(CultureInfo.InvariantCulture), + ]); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("--label"); + } + + [TestMethod] + [DataRow("bash")] + [DataRow("powershell")] + [DataRow("zsh")] + [DataRow("fish")] + [DataRow("nu")] + [Description("Regression guard: verifies global option completion across all supported shells so built-in global flags stay consistently discoverable.")] + public void When_CompletingGlobalOptionPrefix_Then_GlobalOptionsAreReturnedForAllShells(string shell) { const string line = "repl --no"; var output = Run( @@ -149,7 +185,7 @@ public void When_CompletingGlobalOptionPrefix_Then_GlobalOptionsAreReturned() "completion", "__complete", "--shell", - "powershell", + shell, "--line", line, "--cursor", @@ -161,6 +197,33 @@ public void When_CompletingGlobalOptionPrefix_Then_GlobalOptionsAreReturned() output.Text.Should().Contain("--no-logo"); } + [TestMethod] + [DataRow("bash")] + [DataRow("powershell")] + [DataRow("zsh")] + [DataRow("fish")] + [DataRow("nu")] + [Description("Regression guard: verifies enum option value completion across all supported shells so declared enum values remain discoverable everywhere.")] + public void When_CompletingEnumOptionValue_Then_EnumValuesAreReturnedForAllShells(string shell) + { + const string line = "repl render --mode "; + var output = Run( + [ + "completion", + "__complete", + "--shell", + shell, + "--line", + line, + "--cursor", + line.Length.ToString(CultureInfo.InvariantCulture), + ]); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("Fast"); + output.Text.Should().Contain("Slow"); + } + [TestMethod] [Description("Regression guard: verifies hidden commands are excluded from shell completion suggestions.")] public void When_CommandIsHidden_Then_ShellCompletionDoesNotExposeIt() diff --git a/src/Repl.IntegrationTests/GlobalUsings.cs b/src/Repl.IntegrationTests/GlobalUsings.cs index b021952..156c6e5 100644 --- a/src/Repl.IntegrationTests/GlobalUsings.cs +++ b/src/Repl.IntegrationTests/GlobalUsings.cs @@ -1,2 +1,8 @@ global using AwesomeAssertions; global using Microsoft.VisualStudio.TestTools.UnitTesting; +global using Repl.Autocomplete; +global using Repl.Interaction; +global using Repl.Parameters; +global using Repl.Rendering; +global using Repl.ShellCompletion; +global using Repl.Terminal; diff --git a/src/Repl.ShellCompletionTestHost/Program.cs b/src/Repl.ShellCompletionTestHost/Program.cs index 7ae271c..98152ff 100644 --- a/src/Repl.ShellCompletionTestHost/Program.cs +++ b/src/Repl.ShellCompletionTestHost/Program.cs @@ -1,4 +1,6 @@ using System.Globalization; +using Repl.Parameters; +using Repl.ShellCompletion; namespace Repl.ShellCompletionTestHost; @@ -50,6 +52,10 @@ private static void ConfigureCompletionScenario(ReplApp app) ValueTask.FromResult>([$"{input}A", $"{input}B"])); app.Map("config set", () => "ok"); + app.Map( + "render", + ([ReplOption(Aliases = ["-m"])] CompletionRenderMode mode = CompletionRenderMode.Fast) => + mode.ToString()); app.Map("send", () => "ok"); app.Map("secret ping", () => "ok").Hidden(); app.Map("ping", () => "pong"); @@ -71,6 +77,12 @@ private static void ConfigureCompletionScenario(ReplApp app) }); } + private enum CompletionRenderMode + { + Fast, + Slow, + } + private static void ConfigureShellCompletionOptions(ReplApp app) { app.Options(options => diff --git a/src/Repl.Telnet/GlobalUsings.cs b/src/Repl.Telnet/GlobalUsings.cs new file mode 100644 index 0000000..475997c --- /dev/null +++ b/src/Repl.Telnet/GlobalUsings.cs @@ -0,0 +1 @@ +global using Repl.Rendering; diff --git a/src/Repl.Testing/GlobalUsings.cs b/src/Repl.Testing/GlobalUsings.cs new file mode 100644 index 0000000..8087f8a --- /dev/null +++ b/src/Repl.Testing/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Repl.Interaction; +global using Repl.Terminal; diff --git a/src/Repl.Tests/Given_GlobalOptionParser.cs b/src/Repl.Tests/Given_GlobalOptionParser.cs new file mode 100644 index 0000000..7146b0e --- /dev/null +++ b/src/Repl.Tests/Given_GlobalOptionParser.cs @@ -0,0 +1,76 @@ +using AwesomeAssertions; + +namespace Repl.Tests; + +[TestClass] +public sealed class Given_GlobalOptionParser +{ + [TestMethod] + [Description("Regression guard: verifies registered custom global options are consumed from argv so they do not leak into command token parsing.")] + public void When_CustomGlobalOptionIsRegistered_Then_ParserConsumesItIntoCustomGlobalValues() + { + var parsingOptions = new ParsingOptions(); + parsingOptions.AddGlobalOption("tenant"); + + var parsed = GlobalOptionParser.Parse( + ["users", "list", "--tenant", "acme"], + new OutputOptions(), + parsingOptions); + + parsed.RemainingTokens.Should().Equal("users", "list"); + parsed.CustomGlobalNamedOptions.Should().ContainKey("tenant"); + parsed.CustomGlobalNamedOptions["tenant"].Should().ContainSingle().Which.Should().Be("acme"); + } + + [TestMethod] + [Description("Regression guard: verifies custom global aliases are resolved so short and alternate tokens map to the canonical global option name.")] + public void When_CustomGlobalAliasIsUsed_Then_ValueIsCapturedUnderCanonicalName() + { + var parsingOptions = new ParsingOptions(); + parsingOptions.AddGlobalOption("tenant", aliases: ["-t", "--org"]); + + var parsed = GlobalOptionParser.Parse( + ["users", "list", "-t:acme"], + new OutputOptions(), + parsingOptions); + + parsed.RemainingTokens.Should().Equal("users", "list"); + parsed.CustomGlobalNamedOptions.Should().ContainKey("tenant"); + parsed.CustomGlobalNamedOptions["tenant"].Should().ContainSingle().Which.Should().Be("acme"); + } + + [TestMethod] + [Description("Regression guard: verifies built-in global flags honor case-insensitive mode so --HELP and similar casing variants remain consistent with custom option behavior.")] + public void When_BuiltInGlobalFlagUsesDifferentCaseInInsensitiveMode_Then_FlagIsRecognized() + { + var parsingOptions = new ParsingOptions + { + OptionCaseSensitivity = ReplCaseSensitivity.CaseInsensitive, + }; + + var parsed = GlobalOptionParser.Parse( + ["--HELP"], + new OutputOptions(), + parsingOptions); + + parsed.HelpRequested.Should().BeTrue(); + parsed.RemainingTokens.Should().BeEmpty(); + } + + [TestMethod] + [Description("Regression guard: verifies custom global options can consume signed numeric values so '--tenant -1' is treated as option value instead of leaking to remaining tokens.")] + public void When_CustomGlobalOptionValueIsSignedNumber_Then_ValueIsConsumed() + { + var parsingOptions = new ParsingOptions(); + parsingOptions.AddGlobalOption("tenant"); + + var parsed = GlobalOptionParser.Parse( + ["users", "--tenant", "-1"], + new OutputOptions(), + parsingOptions); + + parsed.RemainingTokens.Should().Equal("users"); + parsed.CustomGlobalNamedOptions.Should().ContainKey("tenant"); + parsed.CustomGlobalNamedOptions["tenant"].Should().ContainSingle().Which.Should().Be("-1"); + } +} diff --git a/src/Repl.Tests/Given_InvocationOptionParser.cs b/src/Repl.Tests/Given_InvocationOptionParser.cs new file mode 100644 index 0000000..5fa85cd --- /dev/null +++ b/src/Repl.Tests/Given_InvocationOptionParser.cs @@ -0,0 +1,263 @@ +using AwesomeAssertions; + +namespace Repl.Tests; + +[TestClass] +public sealed class Given_InvocationOptionParser +{ + [TestMethod] + [Description("Regression guard: verifies strict unknown-option mode emits a parser error so invocation fails fast instead of silently accepting typos.")] + public void When_UnknownOptionAndStrictMode_Then_DiagnosticErrorIsProduced() + { + var parsingOptions = new ParsingOptions + { + AllowUnknownOptions = false, + }; + + var parsed = InvocationOptionParser.Parse( + ["--outpu", "json"], + parsingOptions, + knownOptionNames: ["output"]); + + parsed.HasErrors.Should().BeTrue(); + parsed.Diagnostics.Should().ContainSingle(); + parsed.Diagnostics[0].Severity.Should().Be(ParseDiagnosticSeverity.Error); + parsed.Diagnostics[0].Suggestion.Should().Be("--output"); + parsed.NamedOptions.Should().NotContainKey("outpu"); + } + + [TestMethod] + [Description("Regression guard: verifies permissive unknown-option mode preserves legacy behavior so unknown named options still bind by parameter name.")] + public void When_UnknownOptionAndPermissiveMode_Then_OptionIsStoredWithoutDiagnostic() + { + var parsingOptions = new ParsingOptions + { + AllowUnknownOptions = true, + }; + + var parsed = InvocationOptionParser.Parse( + ["--mystery", "value"], + parsingOptions, + knownOptionNames: ["output"]); + + parsed.HasErrors.Should().BeFalse(); + parsed.NamedOptions.Should().ContainKey("mystery"); + parsed.NamedOptions["mystery"].Should().ContainSingle().Which.Should().Be("value"); + } + + [TestMethod] + [Description("Regression guard: verifies colon value syntax is parsed so command handlers can use --name:value form consistently with equals syntax.")] + public void When_ParsingColonSyntax_Then_OptionValueIsCaptured() + { + var parsingOptions = new ParsingOptions + { + AllowUnknownOptions = false, + }; + + var parsed = InvocationOptionParser.Parse( + ["--output:json"], + parsingOptions, + knownOptionNames: ["output"]); + + parsed.HasErrors.Should().BeFalse(); + parsed.NamedOptions.Should().ContainKey("output"); + parsed.NamedOptions["output"].Should().ContainSingle().Which.Should().Be("json"); + } + + [TestMethod] + [Description("Regression guard: verifies response-file tokens are expanded with quoting and comments so complex CLI invocations stay readable and deterministic.")] + public void When_ResponseFileIsProvided_Then_TokensAreExpanded() + { + var parsingOptions = new ParsingOptions + { + AllowUnknownOptions = false, + AllowResponseFiles = true, + }; + var responseFile = Path.Join(Path.GetTempPath(), $"repl-parser-{Guid.NewGuid():N}.rsp"); + File.WriteAllText( + responseFile, + """ + --output json + # comment line + "two words" + """); + + try + { + var parsed = InvocationOptionParser.Parse( + [$"@{responseFile}"], + parsingOptions, + knownOptionNames: ["output"]); + + parsed.HasErrors.Should().BeFalse(); + parsed.NamedOptions.Should().ContainKey("output"); + parsed.NamedOptions["output"].Should().ContainSingle().Which.Should().Be("json"); + parsed.PositionalArguments.Should().ContainSingle().Which.Should().Be("two words"); + } + finally + { + File.Delete(responseFile); + } + } + + [TestMethod] + [Description("Regression guard: verifies missing response files are surfaced as parser diagnostics so users get actionable feedback on invalid @file inputs.")] + public void When_ResponseFileDoesNotExist_Then_DiagnosticErrorIsProduced() + { + var parsingOptions = new ParsingOptions + { + AllowUnknownOptions = true, + AllowResponseFiles = true, + }; + var missingFile = Path.Join(Path.GetTempPath(), $"repl-parser-missing-{Guid.NewGuid():N}.rsp"); + + var parsed = InvocationOptionParser.Parse( + [$"@{missingFile}"], + parsingOptions, + knownOptionNames: []); + + parsed.HasErrors.Should().BeTrue(); + parsed.Diagnostics.Should().ContainSingle(); + parsed.Diagnostics[0].Message.Should().Contain("Response file"); + } + + [TestMethod] + [Description("Regression guard: verifies an empty inline option name with equals syntax is rejected so malformed '--=value' tokens cannot silently bind.")] + public void When_ParsingEqualsWithEmptyOptionName_Then_DiagnosticErrorIsProduced() + { + var parsingOptions = new ParsingOptions + { + AllowUnknownOptions = false, + }; + + var parsed = InvocationOptionParser.Parse( + ["--=value"], + parsingOptions, + knownOptionNames: ["output"]); + + parsed.HasErrors.Should().BeTrue(); + parsed.Diagnostics.Should().ContainSingle(); + parsed.Diagnostics[0].Message.Should().Contain("Unknown option '--'"); + } + + [TestMethod] + [Description("Regression guard: verifies an empty inline option name with colon syntax is rejected so malformed '--:value' tokens cannot silently bind.")] + public void When_ParsingColonWithEmptyOptionName_Then_DiagnosticErrorIsProduced() + { + var parsingOptions = new ParsingOptions + { + AllowUnknownOptions = false, + }; + + var parsed = InvocationOptionParser.Parse( + ["--:value"], + parsingOptions, + knownOptionNames: ["output"]); + + parsed.HasErrors.Should().BeTrue(); + parsed.Diagnostics.Should().ContainSingle(); + parsed.Diagnostics[0].Message.Should().Contain("Unknown option '--'"); + } + + [TestMethod] + [Description("Regression guard: verifies a standalone '@' token is treated as positional input so non-response-file literals are preserved.")] + public void When_TokenIsStandaloneAtSign_Then_TokenRemainsPositional() + { + var parsed = InvocationOptionParser.Parse( + ["@","plain"], + new ParsingOptions { AllowUnknownOptions = true, AllowResponseFiles = true }, + knownOptionNames: []); + + parsed.HasErrors.Should().BeFalse(); + parsed.PositionalArguments.Should().Equal("@", "plain"); + } + + [TestMethod] + [Description("Regression guard: verifies empty response files expand to no tokens so @file indirection is a no-op when file content is empty.")] + public void When_ResponseFileIsEmpty_Then_NoTokensAreInjected() + { + var parsingOptions = new ParsingOptions + { + AllowUnknownOptions = true, + AllowResponseFiles = true, + }; + var responseFile = Path.Join(Path.GetTempPath(), $"repl-parser-empty-{Guid.NewGuid():N}.rsp"); + File.WriteAllText(responseFile, string.Empty); + + try + { + var parsed = InvocationOptionParser.Parse( + [$"@{responseFile}"], + parsingOptions, + knownOptionNames: []); + + parsed.HasErrors.Should().BeFalse(); + parsed.NamedOptions.Should().BeEmpty(); + parsed.PositionalArguments.Should().BeEmpty(); + } + finally + { + File.Delete(responseFile); + } + } + + [TestMethod] + [Description("Regression guard: verifies UTF-8 BOM response files parse correctly so first token is not polluted by BOM bytes.")] + public void When_ResponseFileHasUtf8Bom_Then_FirstTokenParsesNormally() + { + var parsingOptions = new ParsingOptions + { + AllowUnknownOptions = false, + AllowResponseFiles = true, + }; + var responseFile = Path.Join(Path.GetTempPath(), $"repl-parser-bom-{Guid.NewGuid():N}.rsp"); + var contentBytes = new byte[] { 0xEF, 0xBB, 0xBF } + .Concat(System.Text.Encoding.UTF8.GetBytes("--output json")) + .ToArray(); + File.WriteAllBytes(responseFile, contentBytes); + + try + { + var parsed = InvocationOptionParser.Parse( + [$"@{responseFile}"], + parsingOptions, + knownOptionNames: ["output"]); + + parsed.HasErrors.Should().BeFalse(); + parsed.NamedOptions.Should().ContainKey("output"); + parsed.NamedOptions["output"].Should().ContainSingle().Which.Should().Be("json"); + } + finally + { + File.Delete(responseFile); + } + } + + [TestMethod] + [Description("Regression guard: verifies trailing escape in response files emits a warning so silent token corruption is surfaced to callers.")] + public void When_ResponseFileEndsWithEscape_Then_WarningDiagnosticIsProduced() + { + var parsingOptions = new ParsingOptions + { + AllowUnknownOptions = true, + AllowResponseFiles = true, + }; + var responseFile = Path.Join(Path.GetTempPath(), $"repl-parser-escape-{Guid.NewGuid():N}.rsp"); + File.WriteAllText(responseFile, "value\\"); + + try + { + var parsed = InvocationOptionParser.Parse( + [$"@{responseFile}"], + parsingOptions, + knownOptionNames: []); + + parsed.Diagnostics.Should().ContainSingle(); + parsed.Diagnostics[0].Severity.Should().Be(ParseDiagnosticSeverity.Warning); + } + finally + { + File.Delete(responseFile); + } + } +} diff --git a/src/Repl.Tests/GlobalUsings.cs b/src/Repl.Tests/GlobalUsings.cs index b021952..156c6e5 100644 --- a/src/Repl.Tests/GlobalUsings.cs +++ b/src/Repl.Tests/GlobalUsings.cs @@ -1,2 +1,8 @@ global using AwesomeAssertions; global using Microsoft.VisualStudio.TestTools.UnitTesting; +global using Repl.Autocomplete; +global using Repl.Interaction; +global using Repl.Parameters; +global using Repl.Rendering; +global using Repl.ShellCompletion; +global using Repl.Terminal; diff --git a/src/Repl.WebSocket/GlobalUsings.cs b/src/Repl.WebSocket/GlobalUsings.cs new file mode 100644 index 0000000..30bd207 --- /dev/null +++ b/src/Repl.WebSocket/GlobalUsings.cs @@ -0,0 +1 @@ +global using Repl.Terminal;