diff --git a/.editorconfig b/.editorconfig index f03d4ec..45e2d82 100644 --- a/.editorconfig +++ b/.editorconfig @@ -82,3 +82,31 @@ dotnet_naming_symbols.private_fields.applicable_accessibilities = private dotnet_naming_style.camel_case_underscore.required_prefix = _ dotnet_naming_style.camel_case_underscore.capitalization = camel_case + +# CAxxxx analyzers +dotnet_diagnostic.CA1066.severity = warning +dotnet_diagnostic.CA1068.severity = warning +dotnet_diagnostic.CA1819.severity = suggestion # byte[] in record DTOs is acceptable; ImmutableArray would be too invasive +dotnet_diagnostic.CA1823.severity = warning +dotnet_diagnostic.CA2207.severity = warning + +# IDExxxx analyzers +dotnet_diagnostic.IDE1006.severity = warning + +# Meziantou Analyzers +dotnet_diagnostic.MA0032.severity = suggestion # CancellationToken is good practice but shouldn't block compilation +dotnet_diagnostic.MA0045.severity = warning # Do not use blocking calls in a sync method (need to make calling method async) +dotnet_diagnostic.MA0080.severity = error # Use a cancellation token using .WithCancellation() +dotnet_diagnostic.MA0155.severity = error # No async void +dotnet_diagnostic.MA0162.severity = error # Correctly use Process.Start() +dotnet_diagnostic.MA0167.severity = warning # Force usage of TimeProvider whenever possible +dotnet_diagnostic.MA0168.severity = error # Correctly use readonly structs +dotnet_diagnostic.MA0171.severity = warning # Don't use the ugly .HasValue on nullable types +dotnet_diagnostic.MA0180.severity = warning # Correctly use ILogging types +dotnet_diagnostic.MA0181.severity = none # Explicit casts are idiomatic in low-level serialization/rendering code +dotnet_diagnostic.MA0186.severity = warning # Correctly use [NotNullWhen()] + +# Relax certain analyzers in test projects and samples — intentional sync patterns are common. +[{src/Repl.IntegrationTests/**.cs,src/Repl.McpTests/**.cs,src/Repl.Tests/**.cs,src/Repl.ShellCompletionTestHost/**.cs,samples/**/**.cs}] +dotnet_diagnostic.MA0045.severity = suggestion +dotnet_diagnostic.MA0167.severity = suggestion diff --git a/samples/08-mcp-server/Program.cs b/samples/08-mcp-server/Program.cs index b64b134..b29127b 100644 --- a/samples/08-mcp-server/Program.cs +++ b/samples/08-mcp-server/Program.cs @@ -47,7 +47,7 @@ The email must be unique across all contacts. contact.Map("delete {id:int}", async ([Description("Contact numeric id")] int id, Repl.Interaction.IReplInteractionChannel interaction, CancellationToken ct) => { - if (!await interaction.AskConfirmationAsync("confirm", $"Delete contact {id}?", options: new(ct))) + if (!await interaction.AskConfirmationAsync("confirm", $"Delete contact {id}?", options: new(CancellationToken: ct))) { return Results.Cancelled("Delete cancelled by user."); } diff --git a/src/Repl.Core/AmbientCommandDefinition.cs b/src/Repl.Core/AmbientCommandDefinition.cs index 3fb9234..1806233 100644 --- a/src/Repl.Core/AmbientCommandDefinition.cs +++ b/src/Repl.Core/AmbientCommandDefinition.cs @@ -3,7 +3,7 @@ namespace Repl; /// /// Defines a custom ambient command available in all interactive scopes. /// -public sealed class AmbientCommandDefinition +internal sealed class AmbientCommandDefinition { /// /// Gets the command name (matched case-insensitively). diff --git a/src/Repl.Core/Autocomplete/AutocompleteEngine.cs b/src/Repl.Core/Autocomplete/AutocompleteEngine.cs new file mode 100644 index 0000000..9611499 --- /dev/null +++ b/src/Repl.Core/Autocomplete/AutocompleteEngine.cs @@ -0,0 +1,1241 @@ +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace Repl; + +/// +/// Provides interactive autocomplete resolution for the Repl routing graph. +/// +internal sealed class AutocompleteEngine(CoreReplApp app) +{ + internal const string AutocompleteModeSessionStateKey = "__repl.autocomplete.mode"; + + internal AutocompleteMode ResolveEffectiveAutocompleteMode(IServiceProvider serviceProvider) + { + var sessionState = serviceProvider.GetService(typeof(IReplSessionState)) as IReplSessionState; + if (sessionState?.Get(AutocompleteModeSessionStateKey) is { } overrideText + && Enum.TryParse(overrideText, ignoreCase: true, out var overrideMode)) + { + return overrideMode == AutocompleteMode.Auto + ? ResolveAutoAutocompleteMode(serviceProvider) + : overrideMode; + } + + var configured = app.OptionsSnapshot.Interactive.Autocomplete.Mode; + return configured == AutocompleteMode.Auto + ? ResolveAutoAutocompleteMode(serviceProvider) + : configured; + } + + private static AutocompleteMode ResolveAutoAutocompleteMode(IServiceProvider serviceProvider) + { + if (!ReplSessionIO.IsSessionActive + && !Console.IsInputRedirected + && !Console.IsOutputRedirected) + { + // Local interactive console: prefer rich rendering so menu redraw is in-place. + return AutocompleteMode.Rich; + } + + var info = serviceProvider.GetService(typeof(IReplSessionInfo)) as IReplSessionInfo; + var caps = info?.TerminalCapabilities ?? TerminalCapabilities.None; + if (caps.HasFlag(TerminalCapabilities.Ansi) && caps.HasFlag(TerminalCapabilities.VtInput)) + { + return AutocompleteMode.Rich; + } + + if (caps.HasFlag(TerminalCapabilities.VtInput) || caps.HasFlag(TerminalCapabilities.Ansi)) + { + return AutocompleteMode.Basic; + } + + return AutocompleteMode.Basic; + } + + internal static ConsoleLineReader.AutocompleteRenderMode ResolveAutocompleteRenderMode(AutocompleteMode mode) => + mode switch + { + AutocompleteMode.Rich => ConsoleLineReader.AutocompleteRenderMode.Rich, + AutocompleteMode.Basic => ConsoleLineReader.AutocompleteRenderMode.Basic, + _ => ConsoleLineReader.AutocompleteRenderMode.Off, + }; + + internal ConsoleLineReader.AutocompleteColorStyles ResolveAutocompleteColorStyles(bool enabled) + { + if (!enabled) + { + return ConsoleLineReader.AutocompleteColorStyles.Empty; + } + + var palette = app.OptionsSnapshot.Output.ResolvePalette(); + return new ConsoleLineReader.AutocompleteColorStyles( + CommandStyle: palette.AutocompleteCommandStyle, + ContextStyle: palette.AutocompleteContextStyle, + ParameterStyle: palette.AutocompleteParameterStyle, + AmbiguousStyle: palette.AutocompleteAmbiguousStyle, + ErrorStyle: palette.AutocompleteErrorStyle, + HintLabelStyle: palette.AutocompleteHintLabelStyle); + } + + internal async ValueTask ResolveAutocompleteAsync( + ConsoleLineReader.AutocompleteRequest request, + IReadOnlyList scopeTokens, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + var activeGraph = app.ResolveActiveRoutingGraph(); + var comparer = app.OptionsSnapshot.Interactive.Autocomplete.CaseSensitive + ? StringComparer.Ordinal + : StringComparer.OrdinalIgnoreCase; + var prefixComparison = app.OptionsSnapshot.Interactive.Autocomplete.CaseSensitive + ? StringComparison.Ordinal + : StringComparison.OrdinalIgnoreCase; + var state = ResolveAutocompleteState(request, scopeTokens, prefixComparison, activeGraph); + var matchingRoutes = CollectVisibleMatchingRoutes( + state.CommandPrefix, + prefixComparison, + activeGraph.Routes, + activeGraph.Contexts); + var candidates = await CollectAutocompleteSuggestionsAsync( + matchingRoutes, + state.CommandPrefix, + state.CurrentTokenPrefix, + scopeTokens.Count, + activeGraph, + prefixComparison, + comparer, + serviceProvider, + cancellationToken) + .ConfigureAwait(false); + var liveHint = app.OptionsSnapshot.Interactive.Autocomplete.LiveHintEnabled + ? BuildLiveHint( + matchingRoutes, + candidates, + state.CommandPrefix, + state.CurrentTokenPrefix, + app.OptionsSnapshot.Interactive.Autocomplete.LiveHintMaxAlternatives) + : null; + var discoverableRoutes = app.ResolveDiscoverableRoutes( + activeGraph.Routes, + activeGraph.Contexts, + scopeTokens, + prefixComparison); + var discoverableContexts = app.ResolveDiscoverableContexts( + activeGraph.Contexts, + scopeTokens, + prefixComparison); + var tokenClassifications = BuildTokenClassifications( + request.Input, + scopeTokens, + prefixComparison, + discoverableRoutes, + discoverableContexts); + return new ConsoleLineReader.AutocompleteResult( + state.ReplaceStart, + state.ReplaceLength, + candidates, + liveHint, + tokenClassifications); + } + + private AutocompleteResolutionState ResolveAutocompleteState( + ConsoleLineReader.AutocompleteRequest request, + IReadOnlyList scopeTokens, + StringComparison comparison, + ActiveRoutingGraph activeGraph) + { + var state = AnalyzeAutocompleteInput(request.Input, request.Cursor); + var commandPrefix = scopeTokens.Concat(state.PriorTokens).ToArray(); + var currentTokenPrefix = state.CurrentTokenPrefix; + var replaceStart = state.ReplaceStart; + var replaceLength = state.ReplaceLength; + if (!ShouldAdvanceToNextToken( + commandPrefix, + currentTokenPrefix, + replaceStart, + replaceLength, + request.Cursor, + comparison, + activeGraph.Routes, + activeGraph.Contexts)) + { + return new AutocompleteResolutionState( + commandPrefix, + currentTokenPrefix, + replaceStart, + replaceLength); + } + + return new AutocompleteResolutionState( + commandPrefix.Concat([currentTokenPrefix]).ToArray(), + string.Empty, + request.Cursor, + 0); + } + + private async ValueTask CollectAutocompleteSuggestionsAsync( + IReadOnlyList matchingRoutes, + string[] commandPrefix, + string currentTokenPrefix, + int scopeTokenCount, + ActiveRoutingGraph activeGraph, + StringComparison prefixComparison, + StringComparer comparer, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + var commandCandidates = CollectRouteAutocompleteCandidates( + matchingRoutes, + commandPrefix, + currentTokenPrefix, + prefixComparison); + var dynamicCandidates = await CollectDynamicAutocompleteCandidatesAsync( + matchingRoutes, + commandPrefix, + currentTokenPrefix, + prefixComparison, + app.OptionsSnapshot.Parsing, + serviceProvider, + cancellationToken) + .ConfigureAwait(false); + var contextCandidates = app.OptionsSnapshot.Interactive.Autocomplete.ShowContextAlternatives + ? CollectContextAutocompleteCandidates(commandPrefix, currentTokenPrefix, prefixComparison, activeGraph.Contexts) + : []; + var ambientCandidates = commandPrefix.Length == scopeTokenCount + ? CollectAmbientAutocompleteCandidates(currentTokenPrefix, prefixComparison) + : []; + var ambientContinuationCandidates = CollectAmbientContinuationAutocompleteCandidates( + commandPrefix, + currentTokenPrefix, + scopeTokenCount, + prefixComparison, + activeGraph.Routes, + activeGraph.Contexts); + + var candidates = DeduplicateSuggestions( + commandCandidates + .Concat(dynamicCandidates) + .Concat(contextCandidates) + .Concat(ambientCandidates) + .Concat(ambientContinuationCandidates), + comparer); + if (!app.OptionsSnapshot.Interactive.Autocomplete.ShowInvalidAlternatives + || string.IsNullOrWhiteSpace(currentTokenPrefix) + || candidates.Any(static candidate => candidate.IsSelectable)) + { + return candidates; + } + + return + [ + .. candidates, + new ConsoleLineReader.AutocompleteSuggestion( + currentTokenPrefix, + DisplayText: $"{currentTokenPrefix} (invalid)", + Kind: ConsoleLineReader.AutocompleteSuggestionKind.Invalid, + IsSelectable: false), + ]; + } + + [SuppressMessage( + "Maintainability", + "MA0051:Method is too long", + Justification = "Ambient autocomplete candidates are kept together for discoverability.")] + private List CollectAmbientAutocompleteCandidates( + string currentTokenPrefix, + StringComparison comparison) + { + var suggestions = new List(); + AddAmbientSuggestion( + suggestions, + value: "help", + description: "Show help for current path or a specific path.", + currentTokenPrefix, + comparison); + AddAmbientSuggestion( + suggestions, + value: "?", + description: "Alias for help.", + currentTokenPrefix, + comparison); + AddAmbientSuggestion( + suggestions, + value: "..", + description: "Go up one level in interactive mode.", + currentTokenPrefix, + comparison); + if (app.OptionsSnapshot.AmbientCommands.ExitCommandEnabled) + { + AddAmbientSuggestion( + suggestions, + value: "exit", + description: "Leave interactive mode.", + currentTokenPrefix, + comparison); + } + + if (app.OptionsSnapshot.AmbientCommands.ShowHistoryInHelp) + { + AddAmbientSuggestion( + suggestions, + value: "history", + description: "Show command history.", + currentTokenPrefix, + comparison); + } + + if (app.OptionsSnapshot.AmbientCommands.ShowCompleteInHelp) + { + AddAmbientSuggestion( + suggestions, + value: "complete", + description: "Query completion provider.", + currentTokenPrefix, + comparison); + } + + foreach (var cmd in app.OptionsSnapshot.AmbientCommands.CustomCommands.Values) + { + AddAmbientSuggestion( + suggestions, + value: cmd.Name, + description: cmd.Description ?? string.Empty, + currentTokenPrefix, + comparison); + } + + return suggestions; + } + + private List CollectAmbientContinuationAutocompleteCandidates( + string[] commandPrefix, + string currentTokenPrefix, + int scopeTokenCount, + StringComparison comparison, + IReadOnlyList routes, + IReadOnlyList contexts) + { + if (commandPrefix.Length <= scopeTokenCount) + { + return []; + } + + var ambientToken = commandPrefix[scopeTokenCount]; + if (!CoreReplApp.IsHelpToken(ambientToken)) + { + return []; + } + + var helpPathPrefix = commandPrefix.Skip(scopeTokenCount + 1).ToArray(); + var suggestions = CollectHelpPathAutocompleteCandidates(helpPathPrefix, currentTokenPrefix, comparison, routes, contexts); + if (suggestions.Count > 0 || string.IsNullOrWhiteSpace(currentTokenPrefix)) + { + return suggestions; + } + + // `help ` accepts arbitrary path text; keep it neutral instead of invalid. + suggestions.Add(new ConsoleLineReader.AutocompleteSuggestion( + currentTokenPrefix, + Kind: ConsoleLineReader.AutocompleteSuggestionKind.Parameter)); + return suggestions; + } + + private List CollectHelpPathAutocompleteCandidates( + string[] helpPathPrefix, + string currentTokenPrefix, + StringComparison comparison, + IReadOnlyList routes, + IReadOnlyList contexts) + { + var suggestions = new List(); + var segmentIndex = helpPathPrefix.Length; + foreach (var context in contexts) + { + if (app.IsContextSuppressedForDiscovery(context, helpPathPrefix, comparison)) + { + continue; + } + + if (!MatchesTemplatePrefix(context.Template, helpPathPrefix, comparison, app.OptionsSnapshot.Parsing) + || segmentIndex >= context.Template.Segments.Count) + { + continue; + } + + if (context.Template.Segments[segmentIndex] is LiteralRouteSegment literal + && literal.Value.StartsWith(currentTokenPrefix, comparison)) + { + suggestions.Add(new ConsoleLineReader.AutocompleteSuggestion( + literal.Value, + Description: context.Description, + Kind: ConsoleLineReader.AutocompleteSuggestionKind.Context)); + } + } + + foreach (var route in routes) + { + if (route.Command.IsHidden + || app.IsRouteSuppressedForDiscovery(route.Template, contexts, helpPathPrefix, comparison) + || !MatchesTemplatePrefix(route.Template, helpPathPrefix, comparison, app.OptionsSnapshot.Parsing) + || segmentIndex >= route.Template.Segments.Count) + { + continue; + } + + if (route.Template.Segments[segmentIndex] is LiteralRouteSegment literal + && literal.Value.StartsWith(currentTokenPrefix, comparison)) + { + suggestions.Add(new ConsoleLineReader.AutocompleteSuggestion( + literal.Value, + Description: route.Command.Description, + Kind: ConsoleLineReader.AutocompleteSuggestionKind.Command)); + } + } + + return suggestions; + } + + private static void AddAmbientSuggestion( + List suggestions, + string value, + string description, + string currentTokenPrefix, + StringComparison comparison) + { + if (!value.StartsWith(currentTokenPrefix, comparison)) + { + return; + } + + suggestions.Add(new ConsoleLineReader.AutocompleteSuggestion( + value, + Description: description, + Kind: ConsoleLineReader.AutocompleteSuggestionKind.Command)); + } + + [SuppressMessage( + "Maintainability", + "MA0051:Method is too long", + Justification = "Token advancement logic keeps route/context suppression checks centralized.")] + private bool ShouldAdvanceToNextToken( + string[] commandPrefix, + string currentTokenPrefix, + int replaceStart, + int replaceLength, + int cursor, + StringComparison comparison, + IReadOnlyList routes, + IReadOnlyList contexts) + { + if (string.IsNullOrEmpty(currentTokenPrefix) || cursor != replaceStart + replaceLength) + { + return false; + } + + var segmentIndex = commandPrefix.Length; + var hasLiteralMatch = false; + var hasDynamicOrContextMatch = false; + foreach (var route in routes) + { + if (route.Command.IsHidden + || app.IsRouteSuppressedForDiscovery(route.Template, contexts, commandPrefix, comparison) + || segmentIndex >= route.Template.Segments.Count) + { + continue; + } + + if (!MatchesRoutePrefix(route.Template, commandPrefix, comparison, app.OptionsSnapshot.Parsing)) + { + continue; + } + + if (route.Template.Segments[segmentIndex] is LiteralRouteSegment literal + && string.Equals(literal.Value, currentTokenPrefix, comparison)) + { + hasLiteralMatch = true; + continue; + } + + if (route.Template.Segments[segmentIndex] is DynamicRouteSegment dynamic + && RouteConstraintEvaluator.IsMatch(dynamic, currentTokenPrefix, app.OptionsSnapshot.Parsing)) + { + hasDynamicOrContextMatch = true; + } + } + + foreach (var context in contexts) + { + if (app.IsContextSuppressedForDiscovery(context, commandPrefix, comparison)) + { + continue; + } + + if (segmentIndex >= context.Template.Segments.Count + || !MatchesContextPrefix(context.Template, commandPrefix, comparison, app.OptionsSnapshot.Parsing)) + { + continue; + } + + var segment = context.Template.Segments[segmentIndex]; + if (segment is LiteralRouteSegment literal + && string.Equals(literal.Value, currentTokenPrefix, comparison)) + { + hasDynamicOrContextMatch = true; + continue; + } + + if (segment is DynamicRouteSegment dynamic + && RouteConstraintEvaluator.IsMatch(dynamic, currentTokenPrefix, app.OptionsSnapshot.Parsing)) + { + hasDynamicOrContextMatch = true; + } + } + + return hasLiteralMatch && !hasDynamicOrContextMatch; + } + + private static List CollectRouteAutocompleteCandidates( + IReadOnlyList matchingRoutes, + string[] commandPrefix, + string currentTokenPrefix, + StringComparison prefixComparison) + { + var candidates = new List(); + foreach (var route in matchingRoutes) + { + if (commandPrefix.Length < route.Template.Segments.Count + && route.Template.Segments[commandPrefix.Length] is LiteralRouteSegment literal + && literal.Value.StartsWith(currentTokenPrefix, prefixComparison)) + { + candidates.Add(new ConsoleLineReader.AutocompleteSuggestion( + literal.Value, + Description: route.Command.Description, + Kind: ConsoleLineReader.AutocompleteSuggestionKind.Command)); + } + } + + return candidates; + } + + private List CollectContextAutocompleteCandidates( + string[] commandPrefix, + string currentTokenPrefix, + StringComparison comparison, + IReadOnlyList contexts) + { + var suggestions = new List(); + var segmentIndex = commandPrefix.Length; + foreach (var context in contexts) + { + if (app.IsContextSuppressedForDiscovery(context, commandPrefix, comparison)) + { + continue; + } + + if (!MatchesContextPrefix(context.Template, commandPrefix, comparison, app.OptionsSnapshot.Parsing)) + { + continue; + } + + if (segmentIndex >= context.Template.Segments.Count) + { + continue; + } + + var segment = context.Template.Segments[segmentIndex]; + if (segment is LiteralRouteSegment literal) + { + AddContextLiteralCandidate(suggestions, literal, currentTokenPrefix, comparison); + continue; + } + + AddContextDynamicCandidate( + suggestions, + (DynamicRouteSegment)segment, + commandPrefix, + currentTokenPrefix); + } + + return suggestions + .OrderBy(static suggestion => suggestion.DisplayText, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static void AddContextLiteralCandidate( + List suggestions, + LiteralRouteSegment literal, + string currentTokenPrefix, + StringComparison comparison) + { + if (!literal.Value.StartsWith(currentTokenPrefix, comparison)) + { + return; + } + + suggestions.Add(new ConsoleLineReader.AutocompleteSuggestion( + literal.Value, + DisplayText: literal.Value, + Kind: ConsoleLineReader.AutocompleteSuggestionKind.Context)); + } + + private void AddContextDynamicCandidate( + List suggestions, + DynamicRouteSegment dynamic, + IReadOnlyList commandPrefix, + string currentTokenPrefix) + { + var placeholderValue = $"{{{dynamic.Name}}}"; + if (string.IsNullOrWhiteSpace(currentTokenPrefix)) + { + suggestions.Add(new ConsoleLineReader.AutocompleteSuggestion( + Value: string.Empty, + DisplayText: placeholderValue, + Description: $"Context [{BuildContextTargetPath(commandPrefix, placeholderValue)}]", + Kind: ConsoleLineReader.AutocompleteSuggestionKind.Context, + IsSelectable: false)); + return; + } + + if (RouteConstraintEvaluator.IsMatch(dynamic, currentTokenPrefix, app.OptionsSnapshot.Parsing)) + { + suggestions.Add(new ConsoleLineReader.AutocompleteSuggestion( + currentTokenPrefix, + DisplayText: placeholderValue, + Description: $"Context [{BuildContextTargetPath(commandPrefix, currentTokenPrefix)}]", + Kind: ConsoleLineReader.AutocompleteSuggestionKind.Context)); + return; + } + + if (!app.OptionsSnapshot.Interactive.Autocomplete.ShowInvalidAlternatives) + { + return; + } + + suggestions.Add(new ConsoleLineReader.AutocompleteSuggestion( + currentTokenPrefix, + DisplayText: $"{currentTokenPrefix} -> [invalid]", + Kind: ConsoleLineReader.AutocompleteSuggestionKind.Invalid, + IsSelectable: false)); + } + + private static string BuildContextTargetPath(IReadOnlyList commandPrefix, string value) + { + var tokens = commandPrefix.Concat([value]).ToArray(); + return string.Join('/', tokens); + } + + internal List CollectVisibleMatchingRoutes( + string[] commandPrefix, + StringComparison prefixComparison, + IReadOnlyList routes, + IReadOnlyList contexts) + { + var matches = routes + .Where(route => + !route.Command.IsHidden + && !app.IsRouteSuppressedForDiscovery(route.Template, contexts, commandPrefix, prefixComparison) + && MatchesRoutePrefix(route.Template, commandPrefix, prefixComparison, app.OptionsSnapshot.Parsing)) + .ToList(); + if (commandPrefix.Length == 0) + { + return matches; + } + + var literalMatches = matches + .Where(route => MatchesLiteralPrefix(route.Template, commandPrefix, prefixComparison)) + .ToList(); + return literalMatches.Count > 0 ? literalMatches : matches; + } + + private static bool MatchesLiteralPrefix( + RouteTemplate template, + string[] prefixTokens, + StringComparison comparison) + { + if (prefixTokens.Length > template.Segments.Count) + { + return false; + } + + for (var i = 0; i < prefixTokens.Length; i++) + { + if (template.Segments[i] is not LiteralRouteSegment literal + || !string.Equals(literal.Value, prefixTokens[i], comparison)) + { + return false; + } + } + + return true; + } + + private static string? BuildLiveHint( + IReadOnlyList matchingRoutes, + IReadOnlyList suggestions, + string[] commandPrefix, + string currentTokenPrefix, + int maxAlternatives) + { + if (IsGlobalOptionToken(currentTokenPrefix)) + { + return null; + } + + var selectable = suggestions.Where(static suggestion => suggestion.IsSelectable).ToArray(); + var hintAlternatives = suggestions + .Where(static suggestion => + suggestion.IsSelectable + || suggestion is + { + IsSelectable: false, + Kind: ConsoleLineReader.AutocompleteSuggestionKind.Context, + }) + .ToArray(); + if (selectable.Length == 0) + { + return BuildDynamicHint(matchingRoutes, commandPrefix.Length, maxAlternatives) + ?? (string.IsNullOrWhiteSpace(currentTokenPrefix) ? null : $"Invalid: {currentTokenPrefix}"); + } + + var segmentIndex = commandPrefix.Length; + if (TryBuildParameterHint(matchingRoutes, segmentIndex, out var parameterHint) + && selectable.All(static suggestion => + suggestion.Kind is ConsoleLineReader.AutocompleteSuggestionKind.Parameter + or ConsoleLineReader.AutocompleteSuggestionKind.Invalid)) + { + return parameterHint; + } + + if (selectable.Length == 1) + { + var suggestion = selectable[0]; + if (suggestion.Kind == ConsoleLineReader.AutocompleteSuggestionKind.Command) + { + return string.IsNullOrWhiteSpace(suggestion.Description) + ? $"Command: {suggestion.DisplayText}" + : $"Command: {suggestion.DisplayText} - {suggestion.Description}"; + } + + if (suggestion.Kind == ConsoleLineReader.AutocompleteSuggestionKind.Context) + { + return $"Context: {suggestion.DisplayText}"; + } + + return suggestion.DisplayText; + } + + maxAlternatives = Math.Max(1, maxAlternatives); + var shown = hintAlternatives + .Select(static suggestion => suggestion.DisplayText) + .Take(maxAlternatives) + .ToArray(); + var suffix = hintAlternatives.Length > shown.Length + ? $" (+{hintAlternatives.Length - shown.Length})" + : string.Empty; + return $"Matches: {string.Join(", ", shown)}{suffix}"; + } + + private static string? BuildDynamicHint( + IReadOnlyList matchingRoutes, + int segmentIndex, + int maxAlternatives) + { + if (TryBuildParameterHint(matchingRoutes, segmentIndex, out var parameterHint)) + { + return parameterHint; + } + + var dynamicRoutes = matchingRoutes + .Where(route => + segmentIndex < route.Template.Segments.Count + && route.Template.Segments[segmentIndex] is DynamicRouteSegment) + .Select(route => route.Template.Template) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (dynamicRoutes.Length <= 1) + { + return null; + } + + maxAlternatives = Math.Max(1, maxAlternatives); + var shown = dynamicRoutes.Take(maxAlternatives).ToArray(); + var suffix = dynamicRoutes.Length > shown.Length + ? $" (+{dynamicRoutes.Length - shown.Length})" + : string.Empty; + return $"Overloads: {string.Join(", ", shown)}{suffix}"; + } + + private static bool TryBuildParameterHint( + IReadOnlyList matchingRoutes, + int segmentIndex, + out string hint) + { + hint = string.Empty; + var dynamicRoutes = matchingRoutes + .Where(route => + segmentIndex < route.Template.Segments.Count + && route.Template.Segments[segmentIndex] is DynamicRouteSegment) + .ToArray(); + if (dynamicRoutes.Length == 0) + { + return false; + } + + if (dynamicRoutes.Length == 1 + && dynamicRoutes[0].Template.Segments[segmentIndex] is DynamicRouteSegment singleDynamic) + { + var description = TryGetRouteParameterDescription(dynamicRoutes[0], singleDynamic.Name); + hint = string.IsNullOrWhiteSpace(description) + ? $"Param {singleDynamic.Name}" + : $"Param {singleDynamic.Name}: {description}"; + return true; + } + + return false; + } + + private static string? TryGetRouteParameterDescription(RouteDefinition route, string parameterName) + { + var parameter = route.Command.Handler.Method + .GetParameters() + .FirstOrDefault(parameter => + !string.IsNullOrWhiteSpace(parameter.Name) + && string.Equals(parameter.Name, parameterName, StringComparison.OrdinalIgnoreCase)); + return parameter?.GetCustomAttribute()?.Description; + } + + private static async ValueTask> CollectDynamicAutocompleteCandidatesAsync( + IReadOnlyList matchingRoutes, + string[] commandPrefix, + string currentTokenPrefix, + StringComparison prefixComparison, + ParsingOptions parsingOptions, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + var exactRoute = matchingRoutes.FirstOrDefault(route => + route.Template.Segments.Count == commandPrefix.Length + && MatchesTemplatePrefix( + route.Template, + commandPrefix, + prefixComparison, + parsingOptions)); + if (exactRoute is null || exactRoute.Command.Completions.Count != 1) + { + return []; + } + + var completion = exactRoute.Command.Completions.Values.Single(); + var completionContext = new CompletionContext(serviceProvider); + var provided = await completion(completionContext, currentTokenPrefix, cancellationToken) + .ConfigureAwait(false); + return provided + .Where(static item => !string.IsNullOrWhiteSpace(item)) + .Select(static item => new ConsoleLineReader.AutocompleteSuggestion( + item, + Kind: ConsoleLineReader.AutocompleteSuggestionKind.Parameter)) + .ToArray(); + } + + private static ConsoleLineReader.AutocompleteSuggestion[] DeduplicateSuggestions( + IEnumerable suggestions, + StringComparer comparer) + { + var seen = new HashSet(comparer); + var distinct = new List(); + foreach (var suggestion in suggestions) + { + if (string.IsNullOrWhiteSpace(suggestion.DisplayText) || !seen.Add(suggestion.DisplayText)) + { + continue; + } + + distinct.Add(suggestion); + } + + return [.. distinct]; + } + + private List BuildTokenClassifications( + string input, + IReadOnlyList scopeTokens, + StringComparison comparison, + IReadOnlyList routes, + IReadOnlyList contexts) + { + if (string.IsNullOrWhiteSpace(input)) + { + return []; + } + + var tokenSpans = TokenizeInputSpans(input); + if (tokenSpans.Count == 0) + { + return []; + } + + var output = new List(tokenSpans.Count); + for (var i = 0; i < tokenSpans.Count; i++) + { + if (IsGlobalOptionToken(tokenSpans[i].Value)) + { + output.Add(new ConsoleLineReader.TokenClassification( + tokenSpans[i].Start, + tokenSpans[i].End - tokenSpans[i].Start, + ConsoleLineReader.AutocompleteSuggestionKind.Parameter)); + continue; + } + + var prefix = scopeTokens.Concat( + tokenSpans.Take(i) + .Where(static token => !IsGlobalOptionToken(token.Value)) + .Select(static token => token.Value)).ToArray(); + var kind = ClassifyToken( + prefix, + tokenSpans[i].Value, + comparison, + routes, + contexts, + scopeTokenCount: scopeTokens.Count, + isFirstInputToken: i == 0); + output.Add(new ConsoleLineReader.TokenClassification( + tokenSpans[i].Start, + tokenSpans[i].End - tokenSpans[i].Start, + kind)); + } + + return output; + } + + internal static bool IsGlobalOptionToken(string token) => + token.StartsWith("--", StringComparison.Ordinal) && token.Length >= 2; + + [SuppressMessage( + "Maintainability", + "MA0051:Method is too long", + Justification = "Token classification intentionally keeps full precedence rules in one place.")] + private ConsoleLineReader.AutocompleteSuggestionKind ClassifyToken( + string[] prefixTokens, + string token, + StringComparison comparison, + IReadOnlyList routes, + IReadOnlyList contexts, + int scopeTokenCount, + bool isFirstInputToken) + { + if (isFirstInputToken && HasAmbientCommandPrefix(token, comparison)) + { + return ConsoleLineReader.AutocompleteSuggestionKind.Command; + } + + if (TryClassifyAmbientContinuation(prefixTokens, scopeTokenCount, out var ambientKind)) + { + return ambientKind; + } + + var routeLiteralMatch = false; + var routeDynamicMatch = false; + foreach (var route in routes) + { + if (route.Command.IsHidden + || app.IsRouteSuppressedForDiscovery(route.Template, contexts, prefixTokens, comparison) + || !TryClassifyTemplateSegment( + route.Template, + prefixTokens, + token, + comparison, + app.OptionsSnapshot.Parsing, + out var routeKind)) + { + continue; + } + + routeLiteralMatch |= routeKind == ConsoleLineReader.AutocompleteSuggestionKind.Command; + routeDynamicMatch |= routeKind == ConsoleLineReader.AutocompleteSuggestionKind.Parameter; + } + + var contextMatch = contexts.Any(context => + !app.IsContextSuppressedForDiscovery(context, prefixTokens, comparison) + && + TryClassifyTemplateSegment( + context.Template, + prefixTokens, + token, + comparison, + app.OptionsSnapshot.Parsing, + out _)); + if ((routeLiteralMatch || routeDynamicMatch) && contextMatch) + { + return ConsoleLineReader.AutocompleteSuggestionKind.Ambiguous; + } + + if (contextMatch) + { + return ConsoleLineReader.AutocompleteSuggestionKind.Context; + } + + if (routeLiteralMatch) + { + return ConsoleLineReader.AutocompleteSuggestionKind.Command; + } + + if (routeDynamicMatch) + { + return ConsoleLineReader.AutocompleteSuggestionKind.Parameter; + } + + return ConsoleLineReader.AutocompleteSuggestionKind.Invalid; + } + + private static bool TryClassifyAmbientContinuation( + string[] prefixTokens, + int scopeTokenCount, + out ConsoleLineReader.AutocompleteSuggestionKind kind) + { + kind = ConsoleLineReader.AutocompleteSuggestionKind.Invalid; + if (prefixTokens.Length <= scopeTokenCount) + { + return false; + } + + var ambientToken = prefixTokens[scopeTokenCount]; + if (CoreReplApp.IsHelpToken(ambientToken) + || string.Equals(ambientToken, "history", StringComparison.OrdinalIgnoreCase) + || string.Equals(ambientToken, "complete", StringComparison.OrdinalIgnoreCase)) + { + kind = ConsoleLineReader.AutocompleteSuggestionKind.Parameter; + return true; + } + + if (!string.Equals(ambientToken, "autocomplete", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + kind = prefixTokens.Length == scopeTokenCount + 1 + ? ConsoleLineReader.AutocompleteSuggestionKind.Command + : ConsoleLineReader.AutocompleteSuggestionKind.Parameter; + return true; + } + + private bool HasAmbientCommandPrefix(string token, StringComparison comparison) + { + if (string.IsNullOrWhiteSpace(token)) + { + return false; + } + + if ("help".StartsWith(token, comparison) || "?".StartsWith(token, comparison)) + { + return true; + } + + if ("..".StartsWith(token, comparison)) + { + return true; + } + + if (app.OptionsSnapshot.AmbientCommands.ExitCommandEnabled && "exit".StartsWith(token, comparison)) + { + return true; + } + + if (app.OptionsSnapshot.AmbientCommands.ShowHistoryInHelp && "history".StartsWith(token, comparison)) + { + return true; + } + + if (app.OptionsSnapshot.AmbientCommands.ShowCompleteInHelp && "complete".StartsWith(token, comparison)) + { + return true; + } + + return app.OptionsSnapshot.AmbientCommands.CustomCommands.Keys + .Any(name => name.StartsWith(token, comparison)); + } + + private static bool TryClassifyTemplateSegment( + RouteTemplate template, + string[] prefixTokens, + string token, + StringComparison comparison, + ParsingOptions parsingOptions, + out ConsoleLineReader.AutocompleteSuggestionKind kind) + { + kind = ConsoleLineReader.AutocompleteSuggestionKind.Invalid; + if (!MatchesTemplatePrefix(template, prefixTokens, comparison, parsingOptions) + || prefixTokens.Length >= template.Segments.Count) + { + return false; + } + + var segment = template.Segments[prefixTokens.Length]; + if (segment is LiteralRouteSegment literal && literal.Value.StartsWith(token, comparison)) + { + kind = ConsoleLineReader.AutocompleteSuggestionKind.Command; + return true; + } + + if (segment is DynamicRouteSegment dynamic + && RouteConstraintEvaluator.IsMatch(dynamic, token, parsingOptions)) + { + kind = ConsoleLineReader.AutocompleteSuggestionKind.Parameter; + return true; + } + + return false; + } + + private static bool MatchesRoutePrefix( + RouteTemplate template, + string[] prefixTokens, + StringComparison comparison, + ParsingOptions parsingOptions) + { + return MatchesTemplatePrefix(template, prefixTokens, comparison, parsingOptions); + } + + private static bool MatchesContextPrefix( + RouteTemplate template, + string[] prefixTokens, + StringComparison comparison, + ParsingOptions parsingOptions) + { + return MatchesTemplatePrefix(template, prefixTokens, comparison, parsingOptions); + } + + private static bool MatchesTemplatePrefix( + RouteTemplate template, + string[] prefixTokens, + StringComparison comparison, + ParsingOptions parsingOptions) + { + if (prefixTokens.Length > template.Segments.Count) + { + return false; + } + + for (var i = 0; i < prefixTokens.Length; i++) + { + var token = prefixTokens[i]; + var segment = template.Segments[i]; + switch (segment) + { + case LiteralRouteSegment literal + when !string.Equals(literal.Value, token, comparison): + return false; + case DynamicRouteSegment dynamic + when !RouteConstraintEvaluator.IsMatch(dynamic, token, parsingOptions): + return false; + } + } + + return true; + } + + internal static List TokenizeInputSpans(string input) + { + var tokens = new List(); + var index = 0; + while (index < input.Length) + { + while (index < input.Length && char.IsWhiteSpace(input[index])) + { + index++; + } + + if (index >= input.Length) + { + break; + } + + var start = index; + var value = new System.Text.StringBuilder(); + char? quote = null; + + if (input[index] is '"' or '\'') + { + quote = input[index]; + index++; // skip opening quote + } + + while (index < input.Length) + { + if (quote is not null && input[index] == quote.Value) + { + index++; // skip closing quote + break; + } + + if (quote is null && char.IsWhiteSpace(input[index])) + { + break; + } + + if (quote is null && input[index] is '"' or '\'') + { + quote = input[index]; + index++; // skip opening quote mid-token + continue; + } + + value.Append(input[index]); + index++; + } + + tokens.Add(new TokenSpan(value.ToString(), start, index)); + } + + return tokens; + } + + private static AutocompleteInputState AnalyzeAutocompleteInput(string input, int cursor) + { + input ??= string.Empty; + cursor = Math.Clamp(cursor, 0, input.Length); + var tokens = TokenizeInputSpans(input); + + for (var i = 0; i < tokens.Count; i++) + { + var token = tokens[i]; + if (cursor < token.Start || cursor > token.End) + { + continue; + } + + var prefix = input[token.Start..cursor]; + var prior = tokens.Take(i) + .Where(static tokenSpan => !IsGlobalOptionToken(tokenSpan.Value)) + .Select(static tokenSpan => tokenSpan.Value).ToArray(); + return new AutocompleteInputState( + prior, + prefix, + token.Start, + token.End - token.Start); + } + + var trailingPrior = tokens + .Where(token => token.End <= cursor && !IsGlobalOptionToken(token.Value)) + .Select(static token => token.Value).ToArray(); + return new AutocompleteInputState( + trailingPrior, + CurrentTokenPrefix: string.Empty, + ReplaceStart: cursor, + ReplaceLength: 0); + } + + private readonly record struct AutocompleteInputState( + string[] PriorTokens, + string CurrentTokenPrefix, + int ReplaceStart, + int ReplaceLength); + + private readonly record struct AutocompleteResolutionState( + string[] CommandPrefix, + string CurrentTokenPrefix, + int ReplaceStart, + int ReplaceLength); + + internal readonly record struct TokenSpan(string Value, int Start, int End); +} diff --git a/src/Repl.Core/Autocomplete/Public/AutocompleteMode.cs b/src/Repl.Core/Autocomplete/AutocompleteMode.cs similarity index 100% rename from src/Repl.Core/Autocomplete/Public/AutocompleteMode.cs rename to src/Repl.Core/Autocomplete/AutocompleteMode.cs diff --git a/src/Repl.Core/Autocomplete/Public/AutocompleteOptions.cs b/src/Repl.Core/Autocomplete/AutocompleteOptions.cs similarity index 100% rename from src/Repl.Core/Autocomplete/Public/AutocompleteOptions.cs rename to src/Repl.Core/Autocomplete/AutocompleteOptions.cs diff --git a/src/Repl.Core/Autocomplete/Public/AutocompletePresentation.cs b/src/Repl.Core/Autocomplete/AutocompletePresentation.cs similarity index 100% rename from src/Repl.Core/Autocomplete/Public/AutocompletePresentation.cs rename to src/Repl.Core/Autocomplete/AutocompletePresentation.cs diff --git a/src/Repl.Core/CompletionContext.cs b/src/Repl.Core/Autocomplete/CompletionContext.cs similarity index 100% rename from src/Repl.Core/CompletionContext.cs rename to src/Repl.Core/Autocomplete/CompletionContext.cs diff --git a/src/Repl.Core/CompletionDelegate.cs b/src/Repl.Core/Autocomplete/CompletionDelegate.cs similarity index 100% rename from src/Repl.Core/CompletionDelegate.cs rename to src/Repl.Core/Autocomplete/CompletionDelegate.cs diff --git a/src/Repl.Core/CancelKeyHandler.cs b/src/Repl.Core/Console/CancelKeyHandler.cs similarity index 100% rename from src/Repl.Core/CancelKeyHandler.cs rename to src/Repl.Core/Console/CancelKeyHandler.cs diff --git a/src/Repl.Core/ConsoleInputGate.cs b/src/Repl.Core/Console/ConsoleInputGate.cs similarity index 100% rename from src/Repl.Core/ConsoleInputGate.cs rename to src/Repl.Core/Console/ConsoleInputGate.cs diff --git a/src/Repl.Core/ConsoleInteractionChannel.ConsoleIO.cs b/src/Repl.Core/Console/ConsoleInteractionChannel.ConsoleIO.cs similarity index 88% rename from src/Repl.Core/ConsoleInteractionChannel.ConsoleIO.cs rename to src/Repl.Core/Console/ConsoleInteractionChannel.ConsoleIO.cs index e51461d..e2ebc09 100644 --- a/src/Repl.Core/ConsoleInteractionChannel.ConsoleIO.cs +++ b/src/Repl.Core/Console/ConsoleInteractionChannel.ConsoleIO.cs @@ -17,7 +17,9 @@ internal sealed partial class ConsoleInteractionChannel private static string? ReadSecretSync(char? mask, CancellationToken ct) { +#pragma warning disable MA0045 // Intentionally synchronous — called via Task.Run from ReadSecretLineAsync ConsoleInputGate.Gate.Wait(ct); +#pragma warning restore MA0045 try { return ReadSecretCore(mask, ct); @@ -35,7 +37,9 @@ internal sealed partial class ConsoleInteractionChannel { if (!Console.KeyAvailable) { +#pragma warning disable MA0045 // Intentionally synchronous — called via Task.Run from ReadSecretLineAsync Thread.Sleep(15); +#pragma warning restore MA0045 continue; } @@ -58,8 +62,10 @@ internal sealed partial class ConsoleInteractionChannel using var timer = _timeProvider.CreateTimer( callback: static state => { +#pragma warning disable MA0045 // Timer callback is non-async; Cancel is the only option here try { ((CancellationTokenSource)state!).Cancel(); } catch (ObjectDisposedException) { /* CTS disposed before timer fired. */ } +#pragma warning restore MA0045 }, state: timeoutCts, dueTime: timeout, period: Timeout.InfiniteTimeSpan); @@ -82,7 +88,9 @@ internal sealed partial class ConsoleInteractionChannel CancellationToken timeoutCt, CancellationToken externalCt) { +#pragma warning disable MA0045 // Intentionally synchronous — called via Task.Run from ReadSecretWithCountdownAsync ConsoleInputGate.Gate.Wait(externalCt); +#pragma warning restore MA0045 try { return ReadSecretWithCountdownCore(timeout, mask, timeoutCt, externalCt); @@ -130,7 +138,9 @@ internal sealed partial class ConsoleInteractionChannel continue; } +#pragma warning disable MA0045 // Intentionally synchronous — called via Task.Run from ReadSecretWithCountdownAsync Thread.Sleep(15); +#pragma warning restore MA0045 if (!userTyping && remaining > 0) { @@ -217,7 +227,7 @@ await _presenter.PresentAsync( if (Console.IsInputRedirected || ReplSessionIO.IsSessionActive) { - return await ReadWithTimeoutRedirectedAsync(cancellationToken, timeout.Value) + return await ReadWithTimeoutRedirectedAsync(timeout.Value, cancellationToken) .ConfigureAwait(false); } @@ -226,15 +236,17 @@ await _presenter.PresentAsync( } private async ValueTask ReadWithTimeoutRedirectedAsync( - CancellationToken cancellationToken, - TimeSpan timeout) + TimeSpan timeout, + CancellationToken cancellationToken) { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); using var _ = _timeProvider.CreateTimer( static state => { +#pragma warning disable MA0045 // Timer callback is non-async; Cancel is the only option here try { ((CancellationTokenSource)state!).Cancel(); } catch (ObjectDisposedException) { /* CTS disposed before timer fired. */ } +#pragma warning restore MA0045 }, timeoutCts, timeout, Timeout.InfiniteTimeSpan); try @@ -256,8 +268,10 @@ await _presenter.PresentAsync( using var _ = _timeProvider.CreateTimer( static state => { +#pragma warning disable MA0045 // Timer callback is non-async; Cancel is the only option here try { ((CancellationTokenSource)state!).Cancel(); } catch (ObjectDisposedException) { /* CTS disposed before timer fired. */ } +#pragma warning restore MA0045 }, timeoutCts, timeout, Timeout.InfiniteTimeSpan); @@ -285,7 +299,9 @@ private static ConsoleLineReader.ReadResult ReadLineWithCountdownSync( CancellationToken timeoutCt, CancellationToken externalCt) { +#pragma warning disable MA0045 // Intentionally synchronous — called via Task.Run from ReadLineWithCountdownAsync ConsoleInputGate.Gate.Wait(externalCt); +#pragma warning restore MA0045 try { return ReadLineWithCountdownCore(timeout, defaultLabel, timeoutCt, externalCt); @@ -333,7 +349,9 @@ private static ConsoleLineReader.ReadResult ReadLineWithCountdownCore( continue; } +#pragma warning disable MA0045 // Intentionally synchronous — called via Task.Run from ReadLineWithCountdownAsync Thread.Sleep(15); +#pragma warning restore MA0045 if (!userTyping && remaining > 0) { diff --git a/src/Repl.Core/ConsoleInteractionChannel.cs b/src/Repl.Core/Console/ConsoleInteractionChannel.cs similarity index 98% rename from src/Repl.Core/ConsoleInteractionChannel.cs rename to src/Repl.Core/Console/ConsoleInteractionChannel.cs index db2514a..a934142 100644 --- a/src/Repl.Core/ConsoleInteractionChannel.cs +++ b/src/Repl.Core/Console/ConsoleInteractionChannel.cs @@ -136,13 +136,13 @@ public async ValueTask AskChoiceAsync( return (int)dispatched.Value!; } - return await ReadChoiceTextFallbackAsync(name, prompt, choices, effectiveDefaultIndex, effectiveCt, options?.Timeout) + return await ReadChoiceTextFallbackAsync(name, prompt, choices, effectiveDefaultIndex, options?.Timeout, effectiveCt) .ConfigureAwait(false); } private async ValueTask ReadChoiceTextFallbackAsync( string name, string prompt, IReadOnlyList choices, - int effectiveDefaultIndex, CancellationToken ct, TimeSpan? timeout) + int effectiveDefaultIndex, TimeSpan? timeout, CancellationToken ct) { var shortcuts = MnemonicParser.AssignShortcuts(choices); var parsedChoices = new (string Display, char? Shortcut)[choices.Count]; @@ -395,7 +395,7 @@ await _presenter.PresentAsync( { if (Console.IsInputRedirected || ReplSessionIO.IsSessionActive) { - line = await ReadWithTimeoutRedirectedAsync(ct, options.Timeout.Value) + line = await ReadWithTimeoutRedirectedAsync(options.Timeout.Value, ct) .ConfigureAwait(false); } else @@ -480,7 +480,7 @@ public async ValueTask> AskMultiChoiceAsync( return await ReadMultiChoiceTextFallbackAsync( name, prompt, choices, effectiveDefaults, choiceDisplay, defaultLabel, - minSelections, maxSelections, effectiveCt, options?.Timeout).ConfigureAwait(false); + minSelections, maxSelections, options?.Timeout, effectiveCt).ConfigureAwait(false); } private static void ValidateMultiChoiceArgs( @@ -522,7 +522,7 @@ private static string FormatMultiChoiceDisplay(IReadOnlyList choices, IR private async ValueTask> ReadMultiChoiceTextFallbackAsync( string name, string prompt, IReadOnlyList choices, IReadOnlyList effectiveDefaults, string choiceDisplay, string? defaultLabel, - int minSelections, int? maxSelections, CancellationToken ct, TimeSpan? timeout) + int minSelections, int? maxSelections, TimeSpan? timeout, CancellationToken ct) { while (true) { diff --git a/src/Repl.Core/ConsoleKeyReader.cs b/src/Repl.Core/Console/ConsoleKeyReader.cs similarity index 81% rename from src/Repl.Core/ConsoleKeyReader.cs rename to src/Repl.Core/Console/ConsoleKeyReader.cs index 4109665..c584850 100644 --- a/src/Repl.Core/ConsoleKeyReader.cs +++ b/src/Repl.Core/Console/ConsoleKeyReader.cs @@ -24,7 +24,9 @@ public async ValueTask ReadKeyAsync(CancellationToken ct) private static ConsoleKeyInfo ReadKeySync(CancellationToken ct) { +#pragma warning disable MA0045 // Intentionally synchronous — called via Task.Run from ReadKeyAsync ConsoleInputGate.Gate.Wait(ct); +#pragma warning restore MA0045 try { while (true) @@ -36,7 +38,9 @@ private static ConsoleKeyInfo ReadKeySync(CancellationToken ct) return Console.ReadKey(intercept: true); } +#pragma warning disable MA0045 // Intentionally synchronous — called via Task.Run from ReadKeyAsync Thread.Sleep(15); +#pragma warning restore MA0045 } } finally diff --git a/src/Repl.Core/ConsoleLineReader.cs b/src/Repl.Core/Console/ConsoleLineReader.Autocomplete.cs similarity index 59% rename from src/Repl.Core/ConsoleLineReader.cs rename to src/Repl.Core/Console/ConsoleLineReader.Autocomplete.cs index 8800061..962cc51 100644 --- a/src/Repl.Core/ConsoleLineReader.cs +++ b/src/Repl.Core/Console/ConsoleLineReader.Autocomplete.cs @@ -1,498 +1,9 @@ using System.Text; -using System.Runtime.InteropServices; namespace Repl; -/// -/// Custom key-by-key console reader that supports Esc detection, cancellation, -/// cursor movement (Left/Right/Home/End), in-place editing, history navigation, -/// and interactive autocomplete. -/// Works both locally (Console) and remotely (IReplKeyReader + TextWriter). -/// -internal static class ConsoleLineReader +internal static partial class ConsoleLineReader { - private static readonly AsyncLocal AvailableOverlayRowsOverride = new(); - - internal readonly record struct ReadResult(string? Line, bool Escaped); - - internal readonly record struct AutocompleteRequest(string Input, int Cursor, bool MenuRequested); - - internal readonly record struct AutocompleteSuggestion( - string Value, - string? DisplayText = null, - string? Description = null, - AutocompleteSuggestionKind Kind = AutocompleteSuggestionKind.Command, - bool IsSelectable = true) - { - public string DisplayText { get; } = string.IsNullOrWhiteSpace(DisplayText) ? Value : DisplayText; - } - - internal enum AutocompleteSuggestionKind - { - Command = 0, - Context = 1, - Parameter = 2, - Ambiguous = 3, - Invalid = 4, - } - - [StructLayout(LayoutKind.Auto)] - internal readonly record struct TokenClassification( - int Start, - int Length, - AutocompleteSuggestionKind Kind); - - internal readonly record struct AutocompleteColorStyles( - string CommandStyle, - string ContextStyle, - string ParameterStyle, - string AmbiguousStyle, - string ErrorStyle, - string HintLabelStyle) - { - public static AutocompleteColorStyles Empty { get; } = new( - CommandStyle: string.Empty, - ContextStyle: string.Empty, - ParameterStyle: string.Empty, - AmbiguousStyle: string.Empty, - ErrorStyle: string.Empty, - HintLabelStyle: string.Empty); - } - - internal readonly record struct AutocompleteResult( - int ReplaceStart, - int ReplaceLength, - IReadOnlyList Suggestions, - string? HintLine = null, - IReadOnlyList? TokenClassifications = null); - - internal enum AutocompleteRenderMode - { - Off = 0, - Basic = 1, - Rich = 2, - } - - internal delegate ValueTask AutocompleteResolver( - AutocompleteRequest request, - CancellationToken cancellationToken); - - private sealed class LineEditorState( - AutocompleteResolver? resolver, - AutocompleteRenderMode renderMode, - int maxVisibleSuggestions, - AutocompletePresentation presentation, - bool liveHintEnabled, - bool colorizeInputLine, - bool colorizeHintAndMenu, - AutocompleteColorStyles colorStyles) - { - public AutocompleteResolver? Resolver { get; } = resolver; - - public AutocompleteRenderMode RenderMode { get; } = renderMode; - - public int MaxVisibleSuggestions { get; } = Math.Max(1, maxVisibleSuggestions); - - public AutocompletePresentation Presentation { get; } = presentation; - - public bool LiveHintEnabled { get; } = liveHintEnabled; - - public bool ColorizeInputLine { get; } = colorizeInputLine; - - public bool ColorizeHintAndMenu { get; } = colorizeHintAndMenu; - - public AutocompleteColorStyles ColorStyles { get; } = colorStyles; - - public int ConsecutiveTabPresses { get; set; } - - public bool IsMenuOpen { get; set; } - - public int SelectedIndex { get; set; } - - public AutocompleteResult? LastResult { get; set; } - - public string? CurrentHintLine { get; set; } - - public int RenderedOverlayLines { get; set; } - - public IReadOnlyList TokenClassifications { get; set; } = []; - - public int RenderedInputLength { get; set; } - } - - /// - /// Reads a line of input from the console. - /// When the console is redirected (tests, pipes), falls back to Console.In.ReadLineAsync. - /// When interactive, reads key-by-key with Esc detection and cancellation support. - /// - internal static async ValueTask ReadLineAsync(CancellationToken ct) - { - if (ReplSessionIO.KeyReader is { } keyReader) - { - return await ReadLineRemoteAsync(keyReader, navigator: null, editor: null, ct).ConfigureAwait(false); - } - - if (Console.IsInputRedirected || ReplSessionIO.IsSessionActive) - { - var line = await ReplSessionIO.Input.ReadLineAsync(CancellationToken.None).ConfigureAwait(false); - ct.ThrowIfCancellationRequested(); - return new ReadResult(line, Escaped: false); - } - - return await Task.Run(() => ReadLineSync(navigator: null, editor: null, ct), ct).ConfigureAwait(false); - } - - /// - /// Reads a line of input from the console with optional history navigation (Up/Down arrows). - /// - internal static async ValueTask ReadLineAsync(IHistoryProvider? history, CancellationToken ct) => - await ReadLineAsync( - history, - autocompleteResolver: null, - renderMode: AutocompleteRenderMode.Off, - maxVisibleSuggestions: 8, - presentation: AutocompletePresentation.Hybrid, - liveHintEnabled: false, - colorizeInputLine: false, - colorizeHintAndMenu: false, - AutocompleteColorStyles.Empty, - ct).ConfigureAwait(false); - - internal static async ValueTask ReadLineAsync( - IHistoryProvider? history, - AutocompleteResolver? autocompleteResolver, - AutocompleteRenderMode renderMode, - int maxVisibleSuggestions, - AutocompletePresentation presentation, - bool liveHintEnabled, - bool colorizeInputLine, - bool colorizeHintAndMenu, - AutocompleteColorStyles colorStyles, - CancellationToken ct) - { - var editor = new LineEditorState( - autocompleteResolver, - renderMode, - maxVisibleSuggestions, - presentation, - liveHintEnabled, - colorizeInputLine, - colorizeHintAndMenu, - colorStyles); - if (ReplSessionIO.KeyReader is { } keyReader) - { - var navigator = await CreateNavigatorAsync(history, ct).ConfigureAwait(false); - return await ReadLineRemoteAsync(keyReader, navigator, editor, ct).ConfigureAwait(false); - } - - if (Console.IsInputRedirected || ReplSessionIO.IsSessionActive) - { - var line = await ReplSessionIO.Input.ReadLineAsync(CancellationToken.None).ConfigureAwait(false); - ct.ThrowIfCancellationRequested(); - return new ReadResult(line, Escaped: false); - } - - var nav = await CreateNavigatorAsync(history, ct).ConfigureAwait(false); - return await Task.Run(() => ReadLineSync(nav, editor, ct), ct).ConfigureAwait(false); - } - - // ---------- Remote async path (IReplKeyReader + TextWriter) ---------- - - private static async ValueTask ReadLineRemoteAsync( - IReplKeyReader keyReader, - HistoryNavigator? navigator, - LineEditorState? editor, - CancellationToken ct) - { - var output = ReplSessionIO.Output; - var buffer = new StringBuilder(); - var cursor = 0; - var echo = new StringBuilder(); - - while (true) - { - ct.ThrowIfCancellationRequested(); - - ConsoleKeyInfo key; - try - { - key = await keyReader.ReadKeyAsync(ct).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - return new ReadResult(Line: null, Escaped: false); - } - - var autocompleteHandling = await TryHandleAutocompleteKeyAsync( - key, - editor, - buffer, - cursor, - echo, - ct).ConfigureAwait(false); - cursor = autocompleteHandling.Cursor; - if (!autocompleteHandling.Handled) - { - var result = HandleKey(key, buffer, ref cursor, navigator, echo, editor); - if (result is null) - { - await RefreshAssistAfterEditingAsync(editor, buffer, cursor, echo, ct).ConfigureAwait(false); - } - - if (echo.Length > 0) - { - await output.WriteAsync(echo.ToString()).ConfigureAwait(false); - await output.FlushAsync(ct).ConfigureAwait(false); - echo.Clear(); - } - - if (result is not null) - { - return result.Value; - } - - continue; - } - - if (echo.Length > 0) - { - await output.WriteAsync(echo.ToString()).ConfigureAwait(false); - await output.FlushAsync(ct).ConfigureAwait(false); - echo.Clear(); - } - } - } - - // ---------- Console sync path ---------- - - private static ReadResult ReadLineSync(HistoryNavigator? navigator, LineEditorState? editor, CancellationToken ct) - { - ConsoleInputGate.Gate.Wait(ct); - try - { - return ReadLineCore(navigator, editor, ct); - } - finally - { - ConsoleInputGate.Gate.Release(); - } - } - - private static ReadResult ReadLineCore(HistoryNavigator? navigator, LineEditorState? editor, CancellationToken ct) - { - var buffer = new StringBuilder(); - var cursor = 0; - var echo = new StringBuilder(); - while (true) - { - ct.ThrowIfCancellationRequested(); - - if (!Console.KeyAvailable) - { - Thread.Sleep(15); - continue; - } - - var key = Console.ReadKey(intercept: true); -#pragma warning disable VSTHRD002 - var autocompleteHandling = TryHandleAutocompleteKeyAsync(key, editor, buffer, cursor, echo, ct) - .AsTask() - .GetAwaiter() - .GetResult(); -#pragma warning restore VSTHRD002 - cursor = autocompleteHandling.Cursor; - if (!autocompleteHandling.Handled) - { - var result = HandleKey(key, buffer, ref cursor, navigator, echo, editor); - if (result is null) - { -#pragma warning disable VSTHRD002 - RefreshAssistAfterEditingAsync(editor, buffer, cursor, echo, ct) - .AsTask() - .GetAwaiter() - .GetResult(); -#pragma warning restore VSTHRD002 - } - - if (echo.Length > 0) - { - Console.Write(echo.ToString()); - echo.Clear(); - } - - if (result is not null) - { - return result.Value; - } - - continue; - } - - if (echo.Length > 0) - { - Console.Write(echo.ToString()); - echo.Clear(); - } - } - } - - // ---------- Key handling (shared by both paths) ---------- - - internal static ReadResult? HandleKey( - ConsoleKeyInfo key, - StringBuilder buffer, - ref int cursor, - HistoryNavigator? navigator, - StringBuilder echo) => - HandleKey(key, buffer, ref cursor, navigator, echo, editor: null); - - private static ReadResult? HandleKey( - ConsoleKeyInfo key, - StringBuilder buffer, - ref int cursor, - HistoryNavigator? navigator, - StringBuilder echo, - LineEditorState? editor) - { - if (editor is { IsMenuOpen: true, RenderMode: not AutocompleteRenderMode.Rich } - && key.Key is not ConsoleKey.UpArrow - and not ConsoleKey.DownArrow - and not ConsoleKey.Tab - and not ConsoleKey.Enter - and not ConsoleKey.Escape) - { - editor.IsMenuOpen = false; - ClearRenderedMenu(editor, echo); - } - - if (key.Key != ConsoleKey.Tab) - { - ResetTabState(editor); - } - - if (TryHandleEscapeOrEnter(key, editor, buffer, ref cursor, echo, out var result)) - { - return result; - } - - HandleEditingKey(key, buffer, ref cursor, navigator, echo); - return null; - } - - private static bool TryHandleEscapeOrEnter( - ConsoleKeyInfo key, - LineEditorState? editor, - StringBuilder buffer, - ref int cursor, - StringBuilder echo, - out ReadResult? result) - { - if (key.Key == ConsoleKey.Escape) - { - if (editor is { IsMenuOpen: true }) - { - editor.IsMenuOpen = false; - ClearRenderedMenu(editor, echo); - result = null; - return true; - } - - ReplaceLine(buffer, ref cursor, string.Empty, echo); - result = new ReadResult(Line: null, Escaped: true); - return true; - } - - if (key.Key == ConsoleKey.Enter) - { - if (editor is not null) - { - ClearRenderedMenu(editor, echo); - } - - MoveCursorToEnd(buffer, ref cursor, echo); - echo.Append("\r\n"); - result = new ReadResult(buffer.ToString(), Escaped: false); - return true; - } - - result = null; - return false; - } - - private static void HandleEditingKey( - ConsoleKeyInfo key, - StringBuilder buffer, - ref int cursor, - HistoryNavigator? navigator, - StringBuilder echo) - { - switch (key.Key) - { - case ConsoleKey.Backspace: - HandleBackspace(buffer, ref cursor, echo); - break; - case ConsoleKey.Delete: - HandleDelete(buffer, ref cursor, echo); - break; - case ConsoleKey.LeftArrow: - if (cursor > 0) - { - cursor--; - echo.Append('\b'); - } - - break; - case ConsoleKey.RightArrow: - if (cursor < buffer.Length) - { - echo.Append(buffer[cursor]); - cursor++; - } - - break; - case ConsoleKey.Home: - MoveCursorToStart(ref cursor, echo); - break; - case ConsoleKey.End: - MoveCursorToEnd(buffer, ref cursor, echo); - break; - case ConsoleKey.UpArrow when navigator is not null: - navigator.UpdateCurrent(buffer.ToString()); - if (navigator.TryMoveUp(out var upEntry)) - { - ReplaceLine(buffer, ref cursor, upEntry, echo); - } - - break; - case ConsoleKey.DownArrow when navigator is not null: - navigator.UpdateCurrent(buffer.ToString()); - if (navigator.TryMoveDown(out var downEntry)) - { - ReplaceLine(buffer, ref cursor, downEntry, echo); - } - - break; - default: - if (key.KeyChar != '\0') - { - InsertChar(buffer, ref cursor, key.KeyChar, echo); - } - - break; - } - } - - private static void ResetTabState(LineEditorState? editor) - { - if (editor is null) - { - return; - } - - editor.ConsecutiveTabPresses = 0; - editor.LastResult = null; - } - private static async ValueTask RefreshAssistAfterEditingAsync( LineEditorState? editor, StringBuilder buffer, @@ -1319,107 +830,4 @@ private static void ReplaceBuffer(StringBuilder buffer, ref int cursor, int targ cursor = targetCursor; } } - - private static void InsertChar(StringBuilder buffer, ref int cursor, char ch, StringBuilder echo) - { - buffer.Insert(cursor, ch); - WriteFromCursor(buffer, cursor, echo); - cursor++; - MoveBack(buffer.Length - cursor, echo); - } - - private static void HandleBackspace(StringBuilder buffer, ref int cursor, StringBuilder echo) - { - if (cursor == 0) - { - return; - } - - buffer.Remove(cursor - 1, 1); - cursor--; - echo.Append('\b'); - WriteFromCursor(buffer, cursor, echo); - echo.Append(' '); - MoveBack(buffer.Length - cursor + 1, echo); - } - - private static void HandleDelete(StringBuilder buffer, ref int cursor, StringBuilder echo) - { - if (cursor >= buffer.Length) - { - return; - } - - buffer.Remove(cursor, 1); - WriteFromCursor(buffer, cursor, echo); - echo.Append(' '); - MoveBack(buffer.Length - cursor + 1, echo); - } - - private static void ReplaceLine( - StringBuilder buffer, - ref int cursor, - string newText, - StringBuilder echo) - { - MoveCursorToStart(ref cursor, echo); - echo.Append(newText); - var overflow = buffer.Length - newText.Length; - if (overflow > 0) - { - echo.Append(' ', overflow); - MoveBack(overflow, echo); - } - - buffer.Clear(); - buffer.Append(newText); - cursor = newText.Length; - } - - private static void MoveCursorToStart(ref int cursor, StringBuilder echo) - { - if (cursor > 0) - { - MoveBack(cursor, echo); - cursor = 0; - } - } - - private static void MoveCursorToEnd(StringBuilder buffer, ref int cursor, StringBuilder echo) - { - if (cursor < buffer.Length) - { - WriteFromCursor(buffer, cursor, echo); - cursor = buffer.Length; - } - } - - private static void WriteFromCursor(StringBuilder buffer, int cursor, StringBuilder echo) - { - for (var i = cursor; i < buffer.Length; i++) - { - echo.Append(buffer[i]); - } - } - - private static void MoveBack(int count, StringBuilder echo) - { - if (count > 0) - { - echo.Append('\b', count); - } - } - - private static async ValueTask CreateNavigatorAsync( - IHistoryProvider? history, - CancellationToken ct) - { - if (history is null) - { - return null; - } - - var entries = await history.GetRecentAsync(500, ct).ConfigureAwait(false); - return entries.Count > 0 ? new HistoryNavigator(entries) : null; - } } diff --git a/src/Repl.Core/Console/ConsoleLineReader.cs b/src/Repl.Core/Console/ConsoleLineReader.cs new file mode 100644 index 0000000..1d714a4 --- /dev/null +++ b/src/Repl.Core/Console/ConsoleLineReader.cs @@ -0,0 +1,607 @@ +using System.Text; +using System.Runtime.InteropServices; + +namespace Repl; + +/// +/// Custom key-by-key console reader that supports Esc detection, cancellation, +/// cursor movement (Left/Right/Home/End), in-place editing, history navigation, +/// and interactive autocomplete. +/// Works both locally (Console) and remotely (IReplKeyReader + TextWriter). +/// +internal static partial class ConsoleLineReader +{ + private static readonly AsyncLocal AvailableOverlayRowsOverride = new(); + + internal readonly record struct ReadResult(string? Line, bool Escaped); + + internal readonly record struct AutocompleteRequest(string Input, int Cursor, bool MenuRequested); + + internal readonly record struct AutocompleteSuggestion( + string Value, + string? DisplayText = null, + string? Description = null, + AutocompleteSuggestionKind Kind = AutocompleteSuggestionKind.Command, + bool IsSelectable = true) + { + public string DisplayText { get; } = string.IsNullOrWhiteSpace(DisplayText) ? Value : DisplayText; + } + + internal enum AutocompleteSuggestionKind + { + Command = 0, + Context = 1, + Parameter = 2, + Ambiguous = 3, + Invalid = 4, + } + + [StructLayout(LayoutKind.Auto)] + internal readonly record struct TokenClassification( + int Start, + int Length, + AutocompleteSuggestionKind Kind); + + internal readonly record struct AutocompleteColorStyles( + string CommandStyle, + string ContextStyle, + string ParameterStyle, + string AmbiguousStyle, + string ErrorStyle, + string HintLabelStyle) + { + public static AutocompleteColorStyles Empty { get; } = new( + CommandStyle: string.Empty, + ContextStyle: string.Empty, + ParameterStyle: string.Empty, + AmbiguousStyle: string.Empty, + ErrorStyle: string.Empty, + HintLabelStyle: string.Empty); + } + + internal readonly record struct AutocompleteResult( + int ReplaceStart, + int ReplaceLength, + IReadOnlyList Suggestions, + string? HintLine = null, + IReadOnlyList? TokenClassifications = null); + + internal enum AutocompleteRenderMode + { + Off = 0, + Basic = 1, + Rich = 2, + } + + internal delegate ValueTask AutocompleteResolver( + AutocompleteRequest request, + CancellationToken cancellationToken); + + private sealed class LineEditorState( + AutocompleteResolver? resolver, + AutocompleteRenderMode renderMode, + int maxVisibleSuggestions, + AutocompletePresentation presentation, + bool liveHintEnabled, + bool colorizeInputLine, + bool colorizeHintAndMenu, + AutocompleteColorStyles colorStyles) + { + public AutocompleteResolver? Resolver { get; } = resolver; + + public AutocompleteRenderMode RenderMode { get; } = renderMode; + + public int MaxVisibleSuggestions { get; } = Math.Max(1, maxVisibleSuggestions); + + public AutocompletePresentation Presentation { get; } = presentation; + + public bool LiveHintEnabled { get; } = liveHintEnabled; + + public bool ColorizeInputLine { get; } = colorizeInputLine; + + public bool ColorizeHintAndMenu { get; } = colorizeHintAndMenu; + + public AutocompleteColorStyles ColorStyles { get; } = colorStyles; + + public int ConsecutiveTabPresses { get; set; } + + public bool IsMenuOpen { get; set; } + + public int SelectedIndex { get; set; } + + public AutocompleteResult? LastResult { get; set; } + + public string? CurrentHintLine { get; set; } + + public int RenderedOverlayLines { get; set; } + + public IReadOnlyList TokenClassifications { get; set; } = []; + + public int RenderedInputLength { get; set; } + } + + /// + /// Reads a line of input from the console. + /// When the console is redirected (tests, pipes), falls back to Console.In.ReadLineAsync. + /// When interactive, reads key-by-key with Esc detection and cancellation support. + /// + internal static async ValueTask ReadLineAsync(CancellationToken ct) + { + if (ReplSessionIO.KeyReader is { } keyReader) + { + return await ReadLineRemoteAsync(keyReader, navigator: null, editor: null, ct).ConfigureAwait(false); + } + + if (Console.IsInputRedirected || ReplSessionIO.IsSessionActive) + { + var line = await ReplSessionIO.Input.ReadLineAsync(CancellationToken.None).ConfigureAwait(false); + ct.ThrowIfCancellationRequested(); + return new ReadResult(line, Escaped: false); + } + + return await Task.Run(() => ReadLineSync(navigator: null, editor: null, ct), ct).ConfigureAwait(false); + } + + /// + /// Reads a line of input from the console with optional history navigation (Up/Down arrows). + /// + internal static async ValueTask ReadLineAsync(IHistoryProvider? history, CancellationToken ct) => + await ReadLineAsync( + history, + autocompleteResolver: null, + renderMode: AutocompleteRenderMode.Off, + maxVisibleSuggestions: 8, + presentation: AutocompletePresentation.Hybrid, + liveHintEnabled: false, + colorizeInputLine: false, + colorizeHintAndMenu: false, + AutocompleteColorStyles.Empty, + ct).ConfigureAwait(false); + + internal static async ValueTask ReadLineAsync( + IHistoryProvider? history, + AutocompleteResolver? autocompleteResolver, + AutocompleteRenderMode renderMode, + int maxVisibleSuggestions, + AutocompletePresentation presentation, + bool liveHintEnabled, + bool colorizeInputLine, + bool colorizeHintAndMenu, + AutocompleteColorStyles colorStyles, + CancellationToken ct) + { + var editor = new LineEditorState( + autocompleteResolver, + renderMode, + maxVisibleSuggestions, + presentation, + liveHintEnabled, + colorizeInputLine, + colorizeHintAndMenu, + colorStyles); + if (ReplSessionIO.KeyReader is { } keyReader) + { + var navigator = await CreateNavigatorAsync(history, ct).ConfigureAwait(false); + return await ReadLineRemoteAsync(keyReader, navigator, editor, ct).ConfigureAwait(false); + } + + if (Console.IsInputRedirected || ReplSessionIO.IsSessionActive) + { + var line = await ReplSessionIO.Input.ReadLineAsync(CancellationToken.None).ConfigureAwait(false); + ct.ThrowIfCancellationRequested(); + return new ReadResult(line, Escaped: false); + } + + var nav = await CreateNavigatorAsync(history, ct).ConfigureAwait(false); + return await Task.Run(() => ReadLineSync(nav, editor, ct), ct).ConfigureAwait(false); + } + + // ---------- Remote async path (IReplKeyReader + TextWriter) ---------- + + private static async ValueTask ReadLineRemoteAsync( + IReplKeyReader keyReader, + HistoryNavigator? navigator, + LineEditorState? editor, + CancellationToken ct) + { + var output = ReplSessionIO.Output; + var buffer = new StringBuilder(); + var cursor = 0; + var echo = new StringBuilder(); + + while (true) + { + ct.ThrowIfCancellationRequested(); + + ConsoleKeyInfo key; + try + { + key = await keyReader.ReadKeyAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return new ReadResult(Line: null, Escaped: false); + } + + var autocompleteHandling = await TryHandleAutocompleteKeyAsync( + key, + editor, + buffer, + cursor, + echo, + ct).ConfigureAwait(false); + cursor = autocompleteHandling.Cursor; + if (!autocompleteHandling.Handled) + { + var result = HandleKey(key, buffer, ref cursor, navigator, echo, editor); + if (result is null) + { + await RefreshAssistAfterEditingAsync(editor, buffer, cursor, echo, ct).ConfigureAwait(false); + } + + if (echo.Length > 0) + { + await output.WriteAsync(echo.ToString()).ConfigureAwait(false); + await output.FlushAsync(ct).ConfigureAwait(false); + echo.Clear(); + } + + if (result is not null) + { + return result.Value; + } + + continue; + } + + if (echo.Length > 0) + { + await output.WriteAsync(echo.ToString()).ConfigureAwait(false); + await output.FlushAsync(ct).ConfigureAwait(false); + echo.Clear(); + } + } + } + + // ---------- Console sync path ---------- + + private static ReadResult ReadLineSync(HistoryNavigator? navigator, LineEditorState? editor, CancellationToken ct) + { +#pragma warning disable MA0045 // Intentionally synchronous — called via Task.Run from ReadLineAsync + ConsoleInputGate.Gate.Wait(ct); +#pragma warning restore MA0045 + try + { + return ReadLineCore(navigator, editor, ct); + } + finally + { + ConsoleInputGate.Gate.Release(); + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Maintainability", "MA0051:Method is too long", Justification = "Sync polling loop with inline pragma suppressions slightly exceeds threshold.")] + private static ReadResult ReadLineCore(HistoryNavigator? navigator, LineEditorState? editor, CancellationToken ct) + { + var buffer = new StringBuilder(); + var cursor = 0; + var echo = new StringBuilder(); + while (true) + { + ct.ThrowIfCancellationRequested(); + + if (!Console.KeyAvailable) + { +#pragma warning disable MA0045 // Intentionally synchronous — called via Task.Run from ReadLineAsync + Thread.Sleep(15); +#pragma warning restore MA0045 + continue; + } + + var key = Console.ReadKey(intercept: true); +#pragma warning disable VSTHRD002 +#pragma warning disable MA0045 // Intentionally synchronous — called via Task.Run; cannot await in sync polling loop + var autocompleteHandling = TryHandleAutocompleteKeyAsync(key, editor, buffer, cursor, echo, ct) + .AsTask() + .GetAwaiter() + .GetResult(); +#pragma warning restore MA0045 +#pragma warning restore VSTHRD002 + cursor = autocompleteHandling.Cursor; + if (!autocompleteHandling.Handled) + { + var result = HandleKey(key, buffer, ref cursor, navigator, echo, editor); + if (result is null) + { +#pragma warning disable VSTHRD002 +#pragma warning disable MA0045 // Intentionally synchronous — called via Task.Run; cannot await in sync polling loop + RefreshAssistAfterEditingAsync(editor, buffer, cursor, echo, ct) + .AsTask() + .GetAwaiter() + .GetResult(); +#pragma warning restore VSTHRD002 +#pragma warning restore MA0045 + } + + if (echo.Length > 0) + { + Console.Write(echo); + echo.Clear(); + } + + if (result is not null) + { + return result.Value; + } + + continue; + } + + if (echo.Length > 0) + { + Console.Write(echo); + echo.Clear(); + } + } + } + + // ---------- Key handling (shared by both paths) ---------- + + internal static ReadResult? HandleKey( + ConsoleKeyInfo key, + StringBuilder buffer, + ref int cursor, + HistoryNavigator? navigator, + StringBuilder echo) => + HandleKey(key, buffer, ref cursor, navigator, echo, editor: null); + + private static ReadResult? HandleKey( + ConsoleKeyInfo key, + StringBuilder buffer, + ref int cursor, + HistoryNavigator? navigator, + StringBuilder echo, + LineEditorState? editor) + { + if (editor is { IsMenuOpen: true, RenderMode: not AutocompleteRenderMode.Rich } + && key.Key is not ConsoleKey.UpArrow + and not ConsoleKey.DownArrow + and not ConsoleKey.Tab + and not ConsoleKey.Enter + and not ConsoleKey.Escape) + { + editor.IsMenuOpen = false; + ClearRenderedMenu(editor, echo); + } + + if (key.Key != ConsoleKey.Tab) + { + ResetTabState(editor); + } + + if (TryHandleEscapeOrEnter(key, editor, buffer, ref cursor, echo, out var result)) + { + return result; + } + + HandleEditingKey(key, buffer, ref cursor, navigator, echo); + return null; + } + + private static bool TryHandleEscapeOrEnter( + ConsoleKeyInfo key, + LineEditorState? editor, + StringBuilder buffer, + ref int cursor, + StringBuilder echo, + out ReadResult? result) + { + if (key.Key == ConsoleKey.Escape) + { + if (editor is { IsMenuOpen: true }) + { + editor.IsMenuOpen = false; + ClearRenderedMenu(editor, echo); + result = null; + return true; + } + + ReplaceLine(buffer, ref cursor, string.Empty, echo); + result = new ReadResult(Line: null, Escaped: true); + return true; + } + + if (key.Key == ConsoleKey.Enter) + { + if (editor is not null) + { + ClearRenderedMenu(editor, echo); + } + + MoveCursorToEnd(buffer, ref cursor, echo); + echo.Append("\r\n"); + result = new ReadResult(buffer.ToString(), Escaped: false); + return true; + } + + result = null; + return false; + } + + private static void HandleEditingKey( + ConsoleKeyInfo key, + StringBuilder buffer, + ref int cursor, + HistoryNavigator? navigator, + StringBuilder echo) + { + switch (key.Key) + { + case ConsoleKey.Backspace: + HandleBackspace(buffer, ref cursor, echo); + break; + case ConsoleKey.Delete: + HandleDelete(buffer, ref cursor, echo); + break; + case ConsoleKey.LeftArrow: + if (cursor > 0) + { + cursor--; + echo.Append('\b'); + } + + break; + case ConsoleKey.RightArrow: + if (cursor < buffer.Length) + { + echo.Append(buffer[cursor]); + cursor++; + } + + break; + case ConsoleKey.Home: + MoveCursorToStart(ref cursor, echo); + break; + case ConsoleKey.End: + MoveCursorToEnd(buffer, ref cursor, echo); + break; + case ConsoleKey.UpArrow when navigator is not null: + navigator.UpdateCurrent(buffer.ToString()); + if (navigator.TryMoveUp(out var upEntry)) + { + ReplaceLine(buffer, ref cursor, upEntry, echo); + } + + break; + case ConsoleKey.DownArrow when navigator is not null: + navigator.UpdateCurrent(buffer.ToString()); + if (navigator.TryMoveDown(out var downEntry)) + { + ReplaceLine(buffer, ref cursor, downEntry, echo); + } + + break; + default: + if (key.KeyChar != '\0') + { + InsertChar(buffer, ref cursor, key.KeyChar, echo); + } + + break; + } + } + + private static void ResetTabState(LineEditorState? editor) + { + if (editor is null) + { + return; + } + + editor.ConsecutiveTabPresses = 0; + editor.LastResult = null; + } + + private static void InsertChar(StringBuilder buffer, ref int cursor, char ch, StringBuilder echo) + { + buffer.Insert(cursor, ch); + WriteFromCursor(buffer, cursor, echo); + cursor++; + MoveBack(buffer.Length - cursor, echo); + } + + private static void HandleBackspace(StringBuilder buffer, ref int cursor, StringBuilder echo) + { + if (cursor == 0) + { + return; + } + + buffer.Remove(cursor - 1, 1); + cursor--; + echo.Append('\b'); + WriteFromCursor(buffer, cursor, echo); + echo.Append(' '); + MoveBack(buffer.Length - cursor + 1, echo); + } + + private static void HandleDelete(StringBuilder buffer, ref int cursor, StringBuilder echo) + { + if (cursor >= buffer.Length) + { + return; + } + + buffer.Remove(cursor, 1); + WriteFromCursor(buffer, cursor, echo); + echo.Append(' '); + MoveBack(buffer.Length - cursor + 1, echo); + } + + private static void ReplaceLine( + StringBuilder buffer, + ref int cursor, + string newText, + StringBuilder echo) + { + MoveCursorToStart(ref cursor, echo); + echo.Append(newText); + var overflow = buffer.Length - newText.Length; + if (overflow > 0) + { + echo.Append(' ', overflow); + MoveBack(overflow, echo); + } + + buffer.Clear(); + buffer.Append(newText); + cursor = newText.Length; + } + + private static void MoveCursorToStart(ref int cursor, StringBuilder echo) + { + if (cursor > 0) + { + MoveBack(cursor, echo); + cursor = 0; + } + } + + private static void MoveCursorToEnd(StringBuilder buffer, ref int cursor, StringBuilder echo) + { + if (cursor < buffer.Length) + { + WriteFromCursor(buffer, cursor, echo); + cursor = buffer.Length; + } + } + + private static void WriteFromCursor(StringBuilder buffer, int cursor, StringBuilder echo) + { + for (var i = cursor; i < buffer.Length; i++) + { + echo.Append(buffer[i]); + } + } + + private static void MoveBack(int count, StringBuilder echo) + { + if (count > 0) + { + echo.Append('\b', count); + } + } + + private static async ValueTask CreateNavigatorAsync( + IHistoryProvider? history, + CancellationToken ct) + { + if (history is null) + { + return null; + } + + var entries = await history.GetRecentAsync(500, ct).ConfigureAwait(false); + return entries.Count > 0 ? new HistoryNavigator(entries) : null; + } +} diff --git a/src/Repl.Core/ConsoleReplInteractionPresenter.cs b/src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs similarity index 100% rename from src/Repl.Core/ConsoleReplInteractionPresenter.cs rename to src/Repl.Core/Console/ConsoleReplInteractionPresenter.cs diff --git a/src/Repl.Core/ConsoleTerminalInfo.cs b/src/Repl.Core/Console/ConsoleTerminalInfo.cs similarity index 100% rename from src/Repl.Core/ConsoleTerminalInfo.cs rename to src/Repl.Core/Console/ConsoleTerminalInfo.cs diff --git a/src/Repl.Core/HistoryNavigator.cs b/src/Repl.Core/Console/HistoryNavigator.cs similarity index 100% rename from src/Repl.Core/HistoryNavigator.cs rename to src/Repl.Core/Console/HistoryNavigator.cs diff --git a/src/Repl.Core/ICommandTokenReceiver.cs b/src/Repl.Core/Console/ICommandTokenReceiver.cs similarity index 100% rename from src/Repl.Core/ICommandTokenReceiver.cs rename to src/Repl.Core/Console/ICommandTokenReceiver.cs diff --git a/src/Repl.Core/InMemoryHistoryProvider.cs b/src/Repl.Core/Console/InMemoryHistoryProvider.cs similarity index 100% rename from src/Repl.Core/InMemoryHistoryProvider.cs rename to src/Repl.Core/Console/InMemoryHistoryProvider.cs diff --git a/src/Repl.Core/InteractiveOptions.cs b/src/Repl.Core/Console/InteractiveOptions.cs similarity index 100% rename from src/Repl.Core/InteractiveOptions.cs rename to src/Repl.Core/Console/InteractiveOptions.cs diff --git a/src/Repl.Core/InteractivePolicy.cs b/src/Repl.Core/Console/InteractivePolicy.cs similarity index 100% rename from src/Repl.Core/InteractivePolicy.cs rename to src/Repl.Core/Console/InteractivePolicy.cs diff --git a/src/Repl.Core/CoreReplApp.Autocomplete.cs b/src/Repl.Core/CoreReplApp.Autocomplete.cs new file mode 100644 index 0000000..f650b9a --- /dev/null +++ b/src/Repl.Core/CoreReplApp.Autocomplete.cs @@ -0,0 +1,7 @@ +namespace Repl; + +public sealed partial class CoreReplApp +{ + private AutocompleteEngine? _autocompleteEngine; + internal AutocompleteEngine Autocomplete => _autocompleteEngine ??= new(this); +} diff --git a/src/Repl.Core/CoreReplApp.Documentation.cs b/src/Repl.Core/CoreReplApp.Documentation.cs index c5eac52..7f93d60 100644 --- a/src/Repl.Core/CoreReplApp.Documentation.cs +++ b/src/Repl.Core/CoreReplApp.Documentation.cs @@ -1,477 +1,25 @@ -using System.ComponentModel; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using Repl.Internal.Options; - namespace Repl; public sealed partial class CoreReplApp { - /// - public ReplDocumentationModel CreateDocumentationModel(string? targetPath = null) - { - var activeGraph = ResolveActiveRoutingGraph(); - var normalizedTargetPath = NormalizePath(targetPath); - var targetTokens = string.IsNullOrWhiteSpace(normalizedTargetPath) - ? [] - : normalizedTargetPath - .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - var discoverableRoutes = ResolveDiscoverableRoutes( - activeGraph.Routes, - activeGraph.Contexts, - targetTokens, - StringComparison.OrdinalIgnoreCase); - var discoverableContexts = ResolveDiscoverableContexts( - activeGraph.Contexts, - targetTokens, - StringComparison.OrdinalIgnoreCase); - var commands = SelectDocumentationCommands( - normalizedTargetPath, - discoverableRoutes, - discoverableContexts, - out _); + private DocumentationEngine? _documentationEngine; + private DocumentationEngine DocumentationEng => _documentationEngine ??= new(this); - var contexts = SelectDocumentationContexts(normalizedTargetPath, commands, discoverableContexts); - var commandDocs = commands.Select(BuildDocumentationCommand).ToArray(); - var contextDocs = contexts - .Select(context => new ReplDocContext( - Path: context.Template.Template, - Description: context.Description, - IsDynamic: context.Template.Segments.Any(segment => segment is DynamicRouteSegment), - IsHidden: context.IsHidden, - Details: context.Details)) - .ToArray(); - var resourceDocs = commandDocs - .Where(cmd => cmd.IsResource || cmd.Annotations?.ReadOnly == true) - .Select(cmd => new ReplDocResource( - Path: cmd.Path, - Description: cmd.Description, - Details: cmd.Details, - Arguments: cmd.Arguments, - Options: cmd.Options)) - .ToArray(); - return new ReplDocumentationModel( - App: BuildDocumentationApp(), - Contexts: contextDocs, - Commands: commandDocs, - Resources: resourceDocs); - } + /// + public ReplDocumentationModel CreateDocumentationModel(string? targetPath = null) => + DocumentationEng.CreateDocumentationModel(targetPath); internal ReplDocumentationModel CreateDocumentationModel( IServiceProvider serviceProvider, - string? targetPath = null) - { - ArgumentNullException.ThrowIfNull(serviceProvider); - - using var runtimeStateScope = PushRuntimeState(serviceProvider, isInteractiveSession: false); - return CreateDocumentationModel(targetPath); - } + string? targetPath = null) => + DocumentationEng.CreateDocumentationModel(serviceProvider, targetPath); /// /// Internal documentation model creation that supports not-found result for help rendering. /// - internal object CreateDocumentationModelInternal(string? targetPath) - { - var activeGraph = ResolveActiveRoutingGraph(); - var normalizedTargetPath = NormalizePath(targetPath); - var targetTokens = string.IsNullOrWhiteSpace(normalizedTargetPath) - ? [] - : normalizedTargetPath - .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - var discoverableRoutes = ResolveDiscoverableRoutes( - activeGraph.Routes, - activeGraph.Contexts, - targetTokens, - StringComparison.OrdinalIgnoreCase); - var discoverableContexts = ResolveDiscoverableContexts( - activeGraph.Contexts, - targetTokens, - StringComparison.OrdinalIgnoreCase); - var commands = SelectDocumentationCommands( - normalizedTargetPath, - discoverableRoutes, - discoverableContexts, - out var notFoundResult); - if (notFoundResult is not null) - { - return notFoundResult; - } - - var contexts = SelectDocumentationContexts(normalizedTargetPath, commands, discoverableContexts); - var commandDocs = commands.Select(BuildDocumentationCommand).ToArray(); - var contextDocs = contexts - .Select(context => new ReplDocContext( - Path: context.Template.Template, - Description: context.Description, - IsDynamic: context.Template.Segments.Any(segment => segment is DynamicRouteSegment), - IsHidden: context.IsHidden, - Details: context.Details)) - .ToArray(); - var resourceDocs = commandDocs - .Where(cmd => cmd.IsResource || cmd.Annotations?.ReadOnly == true) - .Select(cmd => new ReplDocResource( - Path: cmd.Path, - Description: cmd.Description, - Details: cmd.Details, - Arguments: cmd.Arguments, - Options: cmd.Options)) - .ToArray(); - return new ReplDocumentationModel( - App: BuildDocumentationApp(), - Contexts: contextDocs, - Commands: commandDocs, - Resources: resourceDocs); - } - - private static RouteDefinition[] SelectDocumentationCommands( - string? normalizedTargetPath, - IReadOnlyList routes, - IReadOnlyList contexts, - out IReplResult? notFoundResult) - { - notFoundResult = null; - if (string.IsNullOrWhiteSpace(normalizedTargetPath)) - { - return routes.Where(route => !route.Command.IsHidden).ToArray(); - } - - var exactCommand = routes.FirstOrDefault( - route => string.Equals( - route.Template.Template, - normalizedTargetPath, - StringComparison.OrdinalIgnoreCase)); - if (exactCommand is not null) - { - return [exactCommand]; - } - - var exactContext = contexts.FirstOrDefault( - context => string.Equals( - context.Template.Template, - normalizedTargetPath, - StringComparison.OrdinalIgnoreCase)); - if (exactContext is not null) - { - return routes - .Where(route => - !route.Command.IsHidden - && route.Template.Template.StartsWith( - $"{exactContext.Template.Template} ", - StringComparison.OrdinalIgnoreCase)) - .ToArray(); - } - - notFoundResult = Results.NotFound($"Documentation target '{normalizedTargetPath}' not found."); - return []; - } - - private static ContextDefinition[] SelectDocumentationContexts( - string? normalizedTargetPath, - RouteDefinition[] commands, - IReadOnlyList contexts) - { - if (string.IsNullOrWhiteSpace(normalizedTargetPath)) - { - return [.. contexts]; - } - - var exactContext = contexts.FirstOrDefault( - context => string.Equals( - context.Template.Template, - normalizedTargetPath, - StringComparison.OrdinalIgnoreCase)); - if (exactContext is not null) - { - return [exactContext]; - } - - if (commands.Length == 0) - { - return []; - } - - var selected = contexts - .Where(context => commands.Any(command => - command.Template.Template.StartsWith( - $"{context.Template.Template} ", - StringComparison.OrdinalIgnoreCase) - || string.Equals( - command.Template.Template, - context.Template.Template, - StringComparison.OrdinalIgnoreCase))) - .ToArray(); - return selected; - } - - private ReplDocCommand BuildDocumentationCommand(RouteDefinition route) - { - var dynamicSegments = route.Template.Segments - .OfType() - .ToArray(); - var routeParameterNames = dynamicSegments - .Select(segment => segment.Name) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - var handlerParams = route.Command.Handler.Method.GetParameters(); - var arguments = dynamicSegments - .Select(segment => - { - var paramInfo = handlerParams.FirstOrDefault(p => - string.Equals(p.Name, segment.Name, StringComparison.OrdinalIgnoreCase)); - var description = paramInfo?.GetCustomAttribute()?.Description; - return new ReplDocArgument( - Name: segment.Name, - Type: GetConstraintTypeName(segment.ConstraintKind), - Required: !segment.IsOptional, - Description: description); - }) - .ToArray(); - var regularOptions = handlerParams - .Where(parameter => - !string.IsNullOrWhiteSpace(parameter.Name) - && parameter.ParameterType != typeof(CancellationToken) - && !routeParameterNames.Contains(parameter.Name!) - && !IsFrameworkInjectedParameter(parameter.ParameterType) - && parameter.GetCustomAttribute() is null - && parameter.GetCustomAttribute() is null - && !Attribute.IsDefined(parameter.ParameterType, typeof(ReplOptionsGroupAttribute), inherit: true)) - .Select(parameter => BuildDocumentationOption(route.OptionSchema, parameter)); - var groupOptions = handlerParams - .Where(parameter => Attribute.IsDefined(parameter.ParameterType, typeof(ReplOptionsGroupAttribute), inherit: true)) - .SelectMany(parameter => - { - var defaultInstance = CreateOptionsGroupDefault(parameter.ParameterType); - return GetOptionsGroupProperties(parameter.ParameterType) - .Where(prop => prop.CanWrite) - .Select(prop => BuildDocumentationOptionFromProperty(route.OptionSchema, prop, defaultInstance)); - }); - var options = regularOptions.Concat(groupOptions).ToArray(); - - var answers = BuildDocumentationAnswers(route.Command); - - return new ReplDocCommand( - Path: route.Template.Template, - Description: route.Command.Description, - Aliases: route.Command.Aliases, - IsHidden: route.Command.IsHidden, - Arguments: arguments, - Options: options, - Details: route.Command.Details, - Annotations: route.Command.Annotations, - Metadata: route.Command.Metadata.Count > 0 ? route.Command.Metadata : null, - Answers: answers.Length > 0 ? answers : null, - IsResource: route.Command.IsResource, - IsPrompt: route.Command.IsPrompt); - } - - private static ReplDocAnswer[] BuildDocumentationAnswers(CommandBuilder command) - { - var fluentAnswers = command.Answers - .Select(a => new ReplDocAnswer(a.Name, a.Type, a.Description)); - var attributeAnswers = command.Handler.Method - .GetCustomAttributes() - .Select(a => new ReplDocAnswer(a.Name, a.Type, a.Description)); - return fluentAnswers - .Concat(attributeAnswers) - .GroupBy(a => a.Name, StringComparer.OrdinalIgnoreCase) - .Select(g => g.First()) - .ToArray(); - } - - private ReplDocApp BuildDocumentationApp() - { - var assembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly(); - var name = assembly.GetCustomAttribute()?.Product - ?? assembly.GetName().Name - ?? "repl"; - var version = assembly.GetCustomAttribute()?.InformationalVersion - ?? assembly.GetName().Version?.ToString(); - var description = _description - ?? assembly.GetCustomAttribute()?.Description; - return new ReplDocApp(name, version, description); - } - - private static bool IsFrameworkInjectedParameter(Type parameterType) => - parameterType == typeof(IServiceProvider) - || parameterType == typeof(ICoreReplApp) - || parameterType == typeof(CoreReplApp) - || parameterType == typeof(IReplSessionState) - || parameterType == typeof(IReplInteractionChannel) - || parameterType == typeof(IReplIoContext) - || parameterType == typeof(IReplKeyReader) - || string.Equals(parameterType.FullName, "Repl.Mcp.IMcpClientRoots", StringComparison.Ordinal); - - private static bool IsRequiredParameter(ParameterInfo parameter) - { - if (parameter.HasDefaultValue) - { - return false; - } - - if (!parameter.ParameterType.IsValueType) - { - return false; - } - - return Nullable.GetUnderlyingType(parameter.ParameterType) is null; - } - - private static string GetConstraintTypeName(RouteConstraintKind kind) => - kind switch - { - RouteConstraintKind.String => "string", - RouteConstraintKind.Alpha => "string", - RouteConstraintKind.Bool => "bool", - RouteConstraintKind.Email => "email", - RouteConstraintKind.Uri => "uri", - RouteConstraintKind.Url => "url", - RouteConstraintKind.Urn => "urn", - RouteConstraintKind.Time => "time", - RouteConstraintKind.Date => "date", - RouteConstraintKind.DateTime => "datetime", - RouteConstraintKind.DateTimeOffset => "datetimeoffset", - RouteConstraintKind.TimeSpan => "timespan", - RouteConstraintKind.Guid => "guid", - RouteConstraintKind.Long => "long", - RouteConstraintKind.Int => "int", - RouteConstraintKind.Custom => "custom", - _ => "string", - }; - - private static string GetFriendlyTypeName(Type type) - { - var underlying = Nullable.GetUnderlyingType(type); - if (underlying is not null) - { - return $"{GetFriendlyTypeName(underlying)}?"; - } - - if (type.IsEnum) - { - return string.Join('|', Enum.GetNames(type)); - } - - if (!type.IsGenericType) - { - return type.Name.ToLowerInvariant() switch - { - "string" => "string", - "int32" => "int", - "int64" => "long", - "boolean" => "bool", - "double" => "double", - "decimal" => "decimal", - "dateonly" => "date", - "datetime" => "datetime", - "timeonly" => "time", - "datetimeoffset" => "datetimeoffset", - "timespan" => "timespan", - "repldaterange" => "date-range", - "repldatetimerange" => "datetime-range", - "repldatetimeoffsetrange" => "datetimeoffset-range", - _ => type.Name, - }; - } - - var genericName = type.Name[..type.Name.IndexOf('`')]; - var genericArgs = string.Join(", ", type.GetGenericArguments().Select(GetFriendlyTypeName)); - return $"{genericName}<{genericArgs}>"; - } - - private static ReplDocOption BuildDocumentationOptionFromProperty( - OptionSchema schema, - PropertyInfo property, - object defaultInstance) - { - var entries = schema.Entries - .Where(entry => string.Equals(entry.ParameterName, property.Name, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - var aliases = entries - .Where(entry => entry.TokenKind is OptionSchemaTokenKind.NamedOption or OptionSchemaTokenKind.BoolFlag) - .Select(entry => entry.Token) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - var reverseAliases = entries - .Where(entry => entry.TokenKind == OptionSchemaTokenKind.ReverseFlag) - .Select(entry => entry.Token) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - var valueAliases = entries - .Where(entry => entry.TokenKind is OptionSchemaTokenKind.ValueAlias or OptionSchemaTokenKind.EnumAlias) - .Select(entry => new ReplDocValueAlias(entry.Token, entry.InjectedValue ?? string.Empty)) - .GroupBy(alias => alias.Token, StringComparer.OrdinalIgnoreCase) - .Select(group => group.First()) - .ToArray(); - var effectiveType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; - var enumValues = effectiveType.IsEnum - ? Enum.GetNames(effectiveType) - : []; - var propDefault = property.GetValue(defaultInstance); - var defaultValue = propDefault is not null - ? propDefault.ToString() - : null; - return new ReplDocOption( - Name: property.Name, - Type: GetFriendlyTypeName(property.PropertyType), - Required: false, - Description: property.GetCustomAttribute()?.Description, - Aliases: aliases, - ReverseAliases: reverseAliases, - ValueAliases: valueAliases, - EnumValues: enumValues, - DefaultValue: defaultValue); - } - - private static ReplDocOption BuildDocumentationOption(OptionSchema schema, ParameterInfo parameter) - { - var entries = schema.Entries - .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); - } - - [UnconditionalSuppressMessage( - "Trimming", - "IL2067", - Justification = "Options group types are user-defined and always preserved by the handler delegate reference.")] - private static object CreateOptionsGroupDefault(Type groupType) => - Activator.CreateInstance(groupType)!; + internal object CreateDocumentationModelInternal(string? targetPath) => + DocumentationEng.CreateDocumentationModelInternal(targetPath); - [UnconditionalSuppressMessage( - "Trimming", - "IL2070", - Justification = "Options group types are user-defined and always preserved by the handler delegate reference.")] - private static PropertyInfo[] GetOptionsGroupProperties(Type groupType) => - groupType.GetProperties(BindingFlags.Public | BindingFlags.Instance); + internal ReplDocApp BuildDocumentationApp() => + DocumentationEng.BuildDocumentationApp(); } diff --git a/src/Repl.Core/CoreReplApp.Execution.cs b/src/Repl.Core/CoreReplApp.Execution.cs new file mode 100644 index 0000000..6f302a6 --- /dev/null +++ b/src/Repl.Core/CoreReplApp.Execution.cs @@ -0,0 +1,864 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Runtime.CompilerServices; + +namespace Repl; + +public sealed partial class CoreReplApp +{ + /// + /// Runs the app in synchronous mode. + /// + /// Command-line arguments. + /// Process exit code. + public int Run(string[] args) + { + ArgumentNullException.ThrowIfNull(args); +#pragma warning disable VSTHRD002 // Sync API intentionally blocks to preserve a conventional Run(...) entrypoint. + return RunAsync(args).AsTask().GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 + } + + /// + /// Runs the app in asynchronous mode. + /// + /// Command-line arguments. + /// Cancellation token. + /// Process exit code. + public ValueTask RunAsync(string[] args, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(args); + _ = _description; + _ = _commands.Count; + _ = _middleware.Count; + _ = _options; + cancellationToken.ThrowIfCancellationRequested(); + return ExecuteCoreAsync(args, _services, cancellationToken: cancellationToken); + } + + internal ValueTask RunWithServicesAsync( + string[] args, + IServiceProvider serviceProvider, + CancellationToken cancellationToken = default) => + ExecuteCoreAsync(args, serviceProvider, cancellationToken: cancellationToken); + + /// + /// Executes a nested command invocation that preserves the session baseline. + /// Used by MCP tool calls where the global options from the initial session + /// must remain in effect even though the sub-invocation tokens don't contain them. + /// + internal ValueTask RunSubInvocationAsync( + string[] args, + IServiceProvider serviceProvider, + CancellationToken cancellationToken = default) => + ExecuteCoreAsync(args, serviceProvider, isSubInvocation: true, cancellationToken); + + private async ValueTask ExecuteCoreAsync( + IReadOnlyList args, + IServiceProvider serviceProvider, + bool isSubInvocation = false, + CancellationToken cancellationToken = default) + { + _options.Interaction.SetObserver(observer: ExecutionObserver); + try + { + var globalOptions = GlobalOptionParser.Parse(args, _options.Output, _options.Parsing); + _globalOptionsSnapshot.Update(globalOptions.CustomGlobalNamedOptions); // volatile ref swap — safe under concurrent sub-invocations + if (!isSubInvocation) + { + _globalOptionsSnapshot.SetSessionBaseline(); + } + using var runtimeStateScope = PushRuntimeState(serviceProvider, isInteractiveSession: false); + var prefixResolution = ResolveUniquePrefixes(globalOptions.RemainingTokens); + var resolvedGlobalOptions = globalOptions with { RemainingTokens = prefixResolution.Tokens }; + var ambiguousExitCode = await TryHandleAmbiguousPrefixAsync( + prefixResolution, + globalOptions, + resolvedGlobalOptions, + serviceProvider, + cancellationToken) + .ConfigureAwait(false); + if (ambiguousExitCode is not null) return ambiguousExitCode.Value; + + var preResolvedRouteResolution = TryPreResolveRouteForBanner(resolvedGlobalOptions); + if (!ShouldSuppressGlobalBanner(resolvedGlobalOptions, preResolvedRouteResolution?.Match)) + { + await TryRenderBannerAsync(resolvedGlobalOptions, serviceProvider, cancellationToken).ConfigureAwait(false); + } + + var preExecutionExitCode = await TryHandlePreExecutionAsync( + resolvedGlobalOptions, + serviceProvider, + cancellationToken) + .ConfigureAwait(false); + if (preExecutionExitCode is not null) return preExecutionExitCode.Value; + + var resolution = preResolvedRouteResolution + ?? ResolveWithDiagnostics(resolvedGlobalOptions.RemainingTokens); + var match = resolution.Match; + if (match is null) + { + return await TryHandleContextDeeplinkAsync( + resolvedGlobalOptions, + serviceProvider, + cancellationToken, + constraintFailure: resolution.ConstraintFailure, + missingArgumentsFailure: resolution.MissingArgumentsFailure) + .ConfigureAwait(false); + } + + return await ExecuteMatchedCommandAndMaybeEnterInteractiveAsync( + match, + resolvedGlobalOptions, + serviceProvider, + cancellationToken) + .ConfigureAwait(false); + } + finally + { + _options.Interaction.SetObserver(observer: null); + } + } + + private async ValueTask TryHandleAmbiguousPrefixAsync( + PrefixResolutionResult prefixResolution, + GlobalInvocationOptions globalOptions, + GlobalInvocationOptions resolvedGlobalOptions, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + if (!prefixResolution.IsAmbiguous) + { + return null; + } + + if (!ShouldSuppressGlobalBanner(resolvedGlobalOptions, preResolvedMatch: null)) + { + await TryRenderBannerAsync(resolvedGlobalOptions, serviceProvider, cancellationToken).ConfigureAwait(false); + } + + var ambiguous = CreateAmbiguousPrefixResult(prefixResolution); + _ = await RenderOutputAsync(ambiguous, globalOptions.OutputFormat, cancellationToken) + .ConfigureAwait(false); + return 1; + } + + private static bool ShouldSuppressGlobalBanner( + GlobalInvocationOptions globalOptions, + RouteMatch? preResolvedMatch) + { + if (globalOptions.HelpRequested || globalOptions.RemainingTokens.Count == 0) + { + return false; + } + + return preResolvedMatch?.Route.Command.IsProtocolPassthrough == true; + } + + private RouteResolver.RouteResolutionResult? TryPreResolveRouteForBanner(GlobalInvocationOptions globalOptions) + { + if (globalOptions.HelpRequested || globalOptions.RemainingTokens.Count == 0) + { + return null; + } + + return ResolveWithDiagnostics(globalOptions.RemainingTokens); + } + + private async ValueTask TryHandlePreExecutionAsync( + GlobalInvocationOptions options, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + var completionHandled = await TryHandleCompletionCommandAsync(options, serviceProvider, cancellationToken) + .ConfigureAwait(false); + if (completionHandled is not null) + { + return completionHandled.Value; + } + + if (options.HelpRequested) + { + var rendered = await RenderHelpAsync(options, cancellationToken).ConfigureAwait(false); + return rendered ? 0 : 1; + } + + if (options.RemainingTokens.Count == 0) + { + return await HandleEmptyInvocationAsync(options, serviceProvider, cancellationToken) + .ConfigureAwait(false); + } + + return await TryHandleAmbientInNonInteractiveAsync(options, serviceProvider, cancellationToken) + .ConfigureAwait(false); + } + + private async ValueTask ExecuteMatchedCommandAndMaybeEnterInteractiveAsync( + RouteMatch match, + GlobalInvocationOptions globalOptions, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + if (match.Route.Command.IsProtocolPassthrough + && ReplSessionIO.IsHostedSession + && !match.Route.Command.SupportsHostedProtocolPassthrough) + { + _ = await RenderOutputAsync( + Results.Error( + "protocol_passthrough_hosted_not_supported", + $"Command '{match.Route.Template.Template}' is protocol passthrough and requires a handler parameter of type IReplIoContext in hosted sessions."), + globalOptions.OutputFormat, + cancellationToken) + .ConfigureAwait(false); + return 1; + } + + if (match.Route.Command.IsProtocolPassthrough) + { + return await ExecuteProtocolPassthroughCommandAsync(match, globalOptions, serviceProvider, cancellationToken) + .ConfigureAwait(false); + } + + var (exitCode, enterInteractive) = await ExecuteMatchedCommandAsync( + match, + globalOptions, + serviceProvider, + scopeTokens: null, + cancellationToken) + .ConfigureAwait(false); + + if (enterInteractive || (exitCode == 0 && ShouldEnterInteractive(globalOptions, allowAuto: false))) + { + var matchedPathLength = globalOptions.RemainingTokens.Count - match.RemainingTokens.Count; + var matchedPathTokens = globalOptions.RemainingTokens.Take(matchedPathLength).ToArray(); + var interactiveScope = GetDeepestContextScopePath(matchedPathTokens); + return await RunInteractiveSessionAsync(interactiveScope, serviceProvider, cancellationToken).ConfigureAwait(false); + } + + return exitCode; + } + + private async ValueTask ExecuteProtocolPassthroughCommandAsync( + RouteMatch match, + GlobalInvocationOptions globalOptions, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + if (ReplSessionIO.IsSessionActive) + { + var (exitCode, _) = await ExecuteMatchedCommandAsync( + match, + globalOptions, + serviceProvider, + scopeTokens: null, + cancellationToken) + .ConfigureAwait(false); + return exitCode; + } + + using var protocolScope = ReplSessionIO.SetSession( + Console.Error, + Console.In, + ansiMode: AnsiMode.Never, + commandOutput: Console.Out, + error: Console.Error, + isHostedSession: false); + var (code, _) = await ExecuteMatchedCommandAsync( + match, + globalOptions, + serviceProvider, + scopeTokens: null, + cancellationToken) + .ConfigureAwait(false); + return code; + } + + private async ValueTask HandleEmptyInvocationAsync( + GlobalInvocationOptions globalOptions, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + if (ShouldEnterInteractive(globalOptions, allowAuto: true)) + { + return await RunInteractiveSessionAsync([], serviceProvider, cancellationToken).ConfigureAwait(false); + } + + var helpText = BuildHumanHelp([]); + await ReplSessionIO.Output.WriteLineAsync(helpText).ConfigureAwait(false); + return 0; + } + + private async ValueTask TryHandleCompletionCommandAsync( + GlobalInvocationOptions options, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + if (options.RemainingTokens.Count == 0 + || !string.Equals(options.RemainingTokens[0], "complete", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var completed = await HandleCompletionAmbientCommandAsync( + commandTokens: options.RemainingTokens.Skip(1).ToArray(), + scopeTokens: [], + serviceProvider: serviceProvider, + cancellationToken: cancellationToken) + .ConfigureAwait(false); + return completed ? 0 : 1; + } + + private async ValueTask TryHandleAmbientInNonInteractiveAsync( + GlobalInvocationOptions options, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + if (options.RemainingTokens.Count != 1) + { + return null; + } + + var token = options.RemainingTokens[0]; + AmbientCommandOutcome ambientOutcome; + if (string.Equals(token, "exit", StringComparison.OrdinalIgnoreCase)) + { + ambientOutcome = await HandleExitAmbientCommandAsync().ConfigureAwait(false); + } + else if (string.Equals(token, "..", StringComparison.Ordinal)) + { + ambientOutcome = await HandleUpAmbientCommandAsync(scopeTokens: [], isInteractiveSession: false) + .ConfigureAwait(false); + } + else + { + return null; + } + + return ambientOutcome switch + { + AmbientCommandOutcome.Exit => 0, + AmbientCommandOutcome.Handled => 0, + AmbientCommandOutcome.HandledError => 1, + _ => null, + }; + } + + private async ValueTask TryRenderBannerAsync( + GlobalInvocationOptions globalOptions, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + if (globalOptions.LogoSuppressed) + { + _allBannersSuppressed.Value = true; + } + + if (_bannerRendered.Value || _allBannersSuppressed.Value || !_options.Output.BannerEnabled) + { + return; + } + + var requestedFormat = string.IsNullOrWhiteSpace(globalOptions.OutputFormat) + ? _options.Output.DefaultFormat + : globalOptions.OutputFormat; + if (!_options.Output.BannerFormats.Contains(requestedFormat)) + { + return; + } + + var banner = BuildBannerText(); + if (!string.IsNullOrWhiteSpace(banner)) + { + await ReplSessionIO.Output.WriteLineAsync(banner).ConfigureAwait(false); + } + + if (_banner is not null) + { + await InvokeBannerAsync(_banner, serviceProvider, cancellationToken).ConfigureAwait(false); + } + + _bannerRendered.Value = true; + } + + private async ValueTask TryHandleContextDeeplinkAsync( + GlobalInvocationOptions globalOptions, + IServiceProvider serviceProvider, + CancellationToken cancellationToken, + RouteResolver.RouteConstraintFailure? constraintFailure = null, + RouteResolver.RouteMissingArgumentsFailure? missingArgumentsFailure = null) + { + var activeGraph = ResolveActiveRoutingGraph(); + var contextMatch = ContextResolver.ResolveExact(activeGraph.Contexts, globalOptions.RemainingTokens, _options.Parsing); + if (contextMatch is null) + { + var failure = CreateRouteResolutionFailureResult( + tokens: globalOptions.RemainingTokens, + constraintFailure, + missingArgumentsFailure); + _ = await RenderOutputAsync(failure, globalOptions.OutputFormat, cancellationToken) + .ConfigureAwait(false); + return 1; + } + + var contextValidation = await ValidateContextAsync(contextMatch, serviceProvider, cancellationToken) + .ConfigureAwait(false); + if (!contextValidation.IsValid) + { + _ = await RenderOutputAsync( + contextValidation.Failure, + globalOptions.OutputFormat, + cancellationToken) + .ConfigureAwait(false); + return 1; + } + + if (!ShouldEnterInteractive(globalOptions, allowAuto: true)) + { + var helpText = BuildHumanHelp(globalOptions.RemainingTokens); + await ReplSessionIO.Output.WriteLineAsync(helpText).ConfigureAwait(false); + return 0; + } + + return await RunInteractiveSessionAsync(globalOptions.RemainingTokens.ToArray(), serviceProvider, cancellationToken) + .ConfigureAwait(false); + } + + [SuppressMessage( + "Maintainability", + "MA0051:Method is too long", + Justification = "Execution path intentionally keeps validation, binding, middleware and rendering in one place.")] + internal async ValueTask<(int ExitCode, bool EnterInteractive)> ExecuteMatchedCommandAsync( + RouteMatch match, + GlobalInvocationOptions globalOptions, + IServiceProvider serviceProvider, + List? scopeTokens, + CancellationToken cancellationToken) + { + var activeGraph = ResolveActiveRoutingGraph(); + _options.Interaction.SetPrefilledAnswers(globalOptions.PromptAnswers); + 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, false); + } + + 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, false); + } + 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, + cancellationToken); + try + { + var arguments = HandlerArgumentBinder.Bind(match.Route.Command.Handler, bindingContext); + var contextFailure = await ValidateContextsForMatchAsync( + match, + matchedPathTokens, + activeGraph.Contexts, + serviceProvider, + cancellationToken) + .ConfigureAwait(false); + if (contextFailure is not null) + { + _ = await RenderOutputAsync(contextFailure, globalOptions.OutputFormat, cancellationToken) + .ConfigureAwait(false); + return (1, false); + } + + await TryRenderCommandBannerAsync(match.Route.Command, globalOptions.OutputFormat, serviceProvider, cancellationToken) + .ConfigureAwait(false); + var result = await ExecuteWithMiddlewareAsync( + match.Route.Command.Handler, + arguments, + serviceProvider, + cancellationToken) + .ConfigureAwait(false); + + if (TupleDecomposer.IsTupleResult(result, out var tuple)) + { + return await RenderTupleResultAsync(tuple, scopeTokens, globalOptions, cancellationToken) + .ConfigureAwait(false); + } + + if (result is EnterInteractiveResult enterInteractive) + { + if (enterInteractive.Payload is not null) + { + _ = await RenderOutputAsync(enterInteractive.Payload, globalOptions.OutputFormat, cancellationToken, scopeTokens is not null) + .ConfigureAwait(false); + } + + return (0, true); + } + + var normalizedResult = ApplyNavigationResult(result, scopeTokens); + ExecutionObserver?.OnResult(normalizedResult); + var rendered = await RenderOutputAsync(normalizedResult, globalOptions.OutputFormat, cancellationToken, scopeTokens is not null) + .ConfigureAwait(false); + return (rendered ? ComputeExitCode(normalizedResult) : 1, false); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + _ = await RenderOutputAsync(Results.Validation(ex.Message), globalOptions.OutputFormat, cancellationToken) + .ConfigureAwait(false); + return (1, false); + } + catch (Exception ex) + { + var errorMessage = ex is TargetInvocationException { InnerException: not null } tie + ? tie.InnerException?.Message ?? ex.Message + : ex.Message; + _ = await RenderOutputAsync( + Results.Error("execution_error", errorMessage), + globalOptions.OutputFormat, + cancellationToken) + .ConfigureAwait(false); + return (1, false); + } + } + + private async ValueTask<(int ExitCode, bool EnterInteractive)> RenderTupleResultAsync( + ITuple tuple, + List? scopeTokens, + GlobalInvocationOptions globalOptions, + CancellationToken cancellationToken) + { + var isInteractive = scopeTokens is not null; + var exitCode = 0; + var enterInteractive = false; + + for (var i = 0; i < tuple.Length; i++) + { + var element = tuple[i]; + + // EnterInteractiveResult: extract payload (if any) and signal interactive entry. + if (element is EnterInteractiveResult eir) + { + enterInteractive = true; + element = eir.Payload; + if (element is null) + { + continue; + } + } + + var isLast = i == tuple.Length - 1; + + // Navigation results: only apply navigation on the last element. + var normalized = element is ReplNavigationResult nav && !isLast + ? nav.Payload + : isLast + ? ApplyNavigationResult(element, scopeTokens) + : element; + + ExecutionObserver?.OnResult(normalized); + + var rendered = await RenderOutputAsync(normalized, globalOptions.OutputFormat, cancellationToken, isInteractive) + .ConfigureAwait(false); + + if (!rendered) + { + return (1, false); + } + + if (isLast) + { + exitCode = ComputeExitCode(normalized); + } + } + + return (exitCode, enterInteractive); + } + + private static int ComputeExitCode(object? result) + { + if (result is IExitResult exitResult) + { + return exitResult.ExitCode; + } + + if (result is not IReplResult replResult) + { + return 0; + } + + var kind = replResult.Kind.ToLowerInvariant(); + if (kind is "text" or "success") + { + return 0; + } + + if (kind is "error" or "validation" or "not_found") + { + return 1; + } + + return 1; + } + + internal async ValueTask RenderOutputAsync( + object? result, + string? requestedFormat, + CancellationToken cancellationToken, + bool isInteractive = false) + { + if (result is IExitResult exitResult) + { + if (exitResult.Payload is null) + { + return true; + } + + result = exitResult.Payload; + } + + var format = string.IsNullOrWhiteSpace(requestedFormat) + ? _options.Output.DefaultFormat + : requestedFormat; + if (!_options.Output.Transformers.TryGetValue(format, out var transformer)) + { + // Unknown format is a user-facing validation issue; avoid silent failures from exception swallowing. + await ReplSessionIO.Output.WriteLineAsync($"Error: unknown output format '{format}'.").ConfigureAwait(false); + return false; + } + + var payload = await transformer.TransformAsync(result, cancellationToken).ConfigureAwait(false); + payload = TryColorizeStructuredPayload(payload, format, isInteractive); + if (!string.IsNullOrEmpty(payload)) + { + await ReplSessionIO.Output.WriteLineAsync(payload).ConfigureAwait(false); + } + + return true; + } + + private string TryColorizeStructuredPayload(string payload, string format, bool isInteractive) + { + if (string.IsNullOrEmpty(payload) + || !isInteractive + || !_options.Output.ColorizeStructuredInteractive + || !_options.Output.IsAnsiEnabled() + || !string.Equals(format, "json", StringComparison.OrdinalIgnoreCase)) + { + return payload; + } + + return JsonAnsiColorizer.Colorize(payload, _options.Output.ResolvePalette()); + } + + internal async ValueTask RenderHelpAsync( + GlobalInvocationOptions globalOptions, + CancellationToken cancellationToken) + { + var activeGraph = ResolveActiveRoutingGraph(); + var discoverableRoutes = ResolveDiscoverableRoutes( + activeGraph.Routes, + activeGraph.Contexts, + globalOptions.RemainingTokens, + StringComparison.OrdinalIgnoreCase); + var discoverableContexts = ResolveDiscoverableContexts( + activeGraph.Contexts, + globalOptions.RemainingTokens, + StringComparison.OrdinalIgnoreCase); + var requestedFormat = string.IsNullOrWhiteSpace(globalOptions.OutputFormat) + ? _options.Output.DefaultFormat + : globalOptions.OutputFormat; + if (string.Equals(requestedFormat, "human", StringComparison.OrdinalIgnoreCase)) + { + var helpText = BuildHumanHelp(globalOptions.RemainingTokens); + await ReplSessionIO.Output.WriteLineAsync(helpText).ConfigureAwait(false); + return true; + } + + var machineHelp = HelpTextBuilder.BuildModel( + discoverableRoutes, + discoverableContexts, + globalOptions.RemainingTokens, + _options.Parsing); + return await RenderOutputAsync(machineHelp, requestedFormat, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask ExecuteWithMiddlewareAsync( + Delegate handler, + object?[] arguments, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + object? result = null; + var context = new ReplExecutionContext(serviceProvider, cancellationToken); + var index = -1; + + async ValueTask NextAsync() + { + index++; + if (index == _middleware.Count) + { + result = await CommandInvoker + .InvokeAsync(handler, arguments) + .ConfigureAwait(false); + return; + } + + var middleware = _middleware[index]; + await middleware(context, NextAsync).ConfigureAwait(false); + } + + await NextAsync().ConfigureAwait(false); + return result; + } + + private static object? ApplyNavigationResult(object? result, List? scopeTokens) + { + if (result is not ReplNavigationResult navigation) + { + return result; + } + + if (scopeTokens is null) + { + return navigation.Payload; + } + + ApplyNavigation(scopeTokens, navigation); + return navigation.Payload; + } + + private static void ApplyNavigation(List scopeTokens, ReplNavigationResult navigation) + { + if (navigation.Kind == ReplNavigationKind.Up) + { + if (scopeTokens.Count > 0) + { + scopeTokens.RemoveAt(scopeTokens.Count - 1); + } + + return; + } + + if (!string.IsNullOrWhiteSpace(navigation.TargetPath)) + { + scopeTokens.Clear(); + scopeTokens.AddRange( + navigation.TargetPath + .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + } + } + + 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, + mergedNamedOptions, + parsedOptions.PositionalArguments, + match.Route.OptionSchema, + commandParsingOptions.OptionCaseSensitivity, + contextValues, + _options.Parsing.NumericFormatProvider, + serviceProvider, + _options.Interaction, + cancellationToken); + } + + 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.Interactive.cs b/src/Repl.Core/CoreReplApp.Interactive.cs index 0bb9a70..18e52aa 100644 --- a/src/Repl.Core/CoreReplApp.Interactive.cs +++ b/src/Repl.Core/CoreReplApp.Interactive.cs @@ -1,1859 +1,42 @@ -using System.ComponentModel; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Reflection; - namespace Repl; public sealed partial class CoreReplApp { - private bool ShouldEnterInteractive(GlobalInvocationOptions globalOptions, bool allowAuto) - { - if (globalOptions.InteractivePrevented) - { - return false; - } - - if (globalOptions.InteractiveForced) - { - return true; - } + private InteractiveSession? _interactiveSession; + internal InteractiveSession Interactive => _interactiveSession ??= new(this); - return _options.Interactive.InteractivePolicy switch - { - InteractivePolicy.Force => true, - InteractivePolicy.Prevent => false, - _ => allowAuto, - }; - } + private bool ShouldEnterInteractive(GlobalInvocationOptions globalOptions, bool allowAuto) => + Interactive.ShouldEnterInteractive(globalOptions, allowAuto); - private async ValueTask RunInteractiveSessionAsync( + private ValueTask RunInteractiveSessionAsync( IReadOnlyList initialScopeTokens, IServiceProvider serviceProvider, - CancellationToken cancellationToken) - { - using var runtimeStateScope = PushRuntimeState(serviceProvider, isInteractiveSession: true); - using var cancelHandler = new CancelKeyHandler(); - var scopeTokens = initialScopeTokens.ToList(); - var historyProvider = serviceProvider.GetService(typeof(IHistoryProvider)) as IHistoryProvider; - string? lastHistoryEntry = null; - await _shellCompletionRuntime.HandleStartupAsync(serviceProvider, cancellationToken).ConfigureAwait(false); - while (true) - { - cancellationToken.ThrowIfCancellationRequested(); - var readResult = await ReadInteractiveInputAsync( - scopeTokens, - historyProvider, - serviceProvider, - cancellationToken) - .ConfigureAwait(false); - if (readResult.Escaped) - { - await ReplSessionIO.Output.WriteLineAsync().ConfigureAwait(false); - continue; // Esc at bare prompt → fresh line. - } - - var line = readResult.Line; - if (line is null) - { - return 0; - } - - var inputTokens = TokenizeInteractiveInput(line); - if (inputTokens.Count == 0) - { - continue; - } - - lastHistoryEntry = await TryAppendHistoryAsync( - historyProvider, - lastHistoryEntry, - line, - cancellationToken) - .ConfigureAwait(false); - - var outcome = await DispatchInteractiveCommandAsync( - inputTokens, scopeTokens, serviceProvider, cancelHandler, cancellationToken) - .ConfigureAwait(false); - if (outcome == AmbientCommandOutcome.Exit) - { - return 0; - } - } - } - - private async ValueTask ReadInteractiveInputAsync( - IReadOnlyList scopeTokens, - IHistoryProvider? historyProvider, - IServiceProvider serviceProvider, - CancellationToken cancellationToken) - { - await ReplSessionIO.Output.WriteAsync(BuildPrompt(scopeTokens)).ConfigureAwait(false); - await ReplSessionIO.Output.WriteAsync(' ').ConfigureAwait(false); - var effectiveMode = ResolveEffectiveAutocompleteMode(serviceProvider); - var renderMode = ResolveAutocompleteRenderMode(effectiveMode); - var colorStyles = ResolveAutocompleteColorStyles(renderMode == ConsoleLineReader.AutocompleteRenderMode.Rich); - return await ConsoleLineReader.ReadLineAsync( - historyProvider, - effectiveMode == AutocompleteMode.Off - ? null - : (request, ct) => ResolveAutocompleteAsync(request, scopeTokens, serviceProvider, ct), - renderMode, - _options.Interactive.Autocomplete.MaxVisibleSuggestions, - _options.Interactive.Autocomplete.Presentation, - _options.Interactive.Autocomplete.LiveHintEnabled - && renderMode == ConsoleLineReader.AutocompleteRenderMode.Rich, - _options.Interactive.Autocomplete.ColorizeInputLine, - _options.Interactive.Autocomplete.ColorizeHintAndMenu, - colorStyles, - cancellationToken) - .ConfigureAwait(false); - } + CancellationToken cancellationToken) => + Interactive.RunInteractiveSessionAsync(initialScopeTokens, serviceProvider, cancellationToken); - private static async ValueTask TryAppendHistoryAsync( - IHistoryProvider? historyProvider, - string? previousEntry, - string line, - CancellationToken cancellationToken) - { - if (historyProvider is null || string.Equals(line, previousEntry, StringComparison.Ordinal)) - { - return previousEntry; - } + private string[] GetDeepestContextScopePath(IReadOnlyList matchedPathTokens) => + Interactive.GetDeepestContextScopePath(matchedPathTokens); - // Persist raw input before dispatch so ambient commands are also traceable. - // Skip consecutive duplicates, like standard shell behavior (ignoredups). - await historyProvider.AddAsync(entry: line, cancellationToken).ConfigureAwait(false); - return line; - } - - private async ValueTask DispatchInteractiveCommandAsync( - List inputTokens, - List scopeTokens, - IServiceProvider serviceProvider, - CancelKeyHandler cancelHandler, - CancellationToken cancellationToken) - { - var ambientOutcome = await TryHandleAmbientCommandAsync( - inputTokens, - scopeTokens, - serviceProvider, - isInteractiveSession: true, - cancellationToken) - .ConfigureAwait(false); - if (ambientOutcome is AmbientCommandOutcome.Exit - or AmbientCommandOutcome.Handled - or AmbientCommandOutcome.HandledError) - { - return ambientOutcome; - } - - var invocationTokens = scopeTokens.Concat(inputTokens).ToArray(); - var globalOptions = GlobalOptionParser.Parse(invocationTokens, _options.Output, _options.Parsing); - _globalOptionsSnapshot.Update(globalOptions.CustomGlobalNamedOptions); - var prefixResolution = ResolveUniquePrefixes(globalOptions.RemainingTokens); - if (prefixResolution.IsAmbiguous) - { - var ambiguous = CreateAmbiguousPrefixResult(prefixResolution); - _ = await RenderOutputAsync(ambiguous, globalOptions.OutputFormat, cancellationToken, isInteractive: true) - .ConfigureAwait(false); - return AmbientCommandOutcome.Handled; - } - - var resolvedOptions = globalOptions with { RemainingTokens = prefixResolution.Tokens }; - await ExecuteWithCancellationAsync(resolvedOptions, scopeTokens, serviceProvider, cancelHandler, cancellationToken) - .ConfigureAwait(false); - return AmbientCommandOutcome.Handled; - } - - private async ValueTask ExecuteWithCancellationAsync( - GlobalInvocationOptions resolvedOptions, - List scopeTokens, - IServiceProvider serviceProvider, - CancelKeyHandler cancelHandler, - CancellationToken cancellationToken) - { - using var commandCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - SetCommandTokenOnChannel(serviceProvider, commandCts.Token); - cancelHandler.SetCommandCts(commandCts); - - try - { - await ExecuteInteractiveInputAsync(resolvedOptions, scopeTokens, serviceProvider, commandCts.Token) - .ConfigureAwait(false); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - await ReplSessionIO.Output.WriteLineAsync("Cancelled.").ConfigureAwait(false); - } - finally - { - cancelHandler.SetCommandCts(cts: null); - SetCommandTokenOnChannel(serviceProvider, default); - } - } - - private static void SetCommandTokenOnChannel(IServiceProvider serviceProvider, CancellationToken ct) - { - if (serviceProvider.GetService(typeof(IReplInteractionChannel)) is ICommandTokenReceiver receiver) - { - receiver.SetCommandToken(ct); - } - } - - - private async ValueTask ExecuteInteractiveInputAsync( - GlobalInvocationOptions globalOptions, - List scopeTokens, - IServiceProvider serviceProvider, - CancellationToken cancellationToken) - { - var activeGraph = ResolveActiveRoutingGraph(); - if (globalOptions.HelpRequested) - { - _ = await RenderHelpAsync(globalOptions, cancellationToken).ConfigureAwait(false); - return; - } - - var resolution = ResolveWithDiagnostics(globalOptions.RemainingTokens, activeGraph.Routes); - var match = resolution.Match; - if (match is not null) - { - await ExecuteMatchedCommandAsync(match, globalOptions, serviceProvider, scopeTokens, cancellationToken).ConfigureAwait(false); - return; - } - - var contextMatch = ContextResolver.ResolveExact(activeGraph.Contexts, globalOptions.RemainingTokens, _options.Parsing); - if (contextMatch is not null) - { - var contextValidation = await ValidateContextAsync(contextMatch, serviceProvider, cancellationToken) - .ConfigureAwait(false); - if (!contextValidation.IsValid) - { - _ = await RenderOutputAsync( - contextValidation.Failure, - globalOptions.OutputFormat, - cancellationToken, - isInteractive: true) - .ConfigureAwait(false); - return; - } - - scopeTokens.Clear(); - scopeTokens.AddRange(globalOptions.RemainingTokens); - - if (contextMatch.Context.Banner is { } contextBanner && ShouldRenderBanner(globalOptions.OutputFormat)) - { - await InvokeBannerAsync(contextBanner, serviceProvider, cancellationToken).ConfigureAwait(false); - } - - return; - } - - var failure = CreateRouteResolutionFailureResult( - tokens: globalOptions.RemainingTokens, - resolution.ConstraintFailure, - resolution.MissingArgumentsFailure); - _ = await RenderOutputAsync( - failure, - globalOptions.OutputFormat, - cancellationToken, - isInteractive: true) - .ConfigureAwait(false); - } - - [SuppressMessage( - "Maintainability", - "MA0051:Method is too long", - Justification = "Ambient command routing keeps dispatch table explicit and easy to scan.")] - private async ValueTask TryHandleAmbientCommandAsync( + private ValueTask TryHandleAmbientCommandAsync( List inputTokens, List scopeTokens, IServiceProvider serviceProvider, bool isInteractiveSession, - CancellationToken cancellationToken) - { - if (inputTokens.Count == 0) - { - return AmbientCommandOutcome.NotHandled; - } - - var token = inputTokens[0]; - if (IsHelpToken(token)) - { - var helpPath = scopeTokens.Concat(inputTokens.Skip(1)).ToArray(); - var helpText = BuildHumanHelp(helpPath); - await ReplSessionIO.Output.WriteLineAsync(helpText).ConfigureAwait(false); - return AmbientCommandOutcome.Handled; - } - - if (inputTokens.Count == 1 && string.Equals(token, "..", StringComparison.Ordinal)) - { - return await HandleUpAmbientCommandAsync(scopeTokens, isInteractiveSession).ConfigureAwait(false); - } - - if (inputTokens.Count == 1 && string.Equals(token, "exit", StringComparison.OrdinalIgnoreCase)) - { - return await HandleExitAmbientCommandAsync().ConfigureAwait(false); - } - - if (string.Equals(token, "complete", StringComparison.OrdinalIgnoreCase)) - { - _ = await HandleCompletionAmbientCommandAsync( - inputTokens.Skip(1).ToArray(), - scopeTokens, - serviceProvider, - cancellationToken) - .ConfigureAwait(false); - return AmbientCommandOutcome.Handled; - } + CancellationToken cancellationToken) => + Interactive.TryHandleAmbientCommandAsync(inputTokens, scopeTokens, serviceProvider, isInteractiveSession, cancellationToken); - if (string.Equals(token, "autocomplete", StringComparison.OrdinalIgnoreCase)) - { - return await HandleAutocompleteAmbientCommandAsync( - inputTokens.Skip(1).ToArray(), - serviceProvider, - isInteractiveSession) - .ConfigureAwait(false); - } - - if (string.Equals(token, "history", StringComparison.OrdinalIgnoreCase)) - { - return await HandleHistoryAmbientCommandAsync( - inputTokens.Skip(1).ToArray(), - serviceProvider, - isInteractiveSession, - cancellationToken) - .ConfigureAwait(false); - } - - if (_options.AmbientCommands.CustomCommands.TryGetValue(token, out var customAmbient)) - { - await ExecuteCustomAmbientCommandAsync(customAmbient, serviceProvider, cancellationToken) - .ConfigureAwait(false); - return AmbientCommandOutcome.Handled; - } - - return AmbientCommandOutcome.NotHandled; - } - - private static async ValueTask HandleUpAmbientCommandAsync( + private static ValueTask HandleUpAmbientCommandAsync( List scopeTokens, - bool isInteractiveSession) - { - if (scopeTokens.Count > 0) - { - scopeTokens.RemoveAt(scopeTokens.Count - 1); - return AmbientCommandOutcome.Handled; - } - - if (!isInteractiveSession) - { - await ReplSessionIO.Output.WriteLineAsync("Error: '..' is available only in interactive mode.").ConfigureAwait(false); - return AmbientCommandOutcome.HandledError; - } - - return AmbientCommandOutcome.Handled; - } - - private async ValueTask HandleExitAmbientCommandAsync() - { - if (_options.AmbientCommands.ExitCommandEnabled) - { - return AmbientCommandOutcome.Exit; - } + bool isInteractiveSession) => + InteractiveSession.HandleUpAmbientCommandAsync(scopeTokens, isInteractiveSession); - await ReplSessionIO.Output.WriteLineAsync("Error: exit command is disabled.").ConfigureAwait(false); - return AmbientCommandOutcome.HandledError; - } + private ValueTask HandleExitAmbientCommandAsync() => + Interactive.HandleExitAmbientCommandAsync(); - private static async ValueTask HandleHistoryAmbientCommandAsync( - IReadOnlyList commandTokens, - IServiceProvider serviceProvider, - bool isInteractiveSession, - CancellationToken cancellationToken) - { - if (!isInteractiveSession) - { - await ReplSessionIO.Output.WriteLineAsync("Error: history is available only in interactive mode.").ConfigureAwait(false); - return AmbientCommandOutcome.HandledError; - } - - await HandleHistoryAmbientCommandCoreAsync(commandTokens, serviceProvider, cancellationToken).ConfigureAwait(false); - return AmbientCommandOutcome.Handled; - } - - private async ValueTask HandleAutocompleteAmbientCommandAsync( - string[] commandTokens, - IServiceProvider serviceProvider, - bool isInteractiveSession) - { - if (!isInteractiveSession) - { - await ReplSessionIO.Output.WriteLineAsync("Error: autocomplete is available only in interactive mode.") - .ConfigureAwait(false); - return AmbientCommandOutcome.HandledError; - } - - var sessionState = serviceProvider.GetService(typeof(IReplSessionState)) as IReplSessionState; - if (commandTokens.Length == 0 - || (commandTokens.Length == 1 && string.Equals(commandTokens[0], "show", StringComparison.OrdinalIgnoreCase))) - { - var configured = _options.Interactive.Autocomplete.Mode; - var overrideMode = sessionState?.Get(AutocompleteModeSessionStateKey); - var effective = ResolveEffectiveAutocompleteMode(serviceProvider); - await ReplSessionIO.Output.WriteLineAsync( - $"Autocomplete mode: configured={configured}, override={(overrideMode ?? "none")}, effective={effective}") - .ConfigureAwait(false); - return AmbientCommandOutcome.Handled; - } - - if (commandTokens.Length == 2 && string.Equals(commandTokens[0], "mode", StringComparison.OrdinalIgnoreCase)) - { - if (!Enum.TryParse(commandTokens[1], ignoreCase: true, out var mode)) - { - await ReplSessionIO.Output.WriteLineAsync("Error: autocomplete mode must be one of off|auto|basic|rich.") - .ConfigureAwait(false); - return AmbientCommandOutcome.HandledError; - } - - sessionState?.Set(AutocompleteModeSessionStateKey, mode.ToString()); - var effective = ResolveEffectiveAutocompleteMode(serviceProvider); - await ReplSessionIO.Output.WriteLineAsync($"Autocomplete mode set to {mode} (effective: {effective}).") - .ConfigureAwait(false); - return AmbientCommandOutcome.Handled; - } - - await ReplSessionIO.Output.WriteLineAsync("Error: usage: autocomplete [show] | autocomplete mode .") - .ConfigureAwait(false); - return AmbientCommandOutcome.HandledError; - } - - private async ValueTask HandleCompletionAmbientCommandAsync( + private ValueTask HandleCompletionAmbientCommandAsync( IReadOnlyList commandTokens, IReadOnlyList scopeTokens, IServiceProvider serviceProvider, - CancellationToken cancellationToken) - { - var parsed = InvocationOptionParser.Parse(commandTokens); - if (!parsed.NamedOptions.TryGetValue("target", out var targetValues) || targetValues.Count == 0) - { - await ReplSessionIO.Output.WriteLineAsync("Error: complete requires --target .").ConfigureAwait(false); - return false; - } - - var target = targetValues[0]; - var input = parsed.NamedOptions.TryGetValue("input", out var inputValues) && inputValues.Count > 0 - ? inputValues[0] - : string.Empty; - var fullCommandPath = scopeTokens.Concat(parsed.PositionalArguments).ToArray(); - var resolvedPath = ResolveUniquePrefixes(fullCommandPath); - if (resolvedPath.IsAmbiguous) - { - var ambiguous = CreateAmbiguousPrefixResult(resolvedPath); - _ = await RenderOutputAsync(ambiguous, requestedFormat: null, cancellationToken).ConfigureAwait(false); - return false; - } - - var match = Resolve(resolvedPath.Tokens); - if (match is null || match.RemainingTokens.Count > 0) - { - await ReplSessionIO.Output.WriteLineAsync("Error: complete requires a terminal command path.").ConfigureAwait(false); - return false; - } - - if (!match.Route.Command.Completions.TryGetValue(target, out var completion)) - { - await ReplSessionIO.Output.WriteLineAsync($"Error: no completion provider registered for '{target}'.").ConfigureAwait(false); - return false; - } - - var context = new CompletionContext(serviceProvider); - var candidates = await completion(context, input, cancellationToken).ConfigureAwait(false); - if (candidates.Count == 0) - { - await ReplSessionIO.Output.WriteLineAsync("(none)").ConfigureAwait(false); - return true; - } - - foreach (var candidate in candidates) - { - await ReplSessionIO.Output.WriteLineAsync(candidate).ConfigureAwait(false); - } - - return true; - } - - [SuppressMessage( - "Maintainability", - "MA0051:Method is too long", - Justification = "History command parsing and rendering are intentionally kept together.")] - private static async ValueTask HandleHistoryAmbientCommandCoreAsync( - IReadOnlyList commandTokens, - IServiceProvider serviceProvider, - CancellationToken cancellationToken) - { - var parsed = InvocationOptionParser.Parse(commandTokens); - var limit = 20; - if (parsed.NamedOptions.TryGetValue("limit", out var limitValues) && limitValues.Count > 0) - { - limit = int.TryParse( - limitValues[0], - style: NumberStyles.Integer, - provider: CultureInfo.InvariantCulture, - result: out var parsedLimit) && parsedLimit > 0 - ? parsedLimit - : throw new InvalidOperationException("history --limit must be a positive integer."); - } - - var historyProvider = serviceProvider.GetService(typeof(IHistoryProvider)) as IHistoryProvider; - if (historyProvider is null) - { - await ReplSessionIO.Output.WriteLineAsync("(history unavailable)").ConfigureAwait(false); - return; - } - - var entries = await historyProvider.GetRecentAsync(maxCount: limit, cancellationToken).ConfigureAwait(false); - if (entries.Count == 0) - { - await ReplSessionIO.Output.WriteLineAsync("(empty)").ConfigureAwait(false); - return; - } - - foreach (var entry in entries) - { - await ReplSessionIO.Output.WriteLineAsync(entry).ConfigureAwait(false); - } - } - - private async ValueTask ExecuteCustomAmbientCommandAsync( - AmbientCommandDefinition command, - IServiceProvider serviceProvider, - CancellationToken cancellationToken) - { - var bindingContext = new InvocationBindingContext( - routeValues: new Dictionary(StringComparer.OrdinalIgnoreCase), - namedOptions: new Dictionary>(StringComparer.OrdinalIgnoreCase), - positionalArguments: [], - optionSchema: Internal.Options.OptionSchema.Empty, - optionCaseSensitivity: _options.Parsing.OptionCaseSensitivity, - contextValues: [], - numericFormatProvider: _options.Parsing.NumericFormatProvider ?? CultureInfo.InvariantCulture, - serviceProvider: serviceProvider, - interactionOptions: _options.Interaction, - cancellationToken: cancellationToken); - var arguments = HandlerArgumentBinder.Bind(command.Handler, bindingContext); - await CommandInvoker.InvokeAsync(command.Handler, arguments).ConfigureAwait(false); - } - - private string[] GetDeepestContextScopePath(IReadOnlyList matchedPathTokens) - { - var activeGraph = ResolveActiveRoutingGraph(); - var contextMatches = ContextResolver.ResolvePrefixes(activeGraph.Contexts, matchedPathTokens, _options.Parsing); - var longestPrefixLength = 0; - foreach (var contextMatch in contextMatches) - { - var prefixLength = contextMatch.Context.Template.Segments.Count; - if (prefixLength > longestPrefixLength) - { - longestPrefixLength = prefixLength; - } - } - - return longestPrefixLength == 0 - ? [] - : matchedPathTokens.Take(longestPrefixLength).ToArray(); - } - - private string BuildPrompt(IReadOnlyList scopeTokens) - { - var basePrompt = _options.Interactive.Prompt; - if (scopeTokens.Count == 0) - { - return basePrompt; - } - - var promptWithoutSuffix = basePrompt.EndsWith('>') - ? basePrompt[..^1] - : basePrompt; - var scope = string.Join('/', scopeTokens); - return string.IsNullOrWhiteSpace(promptWithoutSuffix) - ? $"[{scope}]>" - : $"{promptWithoutSuffix} [{scope}]>"; - } - - private AutocompleteMode ResolveEffectiveAutocompleteMode(IServiceProvider serviceProvider) - { - var sessionState = serviceProvider.GetService(typeof(IReplSessionState)) as IReplSessionState; - if (sessionState?.Get(AutocompleteModeSessionStateKey) is { } overrideText - && Enum.TryParse(overrideText, ignoreCase: true, out var overrideMode)) - { - return overrideMode == AutocompleteMode.Auto - ? ResolveAutoAutocompleteMode(serviceProvider) - : overrideMode; - } - - var configured = _options.Interactive.Autocomplete.Mode; - return configured == AutocompleteMode.Auto - ? ResolveAutoAutocompleteMode(serviceProvider) - : configured; - } - - private static AutocompleteMode ResolveAutoAutocompleteMode(IServiceProvider serviceProvider) - { - if (!ReplSessionIO.IsSessionActive - && !Console.IsInputRedirected - && !Console.IsOutputRedirected) - { - // Local interactive console: prefer rich rendering so menu redraw is in-place. - return AutocompleteMode.Rich; - } - - var info = serviceProvider.GetService(typeof(IReplSessionInfo)) as IReplSessionInfo; - var caps = info?.TerminalCapabilities ?? TerminalCapabilities.None; - if (caps.HasFlag(TerminalCapabilities.Ansi) && caps.HasFlag(TerminalCapabilities.VtInput)) - { - return AutocompleteMode.Rich; - } - - if (caps.HasFlag(TerminalCapabilities.VtInput) || caps.HasFlag(TerminalCapabilities.Ansi)) - { - return AutocompleteMode.Basic; - } - - return AutocompleteMode.Basic; - } - - private static ConsoleLineReader.AutocompleteRenderMode ResolveAutocompleteRenderMode(AutocompleteMode mode) => - mode switch - { - AutocompleteMode.Rich => ConsoleLineReader.AutocompleteRenderMode.Rich, - AutocompleteMode.Basic => ConsoleLineReader.AutocompleteRenderMode.Basic, - _ => ConsoleLineReader.AutocompleteRenderMode.Off, - }; - - private ConsoleLineReader.AutocompleteColorStyles ResolveAutocompleteColorStyles(bool enabled) - { - if (!enabled) - { - return ConsoleLineReader.AutocompleteColorStyles.Empty; - } - - var palette = _options.Output.ResolvePalette(); - return new ConsoleLineReader.AutocompleteColorStyles( - CommandStyle: palette.AutocompleteCommandStyle, - ContextStyle: palette.AutocompleteContextStyle, - ParameterStyle: palette.AutocompleteParameterStyle, - AmbiguousStyle: palette.AutocompleteAmbiguousStyle, - ErrorStyle: palette.AutocompleteErrorStyle, - HintLabelStyle: palette.AutocompleteHintLabelStyle); - } - - private async ValueTask ResolveAutocompleteAsync( - ConsoleLineReader.AutocompleteRequest request, - IReadOnlyList scopeTokens, - IServiceProvider serviceProvider, - CancellationToken cancellationToken) - { - var activeGraph = ResolveActiveRoutingGraph(); - var comparer = _options.Interactive.Autocomplete.CaseSensitive - ? StringComparer.Ordinal - : StringComparer.OrdinalIgnoreCase; - var prefixComparison = _options.Interactive.Autocomplete.CaseSensitive - ? StringComparison.Ordinal - : StringComparison.OrdinalIgnoreCase; - var state = ResolveAutocompleteState(request, scopeTokens, prefixComparison, activeGraph); - var matchingRoutes = CollectVisibleMatchingRoutes( - state.CommandPrefix, - prefixComparison, - activeGraph.Routes, - activeGraph.Contexts); - var candidates = await CollectAutocompleteSuggestionsAsync( - matchingRoutes, - state.CommandPrefix, - state.CurrentTokenPrefix, - scopeTokens.Count, - activeGraph, - prefixComparison, - comparer, - serviceProvider, - cancellationToken) - .ConfigureAwait(false); - var liveHint = _options.Interactive.Autocomplete.LiveHintEnabled - ? BuildLiveHint( - matchingRoutes, - candidates, - state.CommandPrefix, - state.CurrentTokenPrefix, - _options.Interactive.Autocomplete.LiveHintMaxAlternatives) - : null; - var discoverableRoutes = ResolveDiscoverableRoutes( - activeGraph.Routes, - activeGraph.Contexts, - scopeTokens, - prefixComparison); - var discoverableContexts = ResolveDiscoverableContexts( - activeGraph.Contexts, - scopeTokens, - prefixComparison); - var tokenClassifications = BuildTokenClassifications( - request.Input, - scopeTokens, - prefixComparison, - discoverableRoutes, - discoverableContexts); - return new ConsoleLineReader.AutocompleteResult( - state.ReplaceStart, - state.ReplaceLength, - candidates, - liveHint, - tokenClassifications); - } - - private AutocompleteResolutionState ResolveAutocompleteState( - ConsoleLineReader.AutocompleteRequest request, - IReadOnlyList scopeTokens, - StringComparison comparison, - ActiveRoutingGraph activeGraph) - { - var state = AnalyzeAutocompleteInput(request.Input, request.Cursor); - var commandPrefix = scopeTokens.Concat(state.PriorTokens).ToArray(); - var currentTokenPrefix = state.CurrentTokenPrefix; - var replaceStart = state.ReplaceStart; - var replaceLength = state.ReplaceLength; - if (!ShouldAdvanceToNextToken( - commandPrefix, - currentTokenPrefix, - replaceStart, - replaceLength, - request.Cursor, - comparison, - activeGraph.Routes, - activeGraph.Contexts)) - { - return new AutocompleteResolutionState( - commandPrefix, - currentTokenPrefix, - replaceStart, - replaceLength); - } - - return new AutocompleteResolutionState( - commandPrefix.Concat([currentTokenPrefix]).ToArray(), - string.Empty, - request.Cursor, - 0); - } - - private async ValueTask CollectAutocompleteSuggestionsAsync( - IReadOnlyList matchingRoutes, - string[] commandPrefix, - string currentTokenPrefix, - int scopeTokenCount, - ActiveRoutingGraph activeGraph, - StringComparison prefixComparison, - StringComparer comparer, - IServiceProvider serviceProvider, - CancellationToken cancellationToken) - { - var commandCandidates = CollectRouteAutocompleteCandidates( - matchingRoutes, - commandPrefix, - currentTokenPrefix, - prefixComparison); - var dynamicCandidates = await CollectDynamicAutocompleteCandidatesAsync( - matchingRoutes, - commandPrefix, - currentTokenPrefix, - prefixComparison, - _options.Parsing, - serviceProvider, - cancellationToken) - .ConfigureAwait(false); - var contextCandidates = _options.Interactive.Autocomplete.ShowContextAlternatives - ? CollectContextAutocompleteCandidates(commandPrefix, currentTokenPrefix, prefixComparison, activeGraph.Contexts) - : []; - var ambientCandidates = commandPrefix.Length == scopeTokenCount - ? CollectAmbientAutocompleteCandidates(currentTokenPrefix, prefixComparison) - : []; - var ambientContinuationCandidates = CollectAmbientContinuationAutocompleteCandidates( - commandPrefix, - currentTokenPrefix, - scopeTokenCount, - prefixComparison, - activeGraph.Routes, - activeGraph.Contexts); - - var candidates = DeduplicateSuggestions( - commandCandidates - .Concat(dynamicCandidates) - .Concat(contextCandidates) - .Concat(ambientCandidates) - .Concat(ambientContinuationCandidates), - comparer); - if (!_options.Interactive.Autocomplete.ShowInvalidAlternatives - || string.IsNullOrWhiteSpace(currentTokenPrefix) - || candidates.Any(static candidate => candidate.IsSelectable)) - { - return candidates; - } - - return - [ - .. candidates, - new ConsoleLineReader.AutocompleteSuggestion( - currentTokenPrefix, - DisplayText: $"{currentTokenPrefix} (invalid)", - Kind: ConsoleLineReader.AutocompleteSuggestionKind.Invalid, - IsSelectable: false), - ]; - } - - [SuppressMessage( - "Maintainability", - "MA0051:Method is too long", - Justification = "Ambient autocomplete candidates are kept together for discoverability.")] - private List CollectAmbientAutocompleteCandidates( - string currentTokenPrefix, - StringComparison comparison) - { - var suggestions = new List(); - AddAmbientSuggestion( - suggestions, - value: "help", - description: "Show help for current path or a specific path.", - currentTokenPrefix, - comparison); - AddAmbientSuggestion( - suggestions, - value: "?", - description: "Alias for help.", - currentTokenPrefix, - comparison); - AddAmbientSuggestion( - suggestions, - value: "..", - description: "Go up one level in interactive mode.", - currentTokenPrefix, - comparison); - if (_options.AmbientCommands.ExitCommandEnabled) - { - AddAmbientSuggestion( - suggestions, - value: "exit", - description: "Leave interactive mode.", - currentTokenPrefix, - comparison); - } - - if (_options.AmbientCommands.ShowHistoryInHelp) - { - AddAmbientSuggestion( - suggestions, - value: "history", - description: "Show command history.", - currentTokenPrefix, - comparison); - } - - if (_options.AmbientCommands.ShowCompleteInHelp) - { - AddAmbientSuggestion( - suggestions, - value: "complete", - description: "Query completion provider.", - currentTokenPrefix, - comparison); - } - - foreach (var cmd in _options.AmbientCommands.CustomCommands.Values) - { - AddAmbientSuggestion( - suggestions, - value: cmd.Name, - description: cmd.Description ?? string.Empty, - currentTokenPrefix, - comparison); - } - - return suggestions; - } - - private List CollectAmbientContinuationAutocompleteCandidates( - string[] commandPrefix, - string currentTokenPrefix, - int scopeTokenCount, - StringComparison comparison, - IReadOnlyList routes, - IReadOnlyList contexts) - { - if (commandPrefix.Length <= scopeTokenCount) - { - return []; - } - - var ambientToken = commandPrefix[scopeTokenCount]; - if (!IsHelpToken(ambientToken)) - { - return []; - } - - var helpPathPrefix = commandPrefix.Skip(scopeTokenCount + 1).ToArray(); - var suggestions = CollectHelpPathAutocompleteCandidates(helpPathPrefix, currentTokenPrefix, comparison, routes, contexts); - if (suggestions.Count > 0 || string.IsNullOrWhiteSpace(currentTokenPrefix)) - { - return suggestions; - } - - // `help ` accepts arbitrary path text; keep it neutral instead of invalid. - suggestions.Add(new ConsoleLineReader.AutocompleteSuggestion( - currentTokenPrefix, - Kind: ConsoleLineReader.AutocompleteSuggestionKind.Parameter)); - return suggestions; - } - - private List CollectHelpPathAutocompleteCandidates( - string[] helpPathPrefix, - string currentTokenPrefix, - StringComparison comparison, - IReadOnlyList routes, - IReadOnlyList contexts) - { - var suggestions = new List(); - var segmentIndex = helpPathPrefix.Length; - foreach (var context in contexts) - { - if (IsContextSuppressedForDiscovery(context, helpPathPrefix, comparison)) - { - continue; - } - - if (!MatchesTemplatePrefix(context.Template, helpPathPrefix, comparison, _options.Parsing) - || segmentIndex >= context.Template.Segments.Count) - { - continue; - } - - if (context.Template.Segments[segmentIndex] is LiteralRouteSegment literal - && literal.Value.StartsWith(currentTokenPrefix, comparison)) - { - suggestions.Add(new ConsoleLineReader.AutocompleteSuggestion( - literal.Value, - Description: context.Description, - Kind: ConsoleLineReader.AutocompleteSuggestionKind.Context)); - } - } - - foreach (var route in routes) - { - if (route.Command.IsHidden - || IsRouteSuppressedForDiscovery(route.Template, contexts, helpPathPrefix, comparison) - || !MatchesTemplatePrefix(route.Template, helpPathPrefix, comparison, _options.Parsing) - || segmentIndex >= route.Template.Segments.Count) - { - continue; - } - - if (route.Template.Segments[segmentIndex] is LiteralRouteSegment literal - && literal.Value.StartsWith(currentTokenPrefix, comparison)) - { - suggestions.Add(new ConsoleLineReader.AutocompleteSuggestion( - literal.Value, - Description: route.Command.Description, - Kind: ConsoleLineReader.AutocompleteSuggestionKind.Command)); - } - } - - return suggestions; - } - - private static void AddAmbientSuggestion( - List suggestions, - string value, - string description, - string currentTokenPrefix, - StringComparison comparison) - { - if (!value.StartsWith(currentTokenPrefix, comparison)) - { - return; - } - - suggestions.Add(new ConsoleLineReader.AutocompleteSuggestion( - value, - Description: description, - Kind: ConsoleLineReader.AutocompleteSuggestionKind.Command)); - } - - [SuppressMessage( - "Maintainability", - "MA0051:Method is too long", - Justification = "Token advancement logic keeps route/context suppression checks centralized.")] - private bool ShouldAdvanceToNextToken( - string[] commandPrefix, - string currentTokenPrefix, - int replaceStart, - int replaceLength, - int cursor, - StringComparison comparison, - IReadOnlyList routes, - IReadOnlyList contexts) - { - if (string.IsNullOrEmpty(currentTokenPrefix) || cursor != replaceStart + replaceLength) - { - return false; - } - - var segmentIndex = commandPrefix.Length; - var hasLiteralMatch = false; - var hasDynamicOrContextMatch = false; - foreach (var route in routes) - { - if (route.Command.IsHidden - || IsRouteSuppressedForDiscovery(route.Template, contexts, commandPrefix, comparison) - || segmentIndex >= route.Template.Segments.Count) - { - continue; - } - - if (!MatchesRoutePrefix(route.Template, commandPrefix, comparison, _options.Parsing)) - { - continue; - } - - if (route.Template.Segments[segmentIndex] is LiteralRouteSegment literal - && string.Equals(literal.Value, currentTokenPrefix, comparison)) - { - hasLiteralMatch = true; - continue; - } - - if (route.Template.Segments[segmentIndex] is DynamicRouteSegment dynamic - && RouteConstraintEvaluator.IsMatch(dynamic, currentTokenPrefix, _options.Parsing)) - { - hasDynamicOrContextMatch = true; - } - } - - foreach (var context in contexts) - { - if (IsContextSuppressedForDiscovery(context, commandPrefix, comparison)) - { - continue; - } - - if (segmentIndex >= context.Template.Segments.Count - || !MatchesContextPrefix(context.Template, commandPrefix, comparison, _options.Parsing)) - { - continue; - } - - var segment = context.Template.Segments[segmentIndex]; - if (segment is LiteralRouteSegment literal - && string.Equals(literal.Value, currentTokenPrefix, comparison)) - { - hasDynamicOrContextMatch = true; - continue; - } - - if (segment is DynamicRouteSegment dynamic - && RouteConstraintEvaluator.IsMatch(dynamic, currentTokenPrefix, _options.Parsing)) - { - hasDynamicOrContextMatch = true; - } - } - - return hasLiteralMatch && !hasDynamicOrContextMatch; - } - - private static List CollectRouteAutocompleteCandidates( - IReadOnlyList matchingRoutes, - string[] commandPrefix, - string currentTokenPrefix, - StringComparison prefixComparison) - { - var candidates = new List(); - foreach (var route in matchingRoutes) - { - if (commandPrefix.Length < route.Template.Segments.Count - && route.Template.Segments[commandPrefix.Length] is LiteralRouteSegment literal - && literal.Value.StartsWith(currentTokenPrefix, prefixComparison)) - { - candidates.Add(new ConsoleLineReader.AutocompleteSuggestion( - literal.Value, - Description: route.Command.Description, - Kind: ConsoleLineReader.AutocompleteSuggestionKind.Command)); - } - } - - return candidates; - } - - private List CollectContextAutocompleteCandidates( - string[] commandPrefix, - string currentTokenPrefix, - StringComparison comparison, - IReadOnlyList contexts) - { - var suggestions = new List(); - var segmentIndex = commandPrefix.Length; - foreach (var context in contexts) - { - if (IsContextSuppressedForDiscovery(context, commandPrefix, comparison)) - { - continue; - } - - if (!MatchesContextPrefix(context.Template, commandPrefix, comparison, _options.Parsing)) - { - continue; - } - - if (segmentIndex >= context.Template.Segments.Count) - { - continue; - } - - var segment = context.Template.Segments[segmentIndex]; - if (segment is LiteralRouteSegment literal) - { - AddContextLiteralCandidate(suggestions, literal, currentTokenPrefix, comparison); - continue; - } - - AddContextDynamicCandidate( - suggestions, - (DynamicRouteSegment)segment, - commandPrefix, - currentTokenPrefix); - } - - return suggestions - .OrderBy(static suggestion => suggestion.DisplayText, StringComparer.OrdinalIgnoreCase) - .ToList(); - } - - private static void AddContextLiteralCandidate( - List suggestions, - LiteralRouteSegment literal, - string currentTokenPrefix, - StringComparison comparison) - { - if (!literal.Value.StartsWith(currentTokenPrefix, comparison)) - { - return; - } - - suggestions.Add(new ConsoleLineReader.AutocompleteSuggestion( - literal.Value, - DisplayText: literal.Value, - Kind: ConsoleLineReader.AutocompleteSuggestionKind.Context)); - } - - private void AddContextDynamicCandidate( - List suggestions, - DynamicRouteSegment dynamic, - IReadOnlyList commandPrefix, - string currentTokenPrefix) - { - var placeholderValue = $"{{{dynamic.Name}}}"; - if (string.IsNullOrWhiteSpace(currentTokenPrefix)) - { - suggestions.Add(new ConsoleLineReader.AutocompleteSuggestion( - Value: string.Empty, - DisplayText: placeholderValue, - Description: $"Context [{BuildContextTargetPath(commandPrefix, placeholderValue)}]", - Kind: ConsoleLineReader.AutocompleteSuggestionKind.Context, - IsSelectable: false)); - return; - } - - if (RouteConstraintEvaluator.IsMatch(dynamic, currentTokenPrefix, _options.Parsing)) - { - suggestions.Add(new ConsoleLineReader.AutocompleteSuggestion( - currentTokenPrefix, - DisplayText: placeholderValue, - Description: $"Context [{BuildContextTargetPath(commandPrefix, currentTokenPrefix)}]", - Kind: ConsoleLineReader.AutocompleteSuggestionKind.Context)); - return; - } - - if (!_options.Interactive.Autocomplete.ShowInvalidAlternatives) - { - return; - } - - suggestions.Add(new ConsoleLineReader.AutocompleteSuggestion( - currentTokenPrefix, - DisplayText: $"{currentTokenPrefix} -> [invalid]", - Kind: ConsoleLineReader.AutocompleteSuggestionKind.Invalid, - IsSelectable: false)); - } - - private static string BuildContextTargetPath(IReadOnlyList commandPrefix, string value) - { - var tokens = commandPrefix.Concat([value]).ToArray(); - return string.Join('/', tokens); - } - - private List CollectVisibleMatchingRoutes( - string[] commandPrefix, - StringComparison prefixComparison, - IReadOnlyList routes, - IReadOnlyList contexts) - { - var matches = routes - .Where(route => - !route.Command.IsHidden - && !IsRouteSuppressedForDiscovery(route.Template, contexts, commandPrefix, prefixComparison) - && MatchesRoutePrefix(route.Template, commandPrefix, prefixComparison, _options.Parsing)) - .ToList(); - if (commandPrefix.Length == 0) - { - return matches; - } - - var literalMatches = matches - .Where(route => MatchesLiteralPrefix(route.Template, commandPrefix, prefixComparison)) - .ToList(); - return literalMatches.Count > 0 ? literalMatches : matches; - } - - private static bool MatchesLiteralPrefix( - RouteTemplate template, - string[] prefixTokens, - StringComparison comparison) - { - if (prefixTokens.Length > template.Segments.Count) - { - return false; - } - - for (var i = 0; i < prefixTokens.Length; i++) - { - if (template.Segments[i] is not LiteralRouteSegment literal - || !string.Equals(literal.Value, prefixTokens[i], comparison)) - { - return false; - } - } - - return true; - } - - private static string? BuildLiveHint( - IReadOnlyList matchingRoutes, - IReadOnlyList suggestions, - string[] commandPrefix, - string currentTokenPrefix, - int maxAlternatives) - { - if (IsGlobalOptionToken(currentTokenPrefix)) - { - return null; - } - - var selectable = suggestions.Where(static suggestion => suggestion.IsSelectable).ToArray(); - var hintAlternatives = suggestions - .Where(static suggestion => - suggestion.IsSelectable - || suggestion is - { - IsSelectable: false, - Kind: ConsoleLineReader.AutocompleteSuggestionKind.Context, - }) - .ToArray(); - if (selectable.Length == 0) - { - return BuildDynamicHint(matchingRoutes, commandPrefix.Length, maxAlternatives) - ?? (string.IsNullOrWhiteSpace(currentTokenPrefix) ? null : $"Invalid: {currentTokenPrefix}"); - } - - var segmentIndex = commandPrefix.Length; - if (TryBuildParameterHint(matchingRoutes, segmentIndex, out var parameterHint) - && selectable.All(static suggestion => - suggestion.Kind is ConsoleLineReader.AutocompleteSuggestionKind.Parameter - or ConsoleLineReader.AutocompleteSuggestionKind.Invalid)) - { - return parameterHint; - } - - if (selectable.Length == 1) - { - var suggestion = selectable[0]; - if (suggestion.Kind == ConsoleLineReader.AutocompleteSuggestionKind.Command) - { - return string.IsNullOrWhiteSpace(suggestion.Description) - ? $"Command: {suggestion.DisplayText}" - : $"Command: {suggestion.DisplayText} - {suggestion.Description}"; - } - - if (suggestion.Kind == ConsoleLineReader.AutocompleteSuggestionKind.Context) - { - return $"Context: {suggestion.DisplayText}"; - } - - return suggestion.DisplayText; - } - - maxAlternatives = Math.Max(1, maxAlternatives); - var shown = hintAlternatives - .Select(static suggestion => suggestion.DisplayText) - .Take(maxAlternatives) - .ToArray(); - var suffix = hintAlternatives.Length > shown.Length - ? $" (+{hintAlternatives.Length - shown.Length})" - : string.Empty; - return $"Matches: {string.Join(", ", shown)}{suffix}"; - } - - private static string? BuildDynamicHint( - IReadOnlyList matchingRoutes, - int segmentIndex, - int maxAlternatives) - { - if (TryBuildParameterHint(matchingRoutes, segmentIndex, out var parameterHint)) - { - return parameterHint; - } - - var dynamicRoutes = matchingRoutes - .Where(route => - segmentIndex < route.Template.Segments.Count - && route.Template.Segments[segmentIndex] is DynamicRouteSegment) - .Select(route => route.Template.Template) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - if (dynamicRoutes.Length <= 1) - { - return null; - } - - maxAlternatives = Math.Max(1, maxAlternatives); - var shown = dynamicRoutes.Take(maxAlternatives).ToArray(); - var suffix = dynamicRoutes.Length > shown.Length - ? $" (+{dynamicRoutes.Length - shown.Length})" - : string.Empty; - return $"Overloads: {string.Join(", ", shown)}{suffix}"; - } - - private static bool TryBuildParameterHint( - IReadOnlyList matchingRoutes, - int segmentIndex, - out string hint) - { - hint = string.Empty; - var dynamicRoutes = matchingRoutes - .Where(route => - segmentIndex < route.Template.Segments.Count - && route.Template.Segments[segmentIndex] is DynamicRouteSegment) - .ToArray(); - if (dynamicRoutes.Length == 0) - { - return false; - } - - if (dynamicRoutes.Length == 1 - && dynamicRoutes[0].Template.Segments[segmentIndex] is DynamicRouteSegment singleDynamic) - { - var description = TryGetRouteParameterDescription(dynamicRoutes[0], singleDynamic.Name); - hint = string.IsNullOrWhiteSpace(description) - ? $"Param {singleDynamic.Name}" - : $"Param {singleDynamic.Name}: {description}"; - return true; - } - - return false; - } - - private static string? TryGetRouteParameterDescription(RouteDefinition route, string parameterName) - { - var parameter = route.Command.Handler.Method - .GetParameters() - .FirstOrDefault(parameter => - !string.IsNullOrWhiteSpace(parameter.Name) - && string.Equals(parameter.Name, parameterName, StringComparison.OrdinalIgnoreCase)); - return parameter?.GetCustomAttribute()?.Description; - } - - private static async ValueTask> CollectDynamicAutocompleteCandidatesAsync( - IReadOnlyList matchingRoutes, - string[] commandPrefix, - string currentTokenPrefix, - StringComparison prefixComparison, - ParsingOptions parsingOptions, - IServiceProvider serviceProvider, - CancellationToken cancellationToken) - { - var exactRoute = matchingRoutes.FirstOrDefault(route => - route.Template.Segments.Count == commandPrefix.Length - && MatchesTemplatePrefix( - route.Template, - commandPrefix, - prefixComparison, - parsingOptions)); - if (exactRoute is null || exactRoute.Command.Completions.Count != 1) - { - return []; - } - - var completion = exactRoute.Command.Completions.Values.Single(); - var completionContext = new CompletionContext(serviceProvider); - var provided = await completion(completionContext, currentTokenPrefix, cancellationToken) - .ConfigureAwait(false); - return provided - .Where(static item => !string.IsNullOrWhiteSpace(item)) - .Select(static item => new ConsoleLineReader.AutocompleteSuggestion( - item, - Kind: ConsoleLineReader.AutocompleteSuggestionKind.Parameter)) - .ToArray(); - } - - private static ConsoleLineReader.AutocompleteSuggestion[] DeduplicateSuggestions( - IEnumerable suggestions, - StringComparer comparer) - { - var seen = new HashSet(comparer); - var distinct = new List(); - foreach (var suggestion in suggestions) - { - if (string.IsNullOrWhiteSpace(suggestion.DisplayText) || !seen.Add(suggestion.DisplayText)) - { - continue; - } - - distinct.Add(suggestion); - } - - return [.. distinct]; - } - - private List BuildTokenClassifications( - string input, - IReadOnlyList scopeTokens, - StringComparison comparison, - IReadOnlyList routes, - IReadOnlyList contexts) - { - if (string.IsNullOrWhiteSpace(input)) - { - return []; - } - - var tokenSpans = TokenizeInputSpans(input); - if (tokenSpans.Count == 0) - { - return []; - } - - var output = new List(tokenSpans.Count); - for (var i = 0; i < tokenSpans.Count; i++) - { - if (IsGlobalOptionToken(tokenSpans[i].Value)) - { - output.Add(new ConsoleLineReader.TokenClassification( - tokenSpans[i].Start, - tokenSpans[i].End - tokenSpans[i].Start, - ConsoleLineReader.AutocompleteSuggestionKind.Parameter)); - continue; - } - - var prefix = scopeTokens.Concat( - tokenSpans.Take(i) - .Where(static token => !IsGlobalOptionToken(token.Value)) - .Select(static token => token.Value)).ToArray(); - var kind = ClassifyToken( - prefix, - tokenSpans[i].Value, - comparison, - routes, - contexts, - scopeTokenCount: scopeTokens.Count, - isFirstInputToken: i == 0); - output.Add(new ConsoleLineReader.TokenClassification( - tokenSpans[i].Start, - tokenSpans[i].End - tokenSpans[i].Start, - kind)); - } - - return output; - } - - private static bool IsGlobalOptionToken(string token) => - token.StartsWith("--", StringComparison.Ordinal) && token.Length >= 2; - - [SuppressMessage( - "Maintainability", - "MA0051:Method is too long", - Justification = "Token classification intentionally keeps full precedence rules in one place.")] - private ConsoleLineReader.AutocompleteSuggestionKind ClassifyToken( - string[] prefixTokens, - string token, - StringComparison comparison, - IReadOnlyList routes, - IReadOnlyList contexts, - int scopeTokenCount, - bool isFirstInputToken) - { - if (isFirstInputToken && HasAmbientCommandPrefix(token, comparison)) - { - return ConsoleLineReader.AutocompleteSuggestionKind.Command; - } - - if (TryClassifyAmbientContinuation(prefixTokens, scopeTokenCount, out var ambientKind)) - { - return ambientKind; - } - - var routeLiteralMatch = false; - var routeDynamicMatch = false; - foreach (var route in routes) - { - if (route.Command.IsHidden - || IsRouteSuppressedForDiscovery(route.Template, contexts, prefixTokens, comparison) - || !TryClassifyTemplateSegment( - route.Template, - prefixTokens, - token, - comparison, - _options.Parsing, - out var routeKind)) - { - continue; - } - - routeLiteralMatch |= routeKind == ConsoleLineReader.AutocompleteSuggestionKind.Command; - routeDynamicMatch |= routeKind == ConsoleLineReader.AutocompleteSuggestionKind.Parameter; - } - - var contextMatch = contexts.Any(context => - !IsContextSuppressedForDiscovery(context, prefixTokens, comparison) - && - TryClassifyTemplateSegment( - context.Template, - prefixTokens, - token, - comparison, - _options.Parsing, - out _)); - if ((routeLiteralMatch || routeDynamicMatch) && contextMatch) - { - return ConsoleLineReader.AutocompleteSuggestionKind.Ambiguous; - } - - if (contextMatch) - { - return ConsoleLineReader.AutocompleteSuggestionKind.Context; - } - - if (routeLiteralMatch) - { - return ConsoleLineReader.AutocompleteSuggestionKind.Command; - } - - if (routeDynamicMatch) - { - return ConsoleLineReader.AutocompleteSuggestionKind.Parameter; - } - - return ConsoleLineReader.AutocompleteSuggestionKind.Invalid; - } - - private static bool TryClassifyAmbientContinuation( - string[] prefixTokens, - int scopeTokenCount, - out ConsoleLineReader.AutocompleteSuggestionKind kind) - { - kind = ConsoleLineReader.AutocompleteSuggestionKind.Invalid; - if (prefixTokens.Length <= scopeTokenCount) - { - return false; - } - - var ambientToken = prefixTokens[scopeTokenCount]; - if (IsHelpToken(ambientToken) - || string.Equals(ambientToken, "history", StringComparison.OrdinalIgnoreCase) - || string.Equals(ambientToken, "complete", StringComparison.OrdinalIgnoreCase)) - { - kind = ConsoleLineReader.AutocompleteSuggestionKind.Parameter; - return true; - } - - if (!string.Equals(ambientToken, "autocomplete", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - kind = prefixTokens.Length == scopeTokenCount + 1 - ? ConsoleLineReader.AutocompleteSuggestionKind.Command - : ConsoleLineReader.AutocompleteSuggestionKind.Parameter; - return true; - } - - private bool HasAmbientCommandPrefix(string token, StringComparison comparison) - { - if (string.IsNullOrWhiteSpace(token)) - { - return false; - } - - if ("help".StartsWith(token, comparison) || "?".StartsWith(token, comparison)) - { - return true; - } - - if ("..".StartsWith(token, comparison)) - { - return true; - } - - if (_options.AmbientCommands.ExitCommandEnabled && "exit".StartsWith(token, comparison)) - { - return true; - } - - if (_options.AmbientCommands.ShowHistoryInHelp && "history".StartsWith(token, comparison)) - { - return true; - } - - if (_options.AmbientCommands.ShowCompleteInHelp && "complete".StartsWith(token, comparison)) - { - return true; - } - - return _options.AmbientCommands.CustomCommands.Keys - .Any(name => name.StartsWith(token, comparison)); - } - - private static bool TryClassifyTemplateSegment( - RouteTemplate template, - string[] prefixTokens, - string token, - StringComparison comparison, - ParsingOptions parsingOptions, - out ConsoleLineReader.AutocompleteSuggestionKind kind) - { - kind = ConsoleLineReader.AutocompleteSuggestionKind.Invalid; - if (!MatchesTemplatePrefix(template, prefixTokens, comparison, parsingOptions) - || prefixTokens.Length >= template.Segments.Count) - { - return false; - } - - var segment = template.Segments[prefixTokens.Length]; - if (segment is LiteralRouteSegment literal && literal.Value.StartsWith(token, comparison)) - { - kind = ConsoleLineReader.AutocompleteSuggestionKind.Command; - return true; - } - - if (segment is DynamicRouteSegment dynamic - && RouteConstraintEvaluator.IsMatch(dynamic, token, parsingOptions)) - { - kind = ConsoleLineReader.AutocompleteSuggestionKind.Parameter; - return true; - } - - return false; - } - - private static bool MatchesRoutePrefix( - RouteTemplate template, - string[] prefixTokens, - StringComparison comparison, - ParsingOptions parsingOptions) - { - return MatchesTemplatePrefix(template, prefixTokens, comparison, parsingOptions); - } - - private static bool MatchesContextPrefix( - RouteTemplate template, - string[] prefixTokens, - StringComparison comparison, - ParsingOptions parsingOptions) - { - return MatchesTemplatePrefix(template, prefixTokens, comparison, parsingOptions); - } - - private static bool MatchesTemplatePrefix( - RouteTemplate template, - string[] prefixTokens, - StringComparison comparison, - ParsingOptions parsingOptions) - { - if (prefixTokens.Length > template.Segments.Count) - { - return false; - } - - for (var i = 0; i < prefixTokens.Length; i++) - { - var token = prefixTokens[i]; - var segment = template.Segments[i]; - switch (segment) - { - case LiteralRouteSegment literal - when !string.Equals(literal.Value, token, comparison): - return false; - case DynamicRouteSegment dynamic - when !RouteConstraintEvaluator.IsMatch(dynamic, token, parsingOptions): - return false; - } - } - - return true; - } - - private static List TokenizeInputSpans(string input) - { - var tokens = new List(); - var index = 0; - while (index < input.Length) - { - while (index < input.Length && char.IsWhiteSpace(input[index])) - { - index++; - } - - if (index >= input.Length) - { - break; - } - - var start = index; - var value = new System.Text.StringBuilder(); - char? quote = null; - - if (input[index] is '"' or '\'') - { - quote = input[index]; - index++; // skip opening quote - } - - while (index < input.Length) - { - if (quote is not null && input[index] == quote.Value) - { - index++; // skip closing quote - quote = null; - break; - } - - if (quote is null && char.IsWhiteSpace(input[index])) - { - break; - } - - if (quote is null && input[index] is '"' or '\'') - { - quote = input[index]; - index++; // skip opening quote mid-token - continue; - } - - value.Append(input[index]); - index++; - } - - tokens.Add(new TokenSpan(value.ToString(), start, index)); - } - - return tokens; - } - - private static AutocompleteInputState AnalyzeAutocompleteInput(string input, int cursor) - { - input ??= string.Empty; - cursor = Math.Clamp(cursor, 0, input.Length); - var tokens = TokenizeInputSpans(input); - - for (var i = 0; i < tokens.Count; i++) - { - var token = tokens[i]; - if (cursor < token.Start || cursor > token.End) - { - continue; - } - - var prefix = input[token.Start..cursor]; - var prior = tokens.Take(i) - .Where(static tokenSpan => !IsGlobalOptionToken(tokenSpan.Value)) - .Select(static tokenSpan => tokenSpan.Value).ToArray(); - return new AutocompleteInputState( - prior, - prefix, - token.Start, - token.End - token.Start); - } - - var trailingPrior = tokens - .Where(token => token.End <= cursor && !IsGlobalOptionToken(token.Value)) - .Select(static token => token.Value).ToArray(); - return new AutocompleteInputState( - trailingPrior, - CurrentTokenPrefix: string.Empty, - ReplaceStart: cursor, - ReplaceLength: 0); - } - - private readonly record struct AutocompleteInputState( - string[] PriorTokens, - string CurrentTokenPrefix, - int ReplaceStart, - int ReplaceLength); - - private readonly record struct AutocompleteResolutionState( - string[] CommandPrefix, - string CurrentTokenPrefix, - int ReplaceStart, - int ReplaceLength); - - private readonly record struct TokenSpan(string Value, int Start, int End); - - private static List TokenizeInteractiveInput(string input) - { - if (string.IsNullOrWhiteSpace(input)) - { - return []; - } - - var tokens = new List(); - var current = new System.Text.StringBuilder(); - char? quote = null; - foreach (var ch in input) - { - if (quote is null && (ch == '"' || ch == '\'')) - { - quote = ch; - continue; - } - - if (quote is not null && ch == quote.Value) - { - quote = null; - continue; - } - - if (quote is null && char.IsWhiteSpace(ch)) - { - if (current.Length > 0) - { - tokens.Add(current.ToString()); - current.Clear(); - } - - continue; - } - - current.Append(ch); - } - - if (current.Length > 0) - { - tokens.Add(current.ToString()); - } - - return tokens; - } + CancellationToken cancellationToken) => + Interactive.HandleCompletionAmbientCommandAsync(commandTokens, scopeTokens, serviceProvider, cancellationToken); } diff --git a/src/Repl.Core/CoreReplApp.OptionParsing.cs b/src/Repl.Core/CoreReplApp.OptionParsing.cs deleted file mode 100644 index bb100e7..0000000 --- a/src/Repl.Core/CoreReplApp.OptionParsing.cs +++ /dev/null @@ -1,62 +0,0 @@ -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 0b0d0bf..dbd62fb 100644 --- a/src/Repl.Core/CoreReplApp.Routing.cs +++ b/src/Repl.Core/CoreReplApp.Routing.cs @@ -1,617 +1,94 @@ -using System.Globalization; -using System.Reflection; -using Repl.Internal.Options; - namespace Repl; public sealed partial class CoreReplApp { - private ContextDefinition RegisterContext(string template, Delegate? validation, string? description) - { - var parsedTemplate = RouteTemplateParser.Parse(template, _options.Parsing); - var moduleId = ResolveCurrentMappingModuleId(); - RouteConfigurationValidator.ValidateUnique( - parsedTemplate, - _contexts - .Where(context => context.ModuleId == moduleId) - .Select(context => context.Template) - ); + private RoutingEngine? _routingEngine; + private RoutingEngine RoutingEng => _routingEngine ??= new(this); - var context = new ContextDefinition(parsedTemplate, validation, description, moduleId); - _contexts.Add(context); - InvalidateRouting(); - return context; - } + internal ContextDefinition RegisterContext(string template, Delegate? validation, string? description) => + RoutingEng.RegisterContext(template, validation, description); - private async ValueTask ValidateContextsForPathAsync( + internal ValueTask ValidateContextsForPathAsync( IReadOnlyList matchedPathTokens, IReadOnlyList contexts, IServiceProvider serviceProvider, - CancellationToken cancellationToken) - { - var contextMatches = ContextResolver.ResolvePrefixes(contexts, matchedPathTokens, _options.Parsing); - foreach (var contextMatch in contextMatches) - { - var validation = await ValidateContextAsync(contextMatch, serviceProvider, cancellationToken).ConfigureAwait(false); - if (!validation.IsValid) - { - return validation.Failure; - } - } - - return null; - } + CancellationToken cancellationToken) => + RoutingEng.ValidateContextsForPathAsync(matchedPathTokens, contexts, serviceProvider, cancellationToken); - private async ValueTask ValidateContextsForMatchAsync( + internal ValueTask ValidateContextsForMatchAsync( RouteMatch match, IReadOnlyList matchedPathTokens, IReadOnlyList contexts, IServiceProvider serviceProvider, - CancellationToken cancellationToken) - { - var contextMatches = ResolveRouteContextPrefixes(match.Route.Template, matchedPathTokens, contexts); - foreach (var contextMatch in contextMatches) - { - var validation = await ValidateContextAsync(contextMatch, serviceProvider, cancellationToken).ConfigureAwait(false); - if (!validation.IsValid) - { - return validation.Failure; - } - } - - return null; - } - - private List BuildContextHierarchyValues( - RouteTemplate matchedRouteTemplate, - IReadOnlyList matchedPathTokens, - IReadOnlyList contexts) - { - var matches = ResolveRouteContextPrefixes(matchedRouteTemplate, matchedPathTokens, contexts); - var values = new List(); - foreach (var contextMatch in matches) - { - foreach (var dynamicSegment in contextMatch.Context.Template.Segments.OfType()) - { - if (!contextMatch.RouteValues.TryGetValue(dynamicSegment.Name, out var routeValue)) - { - continue; - } - - values.Add(ConvertContextValue(routeValue, dynamicSegment.ConstraintKind)); - } - } - - return values; - } + CancellationToken cancellationToken) => + RoutingEng.ValidateContextsForMatchAsync(match, matchedPathTokens, contexts, serviceProvider, cancellationToken); - private IReadOnlyList ResolveRouteContextPrefixes( + internal List BuildContextHierarchyValues( RouteTemplate matchedRouteTemplate, IReadOnlyList matchedPathTokens, - IReadOnlyList contexts) - { - var matches = ContextResolver.ResolvePrefixes(contexts, matchedPathTokens, _options.Parsing); - return [.. - matches.Where(contextMatch => - IsTemplatePrefix( - contextMatch.Context.Template, - matchedRouteTemplate)), - ]; - } - - private static bool IsTemplatePrefix(RouteTemplate contextTemplate, RouteTemplate routeTemplate) - { - if (contextTemplate.Segments.Count > routeTemplate.Segments.Count) - { - return false; - } - - for (var i = 0; i < contextTemplate.Segments.Count; i++) - { - var contextSegment = contextTemplate.Segments[i]; - var routeSegment = routeTemplate.Segments[i]; - if (!AreSegmentsEquivalent(contextSegment, routeSegment)) - { - return false; - } - } - - return true; - } - - private static bool AreSegmentsEquivalent(RouteSegment left, RouteSegment right) - { - if (left is LiteralRouteSegment leftLiteral && right is LiteralRouteSegment rightLiteral) - { - return string.Equals(leftLiteral.Value, rightLiteral.Value, StringComparison.OrdinalIgnoreCase); - } - - if (left is DynamicRouteSegment leftDynamic && right is DynamicRouteSegment rightDynamic) - { - if (leftDynamic.ConstraintKind != rightDynamic.ConstraintKind) - { - return false; - } - - if (leftDynamic.ConstraintKind != RouteConstraintKind.Custom) - { - return true; - } - - return string.Equals( - leftDynamic.CustomConstraintName, - rightDynamic.CustomConstraintName, - StringComparison.OrdinalIgnoreCase); - } - - return false; - } + IReadOnlyList contexts) => + RoutingEng.BuildContextHierarchyValues(matchedRouteTemplate, matchedPathTokens, contexts); - private static object? ConvertContextValue(string routeValue, RouteConstraintKind kind) => - kind switch - { - RouteConstraintKind.Int => ParameterValueConverter.ConvertSingle(routeValue, typeof(int), CultureInfo.InvariantCulture), - RouteConstraintKind.Long => ParameterValueConverter.ConvertSingle(routeValue, typeof(long), CultureInfo.InvariantCulture), - RouteConstraintKind.Bool => ParameterValueConverter.ConvertSingle(routeValue, typeof(bool), CultureInfo.InvariantCulture), - RouteConstraintKind.Guid => ParameterValueConverter.ConvertSingle(routeValue, typeof(Guid), CultureInfo.InvariantCulture), - RouteConstraintKind.Uri => ParameterValueConverter.ConvertSingle(routeValue, typeof(Uri), CultureInfo.InvariantCulture), - RouteConstraintKind.Url => ParameterValueConverter.ConvertSingle(routeValue, typeof(Uri), CultureInfo.InvariantCulture), - RouteConstraintKind.Urn => ParameterValueConverter.ConvertSingle(routeValue, typeof(Uri), CultureInfo.InvariantCulture), - RouteConstraintKind.Time => ParameterValueConverter.ConvertSingle(routeValue, typeof(TimeOnly), CultureInfo.InvariantCulture), - RouteConstraintKind.Date => ParameterValueConverter.ConvertSingle(routeValue, typeof(DateOnly), CultureInfo.InvariantCulture), - RouteConstraintKind.DateTime => ParameterValueConverter.ConvertSingle(routeValue, typeof(DateTime), CultureInfo.InvariantCulture), - RouteConstraintKind.DateTimeOffset => ParameterValueConverter.ConvertSingle(routeValue, typeof(DateTimeOffset), CultureInfo.InvariantCulture), - RouteConstraintKind.TimeSpan => ParameterValueConverter.ConvertSingle(routeValue, typeof(TimeSpan), CultureInfo.InvariantCulture), - _ => routeValue, - }; - - private async ValueTask ValidateContextAsync( + internal ValueTask ValidateContextAsync( ContextMatch contextMatch, IServiceProvider serviceProvider, - CancellationToken cancellationToken) - { - if (contextMatch.Context.Validation is null) - { - return ContextValidationOutcome.Success; - } - - var bindingContext = new InvocationBindingContext( - contextMatch.RouteValues, - new Dictionary>(StringComparer.OrdinalIgnoreCase), - [], - OptionSchema.Empty, - _options.Parsing.OptionCaseSensitivity, - [], - _options.Parsing.NumericFormatProvider, - serviceProvider, - _options.Interaction, - cancellationToken); - var arguments = HandlerArgumentBinder.Bind(contextMatch.Context.Validation, bindingContext); - var validationResult = await CommandInvoker - .InvokeAsync(contextMatch.Context.Validation, arguments) - .ConfigureAwait(false); - return validationResult switch - { - bool value => value - ? ContextValidationOutcome.Success - : ContextValidationOutcome.FromFailure(CreateDefaultContextValidationFailure(contextMatch)), - IReplResult replResult => string.Equals(replResult.Kind, "text", StringComparison.OrdinalIgnoreCase) - ? ContextValidationOutcome.Success - : ContextValidationOutcome.FromFailure(replResult), - string text => string.IsNullOrWhiteSpace(text) - ? ContextValidationOutcome.Success - : ContextValidationOutcome.FromFailure(Results.Validation(text)), - null => ContextValidationOutcome.FromFailure(CreateDefaultContextValidationFailure(contextMatch)), - _ => throw new InvalidOperationException( - "Context validation must return bool, string, IReplResult, or null."), - }; - } - - private static IReplResult CreateDefaultContextValidationFailure(ContextMatch contextMatch) - { - var scope = contextMatch.Context.Template.Template; - var details = contextMatch.RouteValues.Count == 0 - ? null - : contextMatch.RouteValues; - return Results.Validation($"Scope validation failed for '{scope}'.", details); - } - - private IReplResult CreateUnknownCommandResult(IReadOnlyList tokens) - { - var activeGraph = ResolveActiveRoutingGraph(); - var discoverableRoutes = ResolveDiscoverableRoutes( - activeGraph.Routes, - activeGraph.Contexts, - tokens, - StringComparison.OrdinalIgnoreCase); - var input = string.Join(' ', tokens); - var visibleRoutes = discoverableRoutes - .Where(route => !route.Command.IsHidden) - .Select(route => route.Template.Template) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - var bestSuggestion = FindBestSuggestion(input, visibleRoutes); - if (bestSuggestion is null) - { - return Results.Error("unknown_command", $"Unknown command '{input}'."); - } - - return Results.Error( - code: "unknown_command", - message: $"Unknown command '{input}'. Did you mean '{bestSuggestion}'?"); - } - - private static IReplResult CreateAmbiguousPrefixResult(PrefixResolutionResult prefixResolution) - { - var message = $"Ambiguous command prefix '{prefixResolution.AmbiguousToken}'. Candidates: {string.Join(", ", prefixResolution.Candidates)}."; - return Results.Validation(message); - } - - private static IReplResult CreateInvalidRouteValueResult(RouteResolver.RouteConstraintFailure failure) - { - var expected = GetConstraintDisplayName(failure.Segment); - var message = $"Invalid value '{failure.Value}' for parameter '{failure.Segment.Name}' (expected: {expected})."; - return Results.Validation(message); - } - - private static IReplResult CreateMissingRouteValuesResult(RouteResolver.RouteMissingArgumentsFailure failure) - { - if (failure.MissingSegments.Length == 1) - { - var segment = failure.MissingSegments[0]; - var expected = GetConstraintDisplayName(segment); - var message = $"Missing value for parameter '{segment.Name}' (expected: {expected})."; - return Results.Validation(message); - } + CancellationToken cancellationToken) => + RoutingEng.ValidateContextAsync(contextMatch, serviceProvider, cancellationToken); - var names = string.Join(", ", failure.MissingSegments.Select(segment => segment.Name)); - return Results.Validation($"Missing values for parameters: {names}."); - } - - private IReplResult CreateRouteResolutionFailureResult( + internal IReplResult CreateRouteResolutionFailureResult( IReadOnlyList tokens, RouteResolver.RouteConstraintFailure? constraintFailure, - RouteResolver.RouteMissingArgumentsFailure? missingArgumentsFailure) - { - if (constraintFailure is { } routeConstraintFailure) - { - return CreateInvalidRouteValueResult(routeConstraintFailure); - } - - if (missingArgumentsFailure is { } routeMissingArgumentsFailure) - { - return CreateMissingRouteValuesResult(routeMissingArgumentsFailure); - } - - return CreateUnknownCommandResult(tokens); - } - - private static string GetConstraintDisplayName(DynamicRouteSegment segment) => - segment.ConstraintKind == RouteConstraintKind.Custom && !string.IsNullOrWhiteSpace(segment.CustomConstraintName) - ? segment.CustomConstraintName! - : GetConstraintTypeName(segment.ConstraintKind); + RouteResolver.RouteMissingArgumentsFailure? missingArgumentsFailure) => + RoutingEng.CreateRouteResolutionFailureResult(tokens, constraintFailure, missingArgumentsFailure); - private async ValueTask TryRenderCommandBannerAsync( + internal ValueTask TryRenderCommandBannerAsync( CommandBuilder command, string? outputFormat, IServiceProvider serviceProvider, - CancellationToken cancellationToken) - { - if (command.IsProtocolPassthrough) - { - return; - } + CancellationToken cancellationToken) => + RoutingEng.TryRenderCommandBannerAsync(command, outputFormat, serviceProvider, cancellationToken); - if (command.Banner is { } banner && ShouldRenderBanner(outputFormat)) - { - await InvokeBannerAsync(banner, serviceProvider, cancellationToken).ConfigureAwait(false); - } - } + internal bool ShouldRenderBanner(string? requestedOutputFormat) => + RoutingEng.ShouldRenderBanner(requestedOutputFormat); - private bool ShouldRenderBanner(string? requestedOutputFormat) - { - if (_allBannersSuppressed.Value || !_options.Output.BannerEnabled) - { - return false; - } - - var format = string.IsNullOrWhiteSpace(requestedOutputFormat) - ? _options.Output.DefaultFormat - : requestedOutputFormat; - return _options.Output.BannerFormats.Contains(format); - } - - private async ValueTask InvokeBannerAsync( + internal ValueTask InvokeBannerAsync( Delegate banner, IServiceProvider serviceProvider, - CancellationToken cancellationToken) - { - var bindingContext = new InvocationBindingContext( - 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, - interactionOptions: _options.Interaction, - cancellationToken: cancellationToken); - var arguments = HandlerArgumentBinder.Bind(banner, bindingContext); - var result = await CommandInvoker.InvokeAsync(banner, arguments).ConfigureAwait(false); - if (result is string text && !string.IsNullOrEmpty(text)) - { - var styled = _options.Output.IsAnsiEnabled() - ? AnsiText.Apply(text, _options.Output.ResolvePalette().BannerStyle) - : text; - await ReplSessionIO.Output.WriteLineAsync(styled).ConfigureAwait(false); - } - } - - private string BuildBannerText() - { - var assembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly(); - var product = assembly.GetCustomAttribute()?.Product; - var version = assembly.GetCustomAttribute()?.InformationalVersion; - var description = _description - ?? assembly.GetCustomAttribute()?.Description; + CancellationToken cancellationToken) => + RoutingEng.InvokeBannerAsync(banner, serviceProvider, cancellationToken); - var header = string.Join( - ' ', - new[] { product, version } - .Where(value => !string.IsNullOrWhiteSpace(value)) - .Select(value => value!)); + private string BuildBannerText() => + RoutingEng.BuildBannerText(); - if (string.IsNullOrWhiteSpace(header)) - { - return description ?? string.Empty; - } + internal PrefixResolutionResult ResolveUniquePrefixes(IReadOnlyList tokens) => + RoutingEng.ResolveUniquePrefixes(tokens); - return string.IsNullOrWhiteSpace(description) - ? header - : $"{header}{Environment.NewLine}{description}"; - } - - private PrefixResolutionResult ResolveUniquePrefixes(IReadOnlyList tokens) - { - var activeGraph = ResolveActiveRoutingGraph(); - if (tokens.Count == 0) - { - return new PrefixResolutionResult(tokens: []); - } - - var resolved = tokens.ToArray(); - for (var index = 0; index < resolved.Length; index++) - { - // Prefix expansion is only attempted on literal nodes that remain reachable - // after validating previously resolved segments (including typed dynamics). - var candidates = ResolveLiteralCandidatesAtIndex(resolved, index, activeGraph.Routes, activeGraph.Contexts); - if (candidates.Length == 0) - { - continue; - } - - var token = resolved[index]; - var exact = candidates - .Where(candidate => string.Equals(candidate, token, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - if (exact.Length == 1) - { - resolved[index] = exact[0]; - continue; - } - - var prefixMatches = candidates - .Where(candidate => candidate.StartsWith(token, StringComparison.OrdinalIgnoreCase)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - if (prefixMatches.Length == 1) - { - resolved[index] = prefixMatches[0]; - continue; - } - - if (prefixMatches.Length > 1) - { - // Ambiguous shorthand must fail fast so users don't execute the wrong command. - return new PrefixResolutionResult( - tokens: resolved, - ambiguousToken: token, - candidates: prefixMatches); - } - } - - return new PrefixResolutionResult(tokens: resolved); - } - - private string[] ResolveLiteralCandidatesAtIndex( - string[] tokens, - int index, - IReadOnlyList routes, - IReadOnlyList contexts) - { - var prefixTokens = tokens.Take(index).ToArray(); - var discoverableRoutes = ResolveDiscoverableRoutes( - routes, - contexts, - prefixTokens, - StringComparison.OrdinalIgnoreCase); - var discoverableContexts = ResolveDiscoverableContexts( - contexts, - prefixTokens, - StringComparison.OrdinalIgnoreCase); - var literals = EnumeratePrefixTemplates(discoverableRoutes, discoverableContexts) - .Where(template => !template.IsHidden) - .SelectMany(template => GetCandidateLiterals(template, tokens, index)) - .Where(candidate => !string.IsNullOrWhiteSpace(candidate)) - .Select(candidate => candidate!) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - return literals; - } - - private static IEnumerable EnumeratePrefixTemplates( - IReadOnlyList routes, - IReadOnlyList contexts) - { - foreach (var route in routes) - { - yield return new PrefixTemplate(route.Template, route.Command.IsHidden, route.Command.Aliases); - } - - foreach (var context in contexts) - { - yield return new PrefixTemplate(context.Template, context.IsHidden, Aliases: []); - } - } - - private RouteDefinition[] ResolveDiscoverableRoutes( + internal RouteDefinition[] ResolveDiscoverableRoutes( IReadOnlyList routes, IReadOnlyList contexts, IReadOnlyList scopeTokens, StringComparison comparison) => - [.. routes.Where(route => - !IsRouteSuppressedForDiscovery(route.Template, contexts, scopeTokens, comparison)),]; + RoutingEng.ResolveDiscoverableRoutes(routes, contexts, scopeTokens, comparison); - private ContextDefinition[] ResolveDiscoverableContexts( + internal ContextDefinition[] ResolveDiscoverableContexts( IReadOnlyList contexts, IReadOnlyList scopeTokens, StringComparison comparison) => - [.. contexts.Where(context => - !IsContextSuppressedForDiscovery(context, scopeTokens, comparison)),]; + RoutingEng.ResolveDiscoverableContexts(contexts, scopeTokens, comparison); - private bool IsRouteSuppressedForDiscovery( + internal bool IsRouteSuppressedForDiscovery( RouteTemplate routeTemplate, IReadOnlyList contexts, IReadOnlyList scopeTokens, - StringComparison comparison) - { - foreach (var context in contexts) - { - if (!context.IsHidden - || !IsTemplatePrefix(context.Template, routeTemplate) - || !IsContextSuppressedForDiscovery(context, scopeTokens, comparison)) - { - continue; - } - - return true; - } - - return false; - } + StringComparison comparison) => + RoutingEng.IsRouteSuppressedForDiscovery(routeTemplate, contexts, scopeTokens, comparison); - private bool IsContextSuppressedForDiscovery( + internal bool IsContextSuppressedForDiscovery( ContextDefinition context, IReadOnlyList scopeTokens, - StringComparison comparison) - { - if (!context.IsHidden) - { - return false; - } - - return !IsScopeWithinTemplate(scopeTokens, context.Template, comparison); - } - - private bool IsScopeWithinTemplate( - IReadOnlyList scopeTokens, - RouteTemplate template, - StringComparison comparison) - { - if (scopeTokens.Count < template.Segments.Count) - { - return false; - } - - for (var i = 0; i < template.Segments.Count; i++) - { - var scopeToken = scopeTokens[i]; - var segment = template.Segments[i]; - switch (segment) - { - case LiteralRouteSegment literal - when !string.Equals(literal.Value, scopeToken, comparison): - return false; - case DynamicRouteSegment dynamic - when !RouteConstraintEvaluator.IsMatch(dynamic, scopeToken, _options.Parsing): - return false; - } - } - - return true; - } - - private IReadOnlyList GetCandidateLiterals(PrefixTemplate template, string[] tokens, int index) - { - var routeTemplate = template.Template; - if (routeTemplate.Segments.Count <= index) - { - return []; - } - - for (var i = 0; i < index; i++) - { - var token = tokens[i]; - var segment = routeTemplate.Segments[i]; - // Keep only templates whose resolved prefix still matches the user's input. - if (segment is LiteralRouteSegment literal - && !string.Equals(literal.Value, token, StringComparison.OrdinalIgnoreCase)) - { - return []; - } - - if (segment is DynamicRouteSegment dynamic - && !RouteConstraintEvaluator.IsMatch(dynamic, token, _options.Parsing)) - { - return []; - } - } - - if (routeTemplate.Segments[index] is not LiteralRouteSegment literalSegment) - { - return []; - } - - if (index == routeTemplate.Segments.Count - 1 && template.Aliases.Count > 0) - { - return [literalSegment.Value, .. template.Aliases]; - } - - return [literalSegment.Value]; - } - - private static string? FindBestSuggestion(string input, string[] candidates) - { - if (string.IsNullOrWhiteSpace(input) || candidates.Length == 0) - { - return null; - } - - var exactPrefix = candidates - .FirstOrDefault(candidate => - candidate.StartsWith(input, StringComparison.OrdinalIgnoreCase)); - if (!string.IsNullOrWhiteSpace(exactPrefix)) - { - return exactPrefix; - } - - var normalizedInput = input.ToLowerInvariant(); - var minDistance = int.MaxValue; - string? best = null; - foreach (var candidate in candidates) - { - var distance = ComputeLevenshteinDistance( - normalizedInput, - candidate.ToLowerInvariant()); - if (distance < minDistance) - { - minDistance = distance; - best = candidate; - } - } + StringComparison comparison) => + RoutingEng.IsContextSuppressedForDiscovery(context, scopeTokens, comparison); - var threshold = Math.Max(2, normalizedInput.Length / 3); - return minDistance <= threshold ? best : null; - } + private static IReplResult CreateAmbiguousPrefixResult(PrefixResolutionResult prefixResolution) => + RoutingEngine.CreateAmbiguousPrefixResult(prefixResolution); } diff --git a/src/Repl.Core/CoreReplApp.ShellCompletion.cs b/src/Repl.Core/CoreReplApp.ShellCompletion.cs index a930cb8..5862a97 100644 --- a/src/Repl.Core/CoreReplApp.ShellCompletion.cs +++ b/src/Repl.Core/CoreReplApp.ShellCompletion.cs @@ -1,427 +1,19 @@ -using System.Globalization; -using System.Reflection; -using Repl.Internal.Options; - namespace Repl; public sealed partial class CoreReplApp { - private static readonly string[] StaticShellGlobalOptions = - [ - "--help", - "--interactive", - "--no-interactive", - "--no-logo", - "--output:", - ]; - - private string[] ResolveShellCompletionCandidates(string line, int cursor) - { - var activeGraph = ResolveActiveRoutingGraph(); - var state = AnalyzeShellCompletionInput(line, cursor); - if (state.PriorTokens.Length == 0) - { - return []; - } - - var parsed = state.PriorTokens.Length <= 1 - ? InvocationOptionParser.Parse(Array.Empty()) - : InvocationOptionParser.Parse(new ArraySegment( - state.PriorTokens, - offset: 1, - count: state.PriorTokens.Length - 1)); - var commandPrefix = parsed.PositionalArguments as string[] ?? [.. parsed.PositionalArguments]; - var currentTokenPrefix = state.CurrentTokenPrefix; - var currentTokenIsOption = IsGlobalOptionToken(currentTokenPrefix); - var routeMatch = Resolve(commandPrefix, activeGraph.Routes); - 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( - commandPrefix, - currentTokenPrefix, - activeGraph.Routes, - activeGraph.Contexts, - dedupe, - candidates); - } - - if (currentTokenIsOption || (string.IsNullOrEmpty(currentTokenPrefix) && hasTerminalRoute)) - { - AddShellOptionCandidates( - hasTerminalRoute ? routeMatch!.Route : null, - currentTokenPrefix, - dedupe, - candidates); - } - - candidates.Sort(StringComparer.OrdinalIgnoreCase); - 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, - List candidates) - { - if (string.IsNullOrWhiteSpace(candidate) || !dedupe.Add(candidate)) - { - return; - } - - candidates.Add(candidate); - } - - private void AddShellCommandCandidates( - string[] commandPrefix, - string currentTokenPrefix, - IReadOnlyList routes, - IReadOnlyList contexts, - HashSet dedupe, - List candidates) - { - var matchingRoutes = CollectVisibleMatchingRoutes( - commandPrefix, - StringComparison.OrdinalIgnoreCase, - routes, - contexts); - foreach (var route in matchingRoutes) - { - if (commandPrefix.Length >= route.Template.Segments.Count - || route.Template.Segments[commandPrefix.Length] is not LiteralRouteSegment literal - || !literal.Value.StartsWith(currentTokenPrefix, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - TryAddShellCompletionCandidate(literal.Value, dedupe, candidates); - } - } - - private void AddShellOptionCandidates( - RouteDefinition? route, - string currentTokenPrefix, - HashSet dedupe, - List candidates) - { - AddGlobalShellOptionCandidates(currentTokenPrefix, dedupe, candidates); - - if (route is null) - { - return; - } + private ShellCompletionEngine? _shellCompletionEngine; + private ShellCompletionEngine ShellCompletionEng => _shellCompletionEngine ??= new(this); - AddRouteShellOptionCandidates(route, currentTokenPrefix, dedupe, candidates); - } + private string[] ResolveShellCompletionCandidates(string line, int cursor) => + ShellCompletionEng.ResolveShellCompletionCandidates(line, cursor); - private void AddGlobalShellOptionCandidates( - string currentTokenPrefix, - HashSet dedupe, - List candidates) - { - var comparison = _options.Parsing.OptionCaseSensitivity == ReplCaseSensitivity.CaseInsensitive - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; - foreach (var option in StaticShellGlobalOptions) - { - if (option.StartsWith(currentTokenPrefix, comparison)) - { - TryAddShellCompletionCandidate(option, dedupe, candidates); - } - } - - foreach (var alias in _options.Output.Aliases.Keys) - { - var option = $"--{alias}"; - if (option.StartsWith(currentTokenPrefix, comparison)) - { - TryAddShellCompletionCandidate(option, dedupe, candidates); - } - } - - foreach (var format in _options.Output.Transformers.Keys) - { - var option = $"--output:{format}"; - if (option.StartsWith(currentTokenPrefix, comparison)) - { - TryAddShellCompletionCandidate(option, dedupe, candidates); - } - } - - foreach (var custom in _options.Parsing.GlobalOptions.Values) - { - if (custom.CanonicalToken.StartsWith(currentTokenPrefix, comparison)) - { - TryAddShellCompletionCandidate(custom.CanonicalToken, dedupe, candidates); - } - - foreach (var alias in custom.Aliases) - { - if (alias.StartsWith(currentTokenPrefix, comparison)) - { - TryAddShellCompletionCandidate(alias, dedupe, candidates); - } - } - } - } - - 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(token, dedupe, candidates); - } - } - } - - private static ShellCompletionInputState AnalyzeShellCompletionInput(string input, int cursor) - { - input ??= string.Empty; - cursor = Math.Clamp(cursor, 0, input.Length); - var tokens = TokenizeInputSpans(input); - for (var i = 0; i < tokens.Count; i++) - { - var token = tokens[i]; - if (cursor < token.Start || cursor > token.End) - { - continue; - } - - var prior = new string[i]; - for (var priorIndex = 0; priorIndex < i; priorIndex++) - { - prior[priorIndex] = tokens[priorIndex].Value; - } - - var prefix = input[token.Start..cursor]; - return new ShellCompletionInputState(prior, prefix); - } - - var trailingPriorCount = 0; - foreach (var token in tokens) - { - if (token.End <= cursor) - { - trailingPriorCount++; - } - } - - if (trailingPriorCount == 0) - { - return new ShellCompletionInputState([], CurrentTokenPrefix: string.Empty); - } - - var trailingPrior = new string[trailingPriorCount]; - var index = 0; - foreach (var token in tokens) - { - if (token.End <= cursor) - { - trailingPrior[index++] = token.Value; - } - } - - return new ShellCompletionInputState(trailingPrior, CurrentTokenPrefix: string.Empty); - } - - private readonly record struct ShellCompletionInputState( - string[] PriorTokens, - string CurrentTokenPrefix); + private string ResolveShellCompletionCommandName() => + ShellCompletionEng.ResolveShellCompletionCommandName(); internal static string ResolveShellCompletionCommandName( IReadOnlyList? commandLineArgs, string? processPath, - string? fallbackName) - { - if (commandLineArgs is { Count: > 0 }) - { - var commandHead = TryGetCommandHead(commandLineArgs[0]); - if (!string.IsNullOrWhiteSpace(commandHead)) - { - return commandHead; - } - } - - var processHead = TryGetCommandHead(processPath); - if (!string.IsNullOrWhiteSpace(processHead)) - { - return processHead; - } - - return string.IsNullOrWhiteSpace(fallbackName) ? "repl" : fallbackName; - } - - private static string? TryGetCommandHead(string? pathLike) - { - if (string.IsNullOrWhiteSpace(pathLike)) - { - return null; - } - - var fileName = Path.GetFileName(pathLike.Trim()); - if (string.IsNullOrWhiteSpace(fileName)) - { - return null; - } - - foreach (var extension in KnownExecutableExtensions) - { - if (fileName.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) - { - var head = fileName[..^extension.Length]; - return string.IsNullOrWhiteSpace(head) ? null : head; - } - } - - return fileName; - } - - private static readonly string[] KnownExecutableExtensions = - [ - ".exe", - ".cmd", - ".bat", - ".com", - ".ps1", - ".dll", - ]; - - private string ResolveShellCompletionCommandName() - { - var app = BuildDocumentationApp(); - return ResolveShellCompletionCommandName( - Environment.GetCommandLineArgs(), - 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); - } - } + string? fallbackName) => + ShellCompletionEngine.ResolveShellCompletionCommandName(commandLineArgs, processPath, fallbackName); } diff --git a/src/Repl.Core/CoreReplApp.cs b/src/Repl.Core/CoreReplApp.cs index c36b44c..ba52d49 100644 --- a/src/Repl.Core/CoreReplApp.cs +++ b/src/Repl.Core/CoreReplApp.cs @@ -14,8 +14,6 @@ namespace Repl; /// public sealed partial class CoreReplApp : ICoreReplApp { - private const string AutocompleteModeSessionStateKey = "__repl.autocomplete.mode"; - private readonly List _commands = []; private readonly List _contexts = []; private readonly List _routes = []; @@ -36,8 +34,15 @@ public sealed partial class CoreReplApp : ICoreReplApp private readonly GlobalOptionsSnapshot _globalOptionsSnapshot; internal ReplOptions OptionsSnapshot => _options; + internal string? Description => _description; internal IGlobalOptionsAccessor GlobalOptionsAccessor => _globalOptionsSnapshot; + internal GlobalOptionsSnapshot GlobalOptionsSnapshotInstance => _globalOptionsSnapshot; + internal ShellCompletionRuntime ShellCompletionRuntimeInstance => _shellCompletionRuntime; internal IReplExecutionObserver? ExecutionObserver { get; set; } + internal List Contexts => _contexts; + internal AsyncLocal BannerRendered => _bannerRendered; + internal AsyncLocal AllBannersSuppressed => _allBannersSuppressed; + internal Delegate? Banner => _banner; private CoreReplApp() { @@ -354,36 +359,6 @@ IReplMap IReplMap.MapModule(IReplModule module, Func WithBanner(text); - /// - /// Runs the app in synchronous mode. - /// - /// Command-line arguments. - /// Process exit code. - public int Run(string[] args) - { - ArgumentNullException.ThrowIfNull(args); -#pragma warning disable VSTHRD002 // Sync API intentionally blocks to preserve a conventional Run(...) entrypoint. - return RunAsync(args).AsTask().GetAwaiter().GetResult(); -#pragma warning restore VSTHRD002 - } - - /// - /// Runs the app in asynchronous mode. - /// - /// Command-line arguments. - /// Cancellation token. - /// Process exit code. - public ValueTask RunAsync(string[] args, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(args); - _ = _description; - _ = _commands.Count; - _ = _middleware.Count; - _ = _options; - cancellationToken.ThrowIfCancellationRequested(); - return ExecuteCoreAsync(args, _services, cancellationToken: cancellationToken); - } - internal RouteMatch? Resolve(IReadOnlyList inputTokens) { var activeGraph = ResolveActiveRoutingGraph(); @@ -396,266 +371,14 @@ internal RouteResolver.RouteResolutionResult ResolveWithDiagnostics(IReadOnlyLis return ResolveWithDiagnostics(inputTokens, activeGraph.Routes); } - private RouteMatch? Resolve(IReadOnlyList inputTokens, IReadOnlyList routes) => + internal RouteMatch? Resolve(IReadOnlyList inputTokens, IReadOnlyList routes) => RouteResolver.Resolve(routes, inputTokens, _options.Parsing); - private RouteResolver.RouteResolutionResult ResolveWithDiagnostics( + internal RouteResolver.RouteResolutionResult ResolveWithDiagnostics( IReadOnlyList inputTokens, IReadOnlyList routes) => RouteResolver.ResolveWithDiagnostics(routes, inputTokens, _options.Parsing); - internal ValueTask RunWithServicesAsync( - string[] args, - IServiceProvider serviceProvider, - CancellationToken cancellationToken = default) => - ExecuteCoreAsync(args, serviceProvider, cancellationToken: cancellationToken); - - /// - /// Executes a nested command invocation that preserves the session baseline. - /// Used by MCP tool calls where the global options from the initial session - /// must remain in effect even though the sub-invocation tokens don't contain them. - /// - internal ValueTask RunSubInvocationAsync( - string[] args, - IServiceProvider serviceProvider, - CancellationToken cancellationToken = default) => - ExecuteCoreAsync(args, serviceProvider, isSubInvocation: true, cancellationToken); - - private async ValueTask ExecuteCoreAsync( - IReadOnlyList args, - IServiceProvider serviceProvider, - bool isSubInvocation = false, - CancellationToken cancellationToken = default) - { - _options.Interaction.SetObserver(observer: ExecutionObserver); - try - { - var globalOptions = GlobalOptionParser.Parse(args, _options.Output, _options.Parsing); - _globalOptionsSnapshot.Update(globalOptions.CustomGlobalNamedOptions); // volatile ref swap — safe under concurrent sub-invocations - if (!isSubInvocation) - { - _globalOptionsSnapshot.SetSessionBaseline(); - } - using var runtimeStateScope = PushRuntimeState(serviceProvider, isInteractiveSession: false); - var prefixResolution = ResolveUniquePrefixes(globalOptions.RemainingTokens); - var resolvedGlobalOptions = globalOptions with { RemainingTokens = prefixResolution.Tokens }; - var ambiguousExitCode = await TryHandleAmbiguousPrefixAsync( - prefixResolution, - globalOptions, - resolvedGlobalOptions, - serviceProvider, - cancellationToken) - .ConfigureAwait(false); - if (ambiguousExitCode is not null) return ambiguousExitCode.Value; - - var preResolvedRouteResolution = TryPreResolveRouteForBanner(resolvedGlobalOptions); - if (!ShouldSuppressGlobalBanner(resolvedGlobalOptions, preResolvedRouteResolution?.Match)) - { - await TryRenderBannerAsync(resolvedGlobalOptions, serviceProvider, cancellationToken).ConfigureAwait(false); - } - - var preExecutionExitCode = await TryHandlePreExecutionAsync( - resolvedGlobalOptions, - serviceProvider, - cancellationToken) - .ConfigureAwait(false); - if (preExecutionExitCode is not null) return preExecutionExitCode.Value; - - var resolution = preResolvedRouteResolution - ?? ResolveWithDiagnostics(resolvedGlobalOptions.RemainingTokens); - var match = resolution.Match; - if (match is null) - { - return await TryHandleContextDeeplinkAsync( - resolvedGlobalOptions, - serviceProvider, - cancellationToken, - constraintFailure: resolution.ConstraintFailure, - missingArgumentsFailure: resolution.MissingArgumentsFailure) - .ConfigureAwait(false); - } - - return await ExecuteMatchedCommandAndMaybeEnterInteractiveAsync( - match, - resolvedGlobalOptions, - serviceProvider, - cancellationToken) - .ConfigureAwait(false); - } - finally - { - _options.Interaction.SetObserver(observer: null); - } - } - - private async ValueTask TryHandleAmbiguousPrefixAsync( - PrefixResolutionResult prefixResolution, - GlobalInvocationOptions globalOptions, - GlobalInvocationOptions resolvedGlobalOptions, - IServiceProvider serviceProvider, - CancellationToken cancellationToken) - { - if (!prefixResolution.IsAmbiguous) - { - return null; - } - - if (!ShouldSuppressGlobalBanner(resolvedGlobalOptions, preResolvedMatch: null)) - { - await TryRenderBannerAsync(resolvedGlobalOptions, serviceProvider, cancellationToken).ConfigureAwait(false); - } - - var ambiguous = CreateAmbiguousPrefixResult(prefixResolution); - _ = await RenderOutputAsync(ambiguous, globalOptions.OutputFormat, cancellationToken) - .ConfigureAwait(false); - return 1; - } - - private static bool ShouldSuppressGlobalBanner( - GlobalInvocationOptions globalOptions, - RouteMatch? preResolvedMatch) - { - if (globalOptions.HelpRequested || globalOptions.RemainingTokens.Count == 0) - { - return false; - } - - return preResolvedMatch?.Route.Command.IsProtocolPassthrough == true; - } - - private RouteResolver.RouteResolutionResult? TryPreResolveRouteForBanner(GlobalInvocationOptions globalOptions) - { - if (globalOptions.HelpRequested || globalOptions.RemainingTokens.Count == 0) - { - return null; - } - - return ResolveWithDiagnostics(globalOptions.RemainingTokens); - } - - private async ValueTask TryHandlePreExecutionAsync( - GlobalInvocationOptions options, - IServiceProvider serviceProvider, - CancellationToken cancellationToken) - { - var completionHandled = await TryHandleCompletionCommandAsync(options, serviceProvider, cancellationToken) - .ConfigureAwait(false); - if (completionHandled is not null) - { - return completionHandled.Value; - } - - if (options.HelpRequested) - { - var rendered = await RenderHelpAsync(options, cancellationToken).ConfigureAwait(false); - return rendered ? 0 : 1; - } - - if (options.RemainingTokens.Count == 0) - { - return await HandleEmptyInvocationAsync(options, serviceProvider, cancellationToken) - .ConfigureAwait(false); - } - - return await TryHandleAmbientInNonInteractiveAsync(options, serviceProvider, cancellationToken) - .ConfigureAwait(false); - } - - private async ValueTask ExecuteMatchedCommandAndMaybeEnterInteractiveAsync( - RouteMatch match, - GlobalInvocationOptions globalOptions, - IServiceProvider serviceProvider, - CancellationToken cancellationToken) - { - if (match.Route.Command.IsProtocolPassthrough - && ReplSessionIO.IsHostedSession - && !match.Route.Command.SupportsHostedProtocolPassthrough) - { - _ = await RenderOutputAsync( - Results.Error( - "protocol_passthrough_hosted_not_supported", - $"Command '{match.Route.Template.Template}' is protocol passthrough and requires a handler parameter of type IReplIoContext in hosted sessions."), - globalOptions.OutputFormat, - cancellationToken) - .ConfigureAwait(false); - return 1; - } - - if (match.Route.Command.IsProtocolPassthrough) - { - return await ExecuteProtocolPassthroughCommandAsync(match, globalOptions, serviceProvider, cancellationToken) - .ConfigureAwait(false); - } - - var (exitCode, enterInteractive) = await ExecuteMatchedCommandAsync( - match, - globalOptions, - serviceProvider, - scopeTokens: null, - cancellationToken) - .ConfigureAwait(false); - - if (enterInteractive || (exitCode == 0 && ShouldEnterInteractive(globalOptions, allowAuto: false))) - { - var matchedPathLength = globalOptions.RemainingTokens.Count - match.RemainingTokens.Count; - var matchedPathTokens = globalOptions.RemainingTokens.Take(matchedPathLength).ToArray(); - var interactiveScope = GetDeepestContextScopePath(matchedPathTokens); - return await RunInteractiveSessionAsync(interactiveScope, serviceProvider, cancellationToken).ConfigureAwait(false); - } - - return exitCode; - } - - private async ValueTask ExecuteProtocolPassthroughCommandAsync( - RouteMatch match, - GlobalInvocationOptions globalOptions, - IServiceProvider serviceProvider, - CancellationToken cancellationToken) - { - if (ReplSessionIO.IsSessionActive) - { - var (exitCode, _) = await ExecuteMatchedCommandAsync( - match, - globalOptions, - serviceProvider, - scopeTokens: null, - cancellationToken) - .ConfigureAwait(false); - return exitCode; - } - - using var protocolScope = ReplSessionIO.SetSession( - Console.Error, - Console.In, - ansiMode: AnsiMode.Never, - commandOutput: Console.Out, - error: Console.Error, - isHostedSession: false); - var (code, _) = await ExecuteMatchedCommandAsync( - match, - globalOptions, - serviceProvider, - scopeTokens: null, - cancellationToken) - .ConfigureAwait(false); - return code; - } - - private async ValueTask HandleEmptyInvocationAsync( - GlobalInvocationOptions globalOptions, - IServiceProvider serviceProvider, - CancellationToken cancellationToken) - { - if (ShouldEnterInteractive(globalOptions, allowAuto: true)) - { - return await RunInteractiveSessionAsync([], serviceProvider, cancellationToken).ConfigureAwait(false); - } - - var helpText = BuildHumanHelp([]); - await ReplSessionIO.Output.WriteLineAsync(helpText).ConfigureAwait(false); - return 0; - } - private void MapModuleCore( IReplModule module, Func isPresent, @@ -681,7 +404,7 @@ private int RegisterModule(Func isPresent) return moduleId; } - private int ResolveCurrentMappingModuleId() => + internal int ResolveCurrentMappingModuleId() => _moduleMappingScope.Count == 0 ? 0 : _moduleMappingScope.Peek(); private ReplRuntimeChannel ResolveCurrentRuntimeChannel() @@ -701,7 +424,7 @@ private ReplRuntimeChannel ResolveCurrentRuntimeChannel() : ReplRuntimeChannel.Cli; } - private ActiveRoutingGraph ResolveActiveRoutingGraph() + internal ActiveRoutingGraph ResolveActiveRoutingGraph() { var runtime = _runtimeState.Value; var serviceProvider = runtime?.ServiceProvider ?? _services; @@ -800,7 +523,7 @@ private ContextDefinition[] ResolveActiveContexts(HashSet activeModuleIds) ]; } - private RuntimeStateScope PushRuntimeState(IServiceProvider serviceProvider, bool isInteractiveSession) + internal RuntimeStateScope PushRuntimeState(IServiceProvider serviceProvider, bool isInteractiveSession) { var previous = _runtimeState.Value; _runtimeState.Value = new InvocationRuntimeState(serviceProvider, isInteractiveSession); @@ -815,477 +538,11 @@ private static string ResolveEntryAssemblyName() ?? string.Empty; } - private async ValueTask TryHandleCompletionCommandAsync( - GlobalInvocationOptions options, - IServiceProvider serviceProvider, - CancellationToken cancellationToken) - { - if (options.RemainingTokens.Count == 0 - || !string.Equals(options.RemainingTokens[0], "complete", StringComparison.OrdinalIgnoreCase)) - { - return null; - } - - var completed = await HandleCompletionAmbientCommandAsync( - commandTokens: options.RemainingTokens.Skip(1).ToArray(), - scopeTokens: [], - serviceProvider: serviceProvider, - cancellationToken: cancellationToken) - .ConfigureAwait(false); - return completed ? 0 : 1; - } - - private async ValueTask TryHandleAmbientInNonInteractiveAsync( - GlobalInvocationOptions options, - IServiceProvider serviceProvider, - CancellationToken cancellationToken) - { - if (options.RemainingTokens.Count != 1) - { - return null; - } - - var token = options.RemainingTokens[0]; - AmbientCommandOutcome ambientOutcome; - if (string.Equals(token, "exit", StringComparison.OrdinalIgnoreCase)) - { - ambientOutcome = await HandleExitAmbientCommandAsync().ConfigureAwait(false); - } - else if (string.Equals(token, "..", StringComparison.Ordinal)) - { - ambientOutcome = await HandleUpAmbientCommandAsync(scopeTokens: [], isInteractiveSession: false) - .ConfigureAwait(false); - } - else - { - return null; - } - - return ambientOutcome switch - { - AmbientCommandOutcome.Exit => 0, - AmbientCommandOutcome.Handled => 0, - AmbientCommandOutcome.HandledError => 1, - _ => null, - }; - } - - private async ValueTask TryRenderBannerAsync( - GlobalInvocationOptions globalOptions, - IServiceProvider serviceProvider, - CancellationToken cancellationToken) - { - if (globalOptions.LogoSuppressed) - { - _allBannersSuppressed.Value = true; - } - - if (_bannerRendered.Value || _allBannersSuppressed.Value || !_options.Output.BannerEnabled) - { - return; - } - - var requestedFormat = string.IsNullOrWhiteSpace(globalOptions.OutputFormat) - ? _options.Output.DefaultFormat - : globalOptions.OutputFormat; - if (!_options.Output.BannerFormats.Contains(requestedFormat)) - { - return; - } - - var banner = BuildBannerText(); - if (!string.IsNullOrWhiteSpace(banner)) - { - await ReplSessionIO.Output.WriteLineAsync(banner).ConfigureAwait(false); - } - - if (_banner is not null) - { - await InvokeBannerAsync(_banner, serviceProvider, cancellationToken).ConfigureAwait(false); - } - - _bannerRendered.Value = true; - } - - private async ValueTask TryHandleContextDeeplinkAsync( - GlobalInvocationOptions globalOptions, - IServiceProvider serviceProvider, - CancellationToken cancellationToken, - RouteResolver.RouteConstraintFailure? constraintFailure = null, - RouteResolver.RouteMissingArgumentsFailure? missingArgumentsFailure = null) - { - var activeGraph = ResolveActiveRoutingGraph(); - var contextMatch = ContextResolver.ResolveExact(activeGraph.Contexts, globalOptions.RemainingTokens, _options.Parsing); - if (contextMatch is null) - { - var failure = CreateRouteResolutionFailureResult( - tokens: globalOptions.RemainingTokens, - constraintFailure, - missingArgumentsFailure); - _ = await RenderOutputAsync(failure, globalOptions.OutputFormat, cancellationToken) - .ConfigureAwait(false); - return 1; - } - - var contextValidation = await ValidateContextAsync(contextMatch, serviceProvider, cancellationToken) - .ConfigureAwait(false); - if (!contextValidation.IsValid) - { - _ = await RenderOutputAsync( - contextValidation.Failure, - globalOptions.OutputFormat, - cancellationToken) - .ConfigureAwait(false); - return 1; - } - - if (!ShouldEnterInteractive(globalOptions, allowAuto: true)) - { - var helpText = BuildHumanHelp(globalOptions.RemainingTokens); - await ReplSessionIO.Output.WriteLineAsync(helpText).ConfigureAwait(false); - return 0; - } - - return await RunInteractiveSessionAsync(globalOptions.RemainingTokens.ToArray(), serviceProvider, cancellationToken) - .ConfigureAwait(false); - } - - [SuppressMessage( - "Maintainability", - "MA0051:Method is too long", - Justification = "Execution path intentionally keeps validation, binding, middleware and rendering in one place.")] - private async ValueTask<(int ExitCode, bool EnterInteractive)> ExecuteMatchedCommandAsync( - RouteMatch match, - GlobalInvocationOptions globalOptions, - IServiceProvider serviceProvider, - List? scopeTokens, - CancellationToken cancellationToken) - { - var activeGraph = ResolveActiveRoutingGraph(); - _options.Interaction.SetPrefilledAnswers(globalOptions.PromptAnswers); - 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, false); - } - - 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, false); - } - 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, - cancellationToken); - try - { - var arguments = HandlerArgumentBinder.Bind(match.Route.Command.Handler, bindingContext); - var contextFailure = await ValidateContextsForMatchAsync( - match, - matchedPathTokens, - activeGraph.Contexts, - serviceProvider, - cancellationToken) - .ConfigureAwait(false); - if (contextFailure is not null) - { - _ = await RenderOutputAsync(contextFailure, globalOptions.OutputFormat, cancellationToken) - .ConfigureAwait(false); - return (1, false); - } - - await TryRenderCommandBannerAsync(match.Route.Command, globalOptions.OutputFormat, serviceProvider, cancellationToken) - .ConfigureAwait(false); - var result = await ExecuteWithMiddlewareAsync( - match.Route.Command.Handler, - arguments, - serviceProvider, - cancellationToken) - .ConfigureAwait(false); - - if (TupleDecomposer.IsTupleResult(result, out var tuple)) - { - return await RenderTupleResultAsync(tuple, scopeTokens, globalOptions, cancellationToken) - .ConfigureAwait(false); - } - - if (result is EnterInteractiveResult enterInteractive) - { - if (enterInteractive.Payload is not null) - { - _ = await RenderOutputAsync(enterInteractive.Payload, globalOptions.OutputFormat, cancellationToken, scopeTokens is not null) - .ConfigureAwait(false); - } - - return (0, true); - } - - var normalizedResult = ApplyNavigationResult(result, scopeTokens); - ExecutionObserver?.OnResult(normalizedResult); - var rendered = await RenderOutputAsync(normalizedResult, globalOptions.OutputFormat, cancellationToken, scopeTokens is not null) - .ConfigureAwait(false); - return (rendered ? ComputeExitCode(normalizedResult) : 1, false); - } - catch (OperationCanceledException) - { - throw; - } - catch (InvalidOperationException ex) - { - _ = await RenderOutputAsync(Results.Validation(ex.Message), globalOptions.OutputFormat, cancellationToken) - .ConfigureAwait(false); - return (1, false); - } - catch (Exception ex) - { - var errorMessage = ex is TargetInvocationException { InnerException: not null } tie - ? tie.InnerException?.Message ?? ex.Message - : ex.Message; - _ = await RenderOutputAsync( - Results.Error("execution_error", errorMessage), - globalOptions.OutputFormat, - cancellationToken) - .ConfigureAwait(false); - return (1, false); - } - } - - private async ValueTask<(int ExitCode, bool EnterInteractive)> RenderTupleResultAsync( - ITuple tuple, - List? scopeTokens, - GlobalInvocationOptions globalOptions, - CancellationToken cancellationToken) - { - var isInteractive = scopeTokens is not null; - var exitCode = 0; - var enterInteractive = false; - - for (var i = 0; i < tuple.Length; i++) - { - var element = tuple[i]; - - // EnterInteractiveResult: extract payload (if any) and signal interactive entry. - if (element is EnterInteractiveResult eir) - { - enterInteractive = true; - element = eir.Payload; - if (element is null) - { - continue; - } - } - - var isLast = i == tuple.Length - 1; - - // Navigation results: only apply navigation on the last element. - var normalized = element is ReplNavigationResult nav && !isLast - ? nav.Payload - : isLast - ? ApplyNavigationResult(element, scopeTokens) - : element; - - ExecutionObserver?.OnResult(normalized); - - var rendered = await RenderOutputAsync(normalized, globalOptions.OutputFormat, cancellationToken, isInteractive) - .ConfigureAwait(false); - - if (!rendered) - { - return (1, false); - } - - if (isLast) - { - exitCode = ComputeExitCode(normalized); - } - } - - return (exitCode, enterInteractive); - } - - private static int ComputeExitCode(object? result) - { - if (result is IExitResult exitResult) - { - return exitResult.ExitCode; - } - - if (result is not IReplResult replResult) - { - return 0; - } - - var kind = replResult.Kind.ToLowerInvariant(); - if (kind is "text" or "success") - { - return 0; - } - - if (kind is "error" or "validation" or "not_found") - { - return 1; - } - - return 1; - } - - private async ValueTask RenderOutputAsync( - object? result, - string? requestedFormat, - CancellationToken cancellationToken, - bool isInteractive = false) - { - if (result is IExitResult exitResult) - { - if (exitResult.Payload is null) - { - return true; - } - - result = exitResult.Payload; - } - - var format = string.IsNullOrWhiteSpace(requestedFormat) - ? _options.Output.DefaultFormat - : requestedFormat; - if (!_options.Output.Transformers.TryGetValue(format, out var transformer)) - { - // Unknown format is a user-facing validation issue; avoid silent failures from exception swallowing. - await ReplSessionIO.Output.WriteLineAsync($"Error: unknown output format '{format}'.").ConfigureAwait(false); - return false; - } - - var payload = await transformer.TransformAsync(result, cancellationToken).ConfigureAwait(false); - payload = TryColorizeStructuredPayload(payload, format, isInteractive); - if (!string.IsNullOrEmpty(payload)) - { - await ReplSessionIO.Output.WriteLineAsync(payload).ConfigureAwait(false); - } - - return true; - } - - private string TryColorizeStructuredPayload(string payload, string format, bool isInteractive) - { - if (string.IsNullOrEmpty(payload) - || !isInteractive - || !_options.Output.ColorizeStructuredInteractive - || !_options.Output.IsAnsiEnabled() - || !string.Equals(format, "json", StringComparison.OrdinalIgnoreCase)) - { - return payload; - } - - return JsonAnsiColorizer.Colorize(payload, _options.Output.ResolvePalette()); - } - - private async ValueTask RenderHelpAsync( - GlobalInvocationOptions globalOptions, - CancellationToken cancellationToken) - { - var activeGraph = ResolveActiveRoutingGraph(); - var discoverableRoutes = ResolveDiscoverableRoutes( - activeGraph.Routes, - activeGraph.Contexts, - globalOptions.RemainingTokens, - StringComparison.OrdinalIgnoreCase); - var discoverableContexts = ResolveDiscoverableContexts( - activeGraph.Contexts, - globalOptions.RemainingTokens, - StringComparison.OrdinalIgnoreCase); - var requestedFormat = string.IsNullOrWhiteSpace(globalOptions.OutputFormat) - ? _options.Output.DefaultFormat - : globalOptions.OutputFormat; - if (string.Equals(requestedFormat, "human", StringComparison.OrdinalIgnoreCase)) - { - var helpText = BuildHumanHelp(globalOptions.RemainingTokens); - await ReplSessionIO.Output.WriteLineAsync(helpText).ConfigureAwait(false); - return true; - } - - var machineHelp = HelpTextBuilder.BuildModel( - discoverableRoutes, - discoverableContexts, - globalOptions.RemainingTokens, - _options.Parsing); - return await RenderOutputAsync(machineHelp, requestedFormat, cancellationToken).ConfigureAwait(false); - } - - private async ValueTask ExecuteWithMiddlewareAsync( - Delegate handler, - object?[] arguments, - IServiceProvider serviceProvider, - CancellationToken cancellationToken) - { - object? result = null; - var context = new ReplExecutionContext(serviceProvider, cancellationToken); - var index = -1; - - async ValueTask NextAsync() - { - index++; - if (index == _middleware.Count) - { - result = await CommandInvoker - .InvokeAsync(handler, arguments) - .ConfigureAwait(false); - return; - } - - var middleware = _middleware[index]; - await middleware(context, NextAsync).ConfigureAwait(false); - } - - await NextAsync().ConfigureAwait(false); - return result; - } - - private static string? NormalizePath(string? path) - { - if (string.IsNullOrWhiteSpace(path)) - { - return null; - } - - var parts = path - .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - return parts.Length == 0 - ? null - : string.Join(' ', parts); - } - [SuppressMessage( "Maintainability", "MA0051:Method is too long", Justification = "Levenshtein implementation keeps pooling and fast-path logic explicit for readability.")] - private static int ComputeLevenshteinDistance(string source, string target) + internal static int ComputeLevenshteinDistance(string source, string target) { if (string.Equals(source, target, StringComparison.Ordinal)) { @@ -1358,43 +615,6 @@ private static int ComputeLevenshteinDistance(string source, string target) } } - private static object? ApplyNavigationResult(object? result, List? scopeTokens) - { - if (result is not ReplNavigationResult navigation) - { - return result; - } - - if (scopeTokens is null) - { - return navigation.Payload; - } - - ApplyNavigation(scopeTokens, navigation); - return navigation.Payload; - } - - private static void ApplyNavigation(List scopeTokens, ReplNavigationResult navigation) - { - if (navigation.Kind == ReplNavigationKind.Up) - { - if (scopeTokens.Count > 0) - { - scopeTokens.RemoveAt(scopeTokens.Count - 1); - } - - return; - } - - if (!string.IsNullOrWhiteSpace(navigation.TargetPath)) - { - scopeTokens.Clear(); - scopeTokens.AddRange( - navigation.TargetPath - .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); - } - } - private static void ApplyMetadataFromAttributes(CommandBuilder command, Delegate handler) { ArgumentNullException.ThrowIfNull(command); @@ -1433,12 +653,12 @@ private DefaultServiceProvider CreateDefaultServiceProvider() return new DefaultServiceProvider(defaults); } - private static bool IsHelpToken(string token) => + internal static bool IsHelpToken(string token) => string.Equals(token, "help", StringComparison.OrdinalIgnoreCase) || string.Equals(token, "?", StringComparison.Ordinal); - private string BuildHumanHelp(IReadOnlyList scopeTokens) + internal string BuildHumanHelp(IReadOnlyList scopeTokens) { var activeGraph = ResolveActiveRoutingGraph(); var discoverableRoutes = ResolveDiscoverableRoutes( @@ -1462,30 +682,12 @@ private string BuildHumanHelp(IReadOnlyList scopeTokens) palette: settings.Palette); } - private readonly record struct ContextValidationOutcome(bool IsValid, IReplResult? Failure) - { - public static ContextValidationOutcome Success { get; } = - new(IsValid: true, Failure: null); - - public static ContextValidationOutcome FromFailure(IReplResult failure) => - new(IsValid: false, Failure: failure); - } - - private readonly record struct PrefixTemplate( - RouteTemplate Template, - bool IsHidden, - IReadOnlyList Aliases); - - private readonly record struct ActiveRoutingGraph( - RouteDefinition[] Routes, - ContextDefinition[] Contexts, - ReplRuntimeChannel Channel); private readonly record struct ModuleRegistration( int ModuleId, Func IsPresent); - private readonly record struct InvocationRuntimeState( + internal readonly record struct InvocationRuntimeState( IServiceProvider ServiceProvider, bool IsInteractiveSession); @@ -1496,15 +698,7 @@ private sealed class RoutingCacheEntry(long version, ActiveRoutingGraph graph) public ActiveRoutingGraph Graph { get; } = graph; } - private enum AmbientCommandOutcome - { - NotHandled, - Handled, - HandledError, - Exit, - } - - private sealed class RuntimeStateScope( + internal sealed class RuntimeStateScope( AsyncLocal state, InvocationRuntimeState? previous) : IDisposable { @@ -1562,30 +756,4 @@ 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, - mergedNamedOptions, - parsedOptions.PositionalArguments, - match.Route.OptionSchema, - commandParsingOptions.OptionCaseSensitivity, - contextValues, - _options.Parsing.NumericFormatProvider, - serviceProvider, - _options.Interaction, - cancellationToken); - } } diff --git a/src/Repl.Core/Documentation/DocumentationEngine.cs b/src/Repl.Core/Documentation/DocumentationEngine.cs new file mode 100644 index 0000000..8b3f002 --- /dev/null +++ b/src/Repl.Core/Documentation/DocumentationEngine.cs @@ -0,0 +1,499 @@ +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Repl.Internal.Options; + +namespace Repl; + +/// +/// Generates documentation models from the Repl routing graph. +/// +internal sealed class DocumentationEngine(CoreReplApp app) +{ + /// + /// Creates a documentation model for the given target path. + /// + public ReplDocumentationModel CreateDocumentationModel(string? targetPath = null) + { + var activeGraph = app.ResolveActiveRoutingGraph(); + var normalizedTargetPath = NormalizePath(targetPath); + var targetTokens = string.IsNullOrWhiteSpace(normalizedTargetPath) + ? [] + : normalizedTargetPath + .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var discoverableRoutes = app.ResolveDiscoverableRoutes( + activeGraph.Routes, + activeGraph.Contexts, + targetTokens, + StringComparison.OrdinalIgnoreCase); + var discoverableContexts = app.ResolveDiscoverableContexts( + activeGraph.Contexts, + targetTokens, + StringComparison.OrdinalIgnoreCase); + var commands = SelectDocumentationCommands( + normalizedTargetPath, + discoverableRoutes, + discoverableContexts, + out _); + + var contexts = SelectDocumentationContexts(normalizedTargetPath, commands, discoverableContexts); + var commandDocs = commands.Select(BuildDocumentationCommand).ToArray(); + var contextDocs = contexts + .Select(context => new ReplDocContext( + Path: context.Template.Template, + Description: context.Description, + IsDynamic: context.Template.Segments.Any(segment => segment is DynamicRouteSegment), + IsHidden: context.IsHidden, + Details: context.Details)) + .ToArray(); + var resourceDocs = commandDocs + .Where(cmd => cmd.IsResource || cmd.Annotations?.ReadOnly == true) + .Select(cmd => new ReplDocResource( + Path: cmd.Path, + Description: cmd.Description, + Details: cmd.Details, + Arguments: cmd.Arguments, + Options: cmd.Options)) + .ToArray(); + return new ReplDocumentationModel( + App: BuildDocumentationApp(), + Contexts: contextDocs, + Commands: commandDocs, + Resources: resourceDocs); + } + + /// + /// Creates a documentation model with an explicit service provider for runtime state. + /// + public ReplDocumentationModel CreateDocumentationModel( + IServiceProvider serviceProvider, + string? targetPath = null) + { + ArgumentNullException.ThrowIfNull(serviceProvider); + + using var runtimeStateScope = app.PushRuntimeState(serviceProvider, isInteractiveSession: false); + return CreateDocumentationModel(targetPath); + } + + /// + /// Internal documentation model creation that supports not-found result for help rendering. + /// + public object CreateDocumentationModelInternal(string? targetPath) + { + var activeGraph = app.ResolveActiveRoutingGraph(); + var normalizedTargetPath = NormalizePath(targetPath); + var targetTokens = string.IsNullOrWhiteSpace(normalizedTargetPath) + ? [] + : normalizedTargetPath + .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var discoverableRoutes = app.ResolveDiscoverableRoutes( + activeGraph.Routes, + activeGraph.Contexts, + targetTokens, + StringComparison.OrdinalIgnoreCase); + var discoverableContexts = app.ResolveDiscoverableContexts( + activeGraph.Contexts, + targetTokens, + StringComparison.OrdinalIgnoreCase); + var commands = SelectDocumentationCommands( + normalizedTargetPath, + discoverableRoutes, + discoverableContexts, + out var notFoundResult); + if (notFoundResult is not null) + { + return notFoundResult; + } + + var contexts = SelectDocumentationContexts(normalizedTargetPath, commands, discoverableContexts); + var commandDocs = commands.Select(BuildDocumentationCommand).ToArray(); + var contextDocs = contexts + .Select(context => new ReplDocContext( + Path: context.Template.Template, + Description: context.Description, + IsDynamic: context.Template.Segments.Any(segment => segment is DynamicRouteSegment), + IsHidden: context.IsHidden, + Details: context.Details)) + .ToArray(); + var resourceDocs = commandDocs + .Where(cmd => cmd.IsResource || cmd.Annotations?.ReadOnly == true) + .Select(cmd => new ReplDocResource( + Path: cmd.Path, + Description: cmd.Description, + Details: cmd.Details, + Arguments: cmd.Arguments, + Options: cmd.Options)) + .ToArray(); + return new ReplDocumentationModel( + App: BuildDocumentationApp(), + Contexts: contextDocs, + Commands: commandDocs, + Resources: resourceDocs); + } + + private static RouteDefinition[] SelectDocumentationCommands( + string? normalizedTargetPath, + IReadOnlyList routes, + IReadOnlyList contexts, + out IReplResult? notFoundResult) + { + notFoundResult = null; + if (string.IsNullOrWhiteSpace(normalizedTargetPath)) + { + return routes.Where(route => !route.Command.IsHidden).ToArray(); + } + + var exactCommand = routes.FirstOrDefault( + route => string.Equals( + route.Template.Template, + normalizedTargetPath, + StringComparison.OrdinalIgnoreCase)); + if (exactCommand is not null) + { + return [exactCommand]; + } + + var exactContext = contexts.FirstOrDefault( + context => string.Equals( + context.Template.Template, + normalizedTargetPath, + StringComparison.OrdinalIgnoreCase)); + if (exactContext is not null) + { + return routes + .Where(route => + !route.Command.IsHidden + && route.Template.Template.StartsWith( + $"{exactContext.Template.Template} ", + StringComparison.OrdinalIgnoreCase)) + .ToArray(); + } + + notFoundResult = Results.NotFound($"Documentation target '{normalizedTargetPath}' not found."); + return []; + } + + private static ContextDefinition[] SelectDocumentationContexts( + string? normalizedTargetPath, + RouteDefinition[] commands, + IReadOnlyList contexts) + { + if (string.IsNullOrWhiteSpace(normalizedTargetPath)) + { + return [.. contexts]; + } + + var exactContext = contexts.FirstOrDefault( + context => string.Equals( + context.Template.Template, + normalizedTargetPath, + StringComparison.OrdinalIgnoreCase)); + if (exactContext is not null) + { + return [exactContext]; + } + + if (commands.Length == 0) + { + return []; + } + + var selected = contexts + .Where(context => commands.Any(command => + command.Template.Template.StartsWith( + $"{context.Template.Template} ", + StringComparison.OrdinalIgnoreCase) + || string.Equals( + command.Template.Template, + context.Template.Template, + StringComparison.OrdinalIgnoreCase))) + .ToArray(); + return selected; + } + + private ReplDocCommand BuildDocumentationCommand(RouteDefinition route) + { + var dynamicSegments = route.Template.Segments + .OfType() + .ToArray(); + var routeParameterNames = dynamicSegments + .Select(segment => segment.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var handlerParams = route.Command.Handler.Method.GetParameters(); + var arguments = dynamicSegments + .Select(segment => + { + var paramInfo = handlerParams.FirstOrDefault(p => + string.Equals(p.Name, segment.Name, StringComparison.OrdinalIgnoreCase)); + var description = paramInfo?.GetCustomAttribute()?.Description; + return new ReplDocArgument( + Name: segment.Name, + Type: GetConstraintTypeName(segment.ConstraintKind), + Required: !segment.IsOptional, + Description: description); + }) + .ToArray(); + var regularOptions = handlerParams + .Where(parameter => + !string.IsNullOrWhiteSpace(parameter.Name) + && parameter.ParameterType != typeof(CancellationToken) + && !routeParameterNames.Contains(parameter.Name!) + && !IsFrameworkInjectedParameter(parameter.ParameterType) + && parameter.GetCustomAttribute() is null + && parameter.GetCustomAttribute() is null + && !Attribute.IsDefined(parameter.ParameterType, typeof(ReplOptionsGroupAttribute), inherit: true)) + .Select(parameter => BuildDocumentationOption(route.OptionSchema, parameter)); + var groupOptions = handlerParams + .Where(parameter => Attribute.IsDefined(parameter.ParameterType, typeof(ReplOptionsGroupAttribute), inherit: true)) + .SelectMany(parameter => + { + var defaultInstance = CreateOptionsGroupDefault(parameter.ParameterType); + return GetOptionsGroupProperties(parameter.ParameterType) + .Where(prop => prop.CanWrite) + .Select(prop => BuildDocumentationOptionFromProperty(route.OptionSchema, prop, defaultInstance)); + }); + var options = regularOptions.Concat(groupOptions).ToArray(); + + var answers = BuildDocumentationAnswers(route.Command); + + return new ReplDocCommand( + Path: route.Template.Template, + Description: route.Command.Description, + Aliases: route.Command.Aliases, + IsHidden: route.Command.IsHidden, + Arguments: arguments, + Options: options, + Details: route.Command.Details, + Annotations: route.Command.Annotations, + Metadata: route.Command.Metadata.Count > 0 ? route.Command.Metadata : null, + Answers: answers.Length > 0 ? answers : null, + IsResource: route.Command.IsResource, + IsPrompt: route.Command.IsPrompt); + } + + private static ReplDocAnswer[] BuildDocumentationAnswers(CommandBuilder command) + { + var fluentAnswers = command.Answers + .Select(a => new ReplDocAnswer(a.Name, a.Type, a.Description)); + var attributeAnswers = command.Handler.Method + .GetCustomAttributes() + .Select(a => new ReplDocAnswer(a.Name, a.Type, a.Description)); + return fluentAnswers + .Concat(attributeAnswers) + .GroupBy(a => a.Name, StringComparer.OrdinalIgnoreCase) + .Select(g => g.First()) + .ToArray(); + } + + internal ReplDocApp BuildDocumentationApp() + { + var assembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly(); + var name = assembly.GetCustomAttribute()?.Product + ?? assembly.GetName().Name + ?? "repl"; + var version = assembly.GetCustomAttribute()?.InformationalVersion + ?? assembly.GetName().Version?.ToString(); + var description = app.Description + ?? assembly.GetCustomAttribute()?.Description; + return new ReplDocApp(name, version, description); + } + + private static bool IsFrameworkInjectedParameter(Type parameterType) => + parameterType == typeof(IServiceProvider) + || parameterType == typeof(ICoreReplApp) + || parameterType == typeof(CoreReplApp) + || parameterType == typeof(IReplSessionState) + || parameterType == typeof(IReplInteractionChannel) + || parameterType == typeof(IReplIoContext) + || parameterType == typeof(IReplKeyReader) + || string.Equals(parameterType.FullName, "Repl.Mcp.IMcpClientRoots", StringComparison.Ordinal); + + private static bool IsRequiredParameter(ParameterInfo parameter) + { + if (parameter.HasDefaultValue) + { + return false; + } + + if (!parameter.ParameterType.IsValueType) + { + return false; + } + + return Nullable.GetUnderlyingType(parameter.ParameterType) is null; + } + + internal static string GetConstraintTypeName(RouteConstraintKind kind) => + kind switch + { + RouteConstraintKind.String => "string", + RouteConstraintKind.Alpha => "string", + RouteConstraintKind.Bool => "bool", + RouteConstraintKind.Email => "email", + RouteConstraintKind.Uri => "uri", + RouteConstraintKind.Url => "url", + RouteConstraintKind.Urn => "urn", + RouteConstraintKind.Time => "time", + RouteConstraintKind.Date => "date", + RouteConstraintKind.DateTime => "datetime", + RouteConstraintKind.DateTimeOffset => "datetimeoffset", + RouteConstraintKind.TimeSpan => "timespan", + RouteConstraintKind.Guid => "guid", + RouteConstraintKind.Long => "long", + RouteConstraintKind.Int => "int", + RouteConstraintKind.Custom => "custom", + _ => "string", + }; + + private static string GetFriendlyTypeName(Type type) + { + var underlying = Nullable.GetUnderlyingType(type); + if (underlying is not null) + { + return $"{GetFriendlyTypeName(underlying)}?"; + } + + if (type.IsEnum) + { + return string.Join('|', Enum.GetNames(type)); + } + + if (!type.IsGenericType) + { + return type.Name.ToLowerInvariant() switch + { + "string" => "string", + "int32" => "int", + "int64" => "long", + "boolean" => "bool", + "double" => "double", + "decimal" => "decimal", + "dateonly" => "date", + "datetime" => "datetime", + "timeonly" => "time", + "datetimeoffset" => "datetimeoffset", + "timespan" => "timespan", + "repldaterange" => "date-range", + "repldatetimerange" => "datetime-range", + "repldatetimeoffsetrange" => "datetimeoffset-range", + _ => type.Name, + }; + } + + var genericName = type.Name[..type.Name.IndexOf('`')]; + var genericArgs = string.Join(", ", type.GetGenericArguments().Select(GetFriendlyTypeName)); + return $"{genericName}<{genericArgs}>"; + } + + private static ReplDocOption BuildDocumentationOptionFromProperty( + OptionSchema schema, + PropertyInfo property, + object defaultInstance) + { + var entries = schema.Entries + .Where(entry => string.Equals(entry.ParameterName, property.Name, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + var aliases = entries + .Where(entry => entry.TokenKind is OptionSchemaTokenKind.NamedOption or OptionSchemaTokenKind.BoolFlag) + .Select(entry => entry.Token) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var reverseAliases = entries + .Where(entry => entry.TokenKind == OptionSchemaTokenKind.ReverseFlag) + .Select(entry => entry.Token) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var valueAliases = entries + .Where(entry => entry.TokenKind is OptionSchemaTokenKind.ValueAlias or OptionSchemaTokenKind.EnumAlias) + .Select(entry => new ReplDocValueAlias(entry.Token, entry.InjectedValue ?? string.Empty)) + .GroupBy(alias => alias.Token, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .ToArray(); + var effectiveType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; + var enumValues = effectiveType.IsEnum + ? Enum.GetNames(effectiveType) + : []; + var propDefault = property.GetValue(defaultInstance); + var defaultValue = propDefault is not null + ? propDefault.ToString() + : null; + return new ReplDocOption( + Name: property.Name, + Type: GetFriendlyTypeName(property.PropertyType), + Required: false, + Description: property.GetCustomAttribute()?.Description, + Aliases: aliases, + ReverseAliases: reverseAliases, + ValueAliases: valueAliases, + EnumValues: enumValues, + DefaultValue: defaultValue); + } + + private static ReplDocOption BuildDocumentationOption(OptionSchema schema, ParameterInfo parameter) + { + var entries = schema.Entries + .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); + } + + [UnconditionalSuppressMessage( + "Trimming", + "IL2067", + Justification = "Options group types are user-defined and always preserved by the handler delegate reference.")] + private static object CreateOptionsGroupDefault(Type groupType) => + Activator.CreateInstance(groupType)!; + + [UnconditionalSuppressMessage( + "Trimming", + "IL2070", + Justification = "Options group types are user-defined and always preserved by the handler delegate reference.")] + private static PropertyInfo[] GetOptionsGroupProperties(Type groupType) => + groupType.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + private static string? NormalizePath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + var parts = path + .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return parts.Length == 0 + ? null + : string.Join(' ', parts); + } +} diff --git a/src/Repl.Core/ReplDocumentationExtensions.cs b/src/Repl.Core/Documentation/ReplDocumentationExtensions.cs similarity index 100% rename from src/Repl.Core/ReplDocumentationExtensions.cs rename to src/Repl.Core/Documentation/ReplDocumentationExtensions.cs diff --git a/src/Repl.Core/HelpCommandModel.cs b/src/Repl.Core/Help/HelpCommandModel.cs similarity index 100% rename from src/Repl.Core/HelpCommandModel.cs rename to src/Repl.Core/Help/HelpCommandModel.cs diff --git a/src/Repl.Core/HelpDocumentModel.cs b/src/Repl.Core/Help/HelpDocumentModel.cs similarity index 100% rename from src/Repl.Core/HelpDocumentModel.cs rename to src/Repl.Core/Help/HelpDocumentModel.cs diff --git a/src/Repl.Core/HelpTextBuilder.cs b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs similarity index 66% rename from src/Repl.Core/HelpTextBuilder.cs rename to src/Repl.Core/Help/HelpTextBuilder.Rendering.cs index 295e7a4..8a8af41 100644 --- a/src/Repl.Core/HelpTextBuilder.cs +++ b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs @@ -1,230 +1,12 @@ using System.ComponentModel; -using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Text; using Repl.Internal.Options; namespace Repl; -internal static class HelpTextBuilder +internal static partial class HelpTextBuilder { - private static readonly string[] HelpRow = ["help [path]", "Show help for current path or a specific path. Aliases: ?"]; - private static readonly string[] UpRow = ["..", "Go up one level in interactive mode."]; - 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, - IReadOnlyList contexts, - IReadOnlyList scopeTokens, - ParsingOptions parsingOptions) - { - ArgumentNullException.ThrowIfNull(routes); - ArgumentNullException.ThrowIfNull(contexts); - ArgumentNullException.ThrowIfNull(scopeTokens); - ArgumentNullException.ThrowIfNull(parsingOptions); - - var visibleRoutes = routes - .Where(route => !route.Command.IsHidden) - .ToArray(); - var scope = scopeTokens.Count == 0 ? "root" : string.Join(' ', scopeTokens); - if (TryGetCommandHelpRoutes(visibleRoutes, scopeTokens, parsingOptions, out var commandHelpRoutes)) - { - return new HelpDocumentModel( - scope, - commandHelpRoutes.Select(CreateCommandModel).ToArray(), - DateTimeOffset.UtcNow); - } - - var matchingRoutes = visibleRoutes - .Where(route => MatchesPrefix(route.Template, scopeTokens, parsingOptions)) - .ToArray(); - var commands = BuildGroupedCommandModels(matchingRoutes, contexts, scopeTokens, parsingOptions); - return new HelpDocumentModel(scope, commands, DateTimeOffset.UtcNow); - } - - private static HelpCommandModel[] BuildGroupedCommandModels( - RouteDefinition[] matchingRoutes, - IReadOnlyList contexts, - IReadOnlyList scopeTokens, - ParsingOptions parsingOptions) - { - var nextIndex = scopeTokens.Count; - return matchingRoutes - .Where(route => route.Template.Segments.Count > nextIndex) - .GroupBy(route => route.Template.Segments[nextIndex].RawText, StringComparer.OrdinalIgnoreCase) - .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) - .Select(group => - { - var hasChildren = group.Any(route => route.Template.Segments.Count > nextIndex + 1); - var name = hasChildren ? $"{group.Key} ..." : group.Key; - var description = group - .Select(route => route.Command.Description) - .FirstOrDefault(text => !string.IsNullOrWhiteSpace(text)) - ?? contexts - .Where(context => - MatchesPrefix(context.Template, scopeTokens, parsingOptions) - && context.Template.Segments.Count > nextIndex - && string.Equals( - context.Template.Segments[nextIndex].RawText, - group.Key, - StringComparison.OrdinalIgnoreCase)) - .Select(context => context.Description) - .FirstOrDefault(text => !string.IsNullOrWhiteSpace(text)) - ?? string.Empty; - var usage = string.Join(' ', scopeTokens.Append(group.Key)); - var aliases = group - .SelectMany(route => route.Command.Aliases) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - return new HelpCommandModel(name, description, usage, aliases); - }) - .ToArray(); - } - - public static string Build( - IReadOnlyList routes, - IReadOnlyList contexts, - IReadOnlyList scopeTokens, - ParsingOptions parsingOptions, - AmbientCommandOptions? ambientOptions = null, - int? renderWidth = null, - bool useAnsi = false, - AnsiPalette? palette = null) - { - ArgumentNullException.ThrowIfNull(routes); - ArgumentNullException.ThrowIfNull(contexts); - ArgumentNullException.ThrowIfNull(scopeTokens); - ArgumentNullException.ThrowIfNull(parsingOptions); - - var visibleRoutes = routes - .Where(route => !route.Command.IsHidden) - .ToArray(); - - var width = renderWidth ?? ResolveRenderWidth(); - var effectivePalette = palette ?? new DefaultAnsiPaletteProvider().Create(ThemeMode.Dark); - var effectiveAmbientOptions = ambientOptions ?? new AmbientCommandOptions(); - if (TryGetCommandHelpRoutes(visibleRoutes, scopeTokens, parsingOptions, out var commandHelpRoutes)) - { - return BuildCommandHelp(commandHelpRoutes, useAnsi, effectivePalette); - } - - var matchingRoutes = visibleRoutes - .Where(route => MatchesPrefix(route.Template, scopeTokens, parsingOptions)) - .ToArray(); - - return BuildScopeHelp( - scopeTokens, - matchingRoutes, - contexts, - parsingOptions, - effectiveAmbientOptions, - width, - useAnsi, - effectivePalette); - } - - private static bool TryGetCommandHelpRoutes( - RouteDefinition[] visibleRoutes, - IReadOnlyList scopeTokens, - ParsingOptions parsingOptions, - out RouteDefinition[] routes) - { - var exactMatches = visibleRoutes - .Where(route => IsExactMatch(route.Template, scopeTokens, parsingOptions)) - .ToArray(); - exactMatches = PreferMostSpecificLiteralMatches(exactMatches, scopeTokens); - if (exactMatches.Length > 0) - { - routes = OrderCommandHelpRoutes(exactMatches); - return true; - } - - var matchingRoutes = visibleRoutes - .Where(route => MatchesPrefix(route.Template, scopeTokens, parsingOptions)) - .ToArray(); - matchingRoutes = PreferMostSpecificLiteralMatches(matchingRoutes, scopeTokens); - if (matchingRoutes.Length == 0) - { - routes = []; - return false; - } - - var dynamicContinuations = matchingRoutes - .Where(route => - route.Template.Segments.Count > scopeTokens.Count - && route.Template.Segments[scopeTokens.Count] is DynamicRouteSegment) - .ToArray(); - if (dynamicContinuations.Length > 0 && dynamicContinuations.Length == matchingRoutes.Length) - { - routes = OrderCommandHelpRoutes(dynamicContinuations); - return true; - } - - routes = []; - return false; - } - - private static RouteDefinition[] PreferMostSpecificLiteralMatches( - RouteDefinition[] routes, - IReadOnlyList scopeTokens) - { - if (routes.Length <= 1 || scopeTokens.Count == 0) - { - return routes; - } - - var bestScore = routes - .Max(route => CountMatchedLiteralSegments(route.Template, scopeTokens)); - return routes - .Where(route => CountMatchedLiteralSegments(route.Template, scopeTokens) == bestScore) - .ToArray(); - } - - private static int CountMatchedLiteralSegments( - RouteTemplate template, - IReadOnlyList scopeTokens) - { - var score = 0; - var count = Math.Min(template.Segments.Count, scopeTokens.Count); - for (var i = 0; i < count; i++) - { - if (template.Segments[i] is LiteralRouteSegment literal - && string.Equals(literal.Value, scopeTokens[i], StringComparison.OrdinalIgnoreCase)) - { - score++; - } - } - - return score; - } - - private static HelpCommandModel CreateCommandModel(RouteDefinition route) - { - var displayTemplate = FormatRouteTemplate(route.Template); - return new( - Name: displayTemplate, - Description: route.Command.Description ?? "No description.", - Usage: displayTemplate, - Aliases: route.Command.Aliases.ToArray()); - } - - private static RouteDefinition[] OrderCommandHelpRoutes(RouteDefinition[] routes) => - routes - .OrderBy(route => route.Template.Template, StringComparer.OrdinalIgnoreCase) - .ThenBy(route => route.Template.Segments.Count) - .ToArray(); - private static string BuildCommandHelp(RouteDefinition[] routes, bool useAnsi, AnsiPalette palette) { if (routes.Length == 1) @@ -832,65 +614,4 @@ private static int ResolveRenderWidth() return 120; } - - private static bool IsExactMatch( - RouteTemplate template, - IReadOnlyList tokens, - ParsingOptions parsingOptions) - { - if (template.Segments.Count != tokens.Count) - { - return false; - } - - return MatchesPrefix(template, tokens, parsingOptions); - } - - [UnconditionalSuppressMessage( - "Trimming", - "IL2067", - Justification = "Options group types are user-defined and always preserved by the handler delegate reference.")] - private static object CreateOptionsGroupDefault(Type groupType) => - Activator.CreateInstance(groupType)!; - - [UnconditionalSuppressMessage( - "Trimming", - "IL2070", - Justification = "Options group types are user-defined and always preserved by the handler delegate reference.")] - private static PropertyInfo[] GetOptionsGroupProperties(Type groupType) => - groupType.GetProperties(BindingFlags.Public | BindingFlags.Instance); - - private static bool MatchesPrefix( - RouteTemplate template, - IReadOnlyList tokens, - ParsingOptions parsingOptions) - { - if (tokens.Count > template.Segments.Count) - { - return false; - } - - for (var i = 0; i < tokens.Count; i++) - { - var token = tokens[i]; - var segment = template.Segments[i]; - if (segment is LiteralRouteSegment literal) - { - if (!string.Equals(literal.Value, token, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - continue; - } - - var dynamic = (DynamicRouteSegment)segment; - if (!RouteConstraintEvaluator.IsMatch(dynamic, token, parsingOptions)) - { - return false; - } - } - - return true; - } } diff --git a/src/Repl.Core/Help/HelpTextBuilder.cs b/src/Repl.Core/Help/HelpTextBuilder.cs new file mode 100644 index 0000000..efc66f6 --- /dev/null +++ b/src/Repl.Core/Help/HelpTextBuilder.cs @@ -0,0 +1,286 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Repl.Internal.Options; + +namespace Repl; + +internal static partial class HelpTextBuilder +{ + private static readonly string[] HelpRow = ["help [path]", "Show help for current path or a specific path. Aliases: ?"]; + private static readonly string[] UpRow = ["..", "Go up one level in interactive mode."]; + 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, + IReadOnlyList contexts, + IReadOnlyList scopeTokens, + ParsingOptions parsingOptions) + { + ArgumentNullException.ThrowIfNull(routes); + ArgumentNullException.ThrowIfNull(contexts); + ArgumentNullException.ThrowIfNull(scopeTokens); + ArgumentNullException.ThrowIfNull(parsingOptions); + + var visibleRoutes = routes + .Where(route => !route.Command.IsHidden) + .ToArray(); + var scope = scopeTokens.Count == 0 ? "root" : string.Join(' ', scopeTokens); + if (TryGetCommandHelpRoutes(visibleRoutes, scopeTokens, parsingOptions, out var commandHelpRoutes)) + { + return new HelpDocumentModel( + scope, + commandHelpRoutes.Select(CreateCommandModel).ToArray(), + DateTimeOffset.UtcNow); + } + + var matchingRoutes = visibleRoutes + .Where(route => MatchesPrefix(route.Template, scopeTokens, parsingOptions)) + .ToArray(); + var commands = BuildGroupedCommandModels(matchingRoutes, contexts, scopeTokens, parsingOptions); + return new HelpDocumentModel(scope, commands, DateTimeOffset.UtcNow); + } + + private static HelpCommandModel[] BuildGroupedCommandModels( + RouteDefinition[] matchingRoutes, + IReadOnlyList contexts, + IReadOnlyList scopeTokens, + ParsingOptions parsingOptions) + { + var nextIndex = scopeTokens.Count; + return matchingRoutes + .Where(route => route.Template.Segments.Count > nextIndex) + .GroupBy(route => route.Template.Segments[nextIndex].RawText, StringComparer.OrdinalIgnoreCase) + .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) + .Select(group => + { + var hasChildren = group.Any(route => route.Template.Segments.Count > nextIndex + 1); + var name = hasChildren ? $"{group.Key} ..." : group.Key; + var description = group + .Select(route => route.Command.Description) + .FirstOrDefault(text => !string.IsNullOrWhiteSpace(text)) + ?? contexts + .Where(context => + MatchesPrefix(context.Template, scopeTokens, parsingOptions) + && context.Template.Segments.Count > nextIndex + && string.Equals( + context.Template.Segments[nextIndex].RawText, + group.Key, + StringComparison.OrdinalIgnoreCase)) + .Select(context => context.Description) + .FirstOrDefault(text => !string.IsNullOrWhiteSpace(text)) + ?? string.Empty; + var usage = string.Join(' ', scopeTokens.Append(group.Key)); + var aliases = group + .SelectMany(route => route.Command.Aliases) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + return new HelpCommandModel(name, description, usage, aliases); + }) + .ToArray(); + } + + public static string Build( + IReadOnlyList routes, + IReadOnlyList contexts, + IReadOnlyList scopeTokens, + ParsingOptions parsingOptions, + AmbientCommandOptions? ambientOptions = null, + int? renderWidth = null, + bool useAnsi = false, + AnsiPalette? palette = null) + { + ArgumentNullException.ThrowIfNull(routes); + ArgumentNullException.ThrowIfNull(contexts); + ArgumentNullException.ThrowIfNull(scopeTokens); + ArgumentNullException.ThrowIfNull(parsingOptions); + + var visibleRoutes = routes + .Where(route => !route.Command.IsHidden) + .ToArray(); + + var width = renderWidth ?? ResolveRenderWidth(); + var effectivePalette = palette ?? new DefaultAnsiPaletteProvider().Create(ThemeMode.Dark); + var effectiveAmbientOptions = ambientOptions ?? new AmbientCommandOptions(); + if (TryGetCommandHelpRoutes(visibleRoutes, scopeTokens, parsingOptions, out var commandHelpRoutes)) + { + return BuildCommandHelp(commandHelpRoutes, useAnsi, effectivePalette); + } + + var matchingRoutes = visibleRoutes + .Where(route => MatchesPrefix(route.Template, scopeTokens, parsingOptions)) + .ToArray(); + + return BuildScopeHelp( + scopeTokens, + matchingRoutes, + contexts, + parsingOptions, + effectiveAmbientOptions, + width, + useAnsi, + effectivePalette); + } + + private static bool TryGetCommandHelpRoutes( + RouteDefinition[] visibleRoutes, + IReadOnlyList scopeTokens, + ParsingOptions parsingOptions, + out RouteDefinition[] routes) + { + var exactMatches = visibleRoutes + .Where(route => IsExactMatch(route.Template, scopeTokens, parsingOptions)) + .ToArray(); + exactMatches = PreferMostSpecificLiteralMatches(exactMatches, scopeTokens); + if (exactMatches.Length > 0) + { + routes = OrderCommandHelpRoutes(exactMatches); + return true; + } + + var matchingRoutes = visibleRoutes + .Where(route => MatchesPrefix(route.Template, scopeTokens, parsingOptions)) + .ToArray(); + matchingRoutes = PreferMostSpecificLiteralMatches(matchingRoutes, scopeTokens); + if (matchingRoutes.Length == 0) + { + routes = []; + return false; + } + + var dynamicContinuations = matchingRoutes + .Where(route => + route.Template.Segments.Count > scopeTokens.Count + && route.Template.Segments[scopeTokens.Count] is DynamicRouteSegment) + .ToArray(); + if (dynamicContinuations.Length > 0 && dynamicContinuations.Length == matchingRoutes.Length) + { + routes = OrderCommandHelpRoutes(dynamicContinuations); + return true; + } + + routes = []; + return false; + } + + private static RouteDefinition[] PreferMostSpecificLiteralMatches( + RouteDefinition[] routes, + IReadOnlyList scopeTokens) + { + if (routes.Length <= 1 || scopeTokens.Count == 0) + { + return routes; + } + + var bestScore = routes + .Max(route => CountMatchedLiteralSegments(route.Template, scopeTokens)); + return routes + .Where(route => CountMatchedLiteralSegments(route.Template, scopeTokens) == bestScore) + .ToArray(); + } + + private static int CountMatchedLiteralSegments( + RouteTemplate template, + IReadOnlyList scopeTokens) + { + var score = 0; + var count = Math.Min(template.Segments.Count, scopeTokens.Count); + for (var i = 0; i < count; i++) + { + if (template.Segments[i] is LiteralRouteSegment literal + && string.Equals(literal.Value, scopeTokens[i], StringComparison.OrdinalIgnoreCase)) + { + score++; + } + } + + return score; + } + + private static HelpCommandModel CreateCommandModel(RouteDefinition route) + { + var displayTemplate = FormatRouteTemplate(route.Template); + return new( + Name: displayTemplate, + Description: route.Command.Description ?? "No description.", + Usage: displayTemplate, + Aliases: route.Command.Aliases.ToArray()); + } + + private static RouteDefinition[] OrderCommandHelpRoutes(RouteDefinition[] routes) => + routes + .OrderBy(route => route.Template.Template, StringComparer.OrdinalIgnoreCase) + .ThenBy(route => route.Template.Segments.Count) + .ToArray(); + + private static bool IsExactMatch( + RouteTemplate template, + IReadOnlyList tokens, + ParsingOptions parsingOptions) + { + if (template.Segments.Count != tokens.Count) + { + return false; + } + + return MatchesPrefix(template, tokens, parsingOptions); + } + + [UnconditionalSuppressMessage( + "Trimming", + "IL2067", + Justification = "Options group types are user-defined and always preserved by the handler delegate reference.")] + private static object CreateOptionsGroupDefault(Type groupType) => + Activator.CreateInstance(groupType)!; + + [UnconditionalSuppressMessage( + "Trimming", + "IL2070", + Justification = "Options group types are user-defined and always preserved by the handler delegate reference.")] + private static PropertyInfo[] GetOptionsGroupProperties(Type groupType) => + groupType.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + private static bool MatchesPrefix( + RouteTemplate template, + IReadOnlyList tokens, + ParsingOptions parsingOptions) + { + if (tokens.Count > template.Segments.Count) + { + return false; + } + + for (var i = 0; i < tokens.Count; i++) + { + var token = tokens[i]; + var segment = template.Segments[i]; + if (segment is LiteralRouteSegment literal) + { + if (!string.Equals(literal.Value, token, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + continue; + } + + var dynamic = (DynamicRouteSegment)segment; + if (!RouteConstraintEvaluator.IsMatch(dynamic, token, parsingOptions)) + { + return false; + } + } + + return true; + } +} diff --git a/src/Repl.Core/Interaction/Public/AskChoiceRequest.cs b/src/Repl.Core/Interaction/AskChoiceRequest.cs similarity index 100% rename from src/Repl.Core/Interaction/Public/AskChoiceRequest.cs rename to src/Repl.Core/Interaction/AskChoiceRequest.cs diff --git a/src/Repl.Core/Interaction/Public/AskConfirmationRequest.cs b/src/Repl.Core/Interaction/AskConfirmationRequest.cs similarity index 100% rename from src/Repl.Core/Interaction/Public/AskConfirmationRequest.cs rename to src/Repl.Core/Interaction/AskConfirmationRequest.cs diff --git a/src/Repl.Core/Interaction/Public/AskMultiChoiceOptions.cs b/src/Repl.Core/Interaction/AskMultiChoiceOptions.cs similarity index 91% rename from src/Repl.Core/Interaction/Public/AskMultiChoiceOptions.cs rename to src/Repl.Core/Interaction/AskMultiChoiceOptions.cs index 3778243..8b56af0 100644 --- a/src/Repl.Core/Interaction/Public/AskMultiChoiceOptions.cs +++ b/src/Repl.Core/Interaction/AskMultiChoiceOptions.cs @@ -3,10 +3,6 @@ namespace Repl.Interaction; /// /// Options for . /// -/// -/// Explicit cancellation token. When default, the channel uses the -/// ambient per-command token set by the framework before each command dispatch. -/// /// /// Optional timeout for the prompt. When the timeout elapses, the default /// selections are returned. @@ -17,8 +13,12 @@ namespace Repl.Interaction; /// /// Maximum number of selections allowed. null means no maximum. /// +/// +/// Explicit cancellation token. When default, the channel uses the +/// ambient per-command token set by the framework before each command dispatch. +/// public record AskMultiChoiceOptions( - CancellationToken CancellationToken = default, TimeSpan? Timeout = null, int? MinSelections = null, - int? MaxSelections = null); + int? MaxSelections = null, + CancellationToken CancellationToken = default); diff --git a/src/Repl.Core/Interaction/Public/AskMultiChoiceRequest.cs b/src/Repl.Core/Interaction/AskMultiChoiceRequest.cs similarity index 100% rename from src/Repl.Core/Interaction/Public/AskMultiChoiceRequest.cs rename to src/Repl.Core/Interaction/AskMultiChoiceRequest.cs diff --git a/src/Repl.Core/Interaction/Public/AskNumberOptions.cs b/src/Repl.Core/Interaction/AskNumberOptions.cs similarity index 89% rename from src/Repl.Core/Interaction/Public/AskNumberOptions.cs rename to src/Repl.Core/Interaction/AskNumberOptions.cs index c1c2c61..d1741c6 100644 --- a/src/Repl.Core/Interaction/Public/AskNumberOptions.cs +++ b/src/Repl.Core/Interaction/AskNumberOptions.cs @@ -4,17 +4,17 @@ namespace Repl.Interaction; /// Options for . /// /// The numeric type. -/// -/// Explicit cancellation token. When default, the channel uses the -/// ambient per-command token set by the framework before each command dispatch. -/// /// /// Optional timeout for the prompt. /// /// Optional minimum bound (inclusive). /// Optional maximum bound (inclusive). +/// +/// Explicit cancellation token. When default, the channel uses the +/// ambient per-command token set by the framework before each command dispatch. +/// public record AskNumberOptions( - CancellationToken CancellationToken = default, TimeSpan? Timeout = null, T? Min = null, - T? Max = null) where T : struct; + T? Max = null, + CancellationToken CancellationToken = default) where T : struct; diff --git a/src/Repl.Core/Interaction/Public/AskOptions.cs b/src/Repl.Core/Interaction/AskOptions.cs similarity index 90% rename from src/Repl.Core/Interaction/Public/AskOptions.cs rename to src/Repl.Core/Interaction/AskOptions.cs index 25a20dd..255268d 100644 --- a/src/Repl.Core/Interaction/Public/AskOptions.cs +++ b/src/Repl.Core/Interaction/AskOptions.cs @@ -5,14 +5,14 @@ namespace Repl.Interaction; /// and (and future features) in one place to avoid /// signature churn on methods. /// -/// -/// Explicit cancellation token. When default, the channel uses the -/// ambient per-command token set by the framework before each command dispatch. -/// /// /// Optional timeout for the prompt. When the timeout elapses, the default /// value is auto-selected and a countdown is displayed inline. /// +/// +/// Explicit cancellation token. When default, the channel uses the +/// ambient per-command token set by the framework before each command dispatch. +/// public record AskOptions( - CancellationToken CancellationToken = default, - TimeSpan? Timeout = null); + TimeSpan? Timeout = null, + CancellationToken CancellationToken = default); diff --git a/src/Repl.Core/Interaction/Public/AskSecretOptions.cs b/src/Repl.Core/Interaction/AskSecretOptions.cs similarity index 91% rename from src/Repl.Core/Interaction/Public/AskSecretOptions.cs rename to src/Repl.Core/Interaction/AskSecretOptions.cs index 87bc0c2..14609b0 100644 --- a/src/Repl.Core/Interaction/Public/AskSecretOptions.cs +++ b/src/Repl.Core/Interaction/AskSecretOptions.cs @@ -3,10 +3,6 @@ namespace Repl.Interaction; /// /// Options for . /// -/// -/// Explicit cancellation token. When default, the channel uses the -/// ambient per-command token set by the framework before each command dispatch. -/// /// /// Optional timeout for the prompt. When the timeout elapses, an empty string /// is returned and a countdown is displayed inline. @@ -18,8 +14,12 @@ namespace Repl.Interaction; /// /// When false, the prompt loops until a non-empty value is entered. /// +/// +/// Explicit cancellation token. When default, the channel uses the +/// ambient per-command token set by the framework before each command dispatch. +/// public record AskSecretOptions( - CancellationToken CancellationToken = default, TimeSpan? Timeout = null, char? Mask = '*', - bool AllowEmpty = false); + bool AllowEmpty = false, + CancellationToken CancellationToken = default); diff --git a/src/Repl.Core/Interaction/Public/AskSecretRequest.cs b/src/Repl.Core/Interaction/AskSecretRequest.cs similarity index 100% rename from src/Repl.Core/Interaction/Public/AskSecretRequest.cs rename to src/Repl.Core/Interaction/AskSecretRequest.cs diff --git a/src/Repl.Core/Interaction/Public/AskTextRequest.cs b/src/Repl.Core/Interaction/AskTextRequest.cs similarity index 100% rename from src/Repl.Core/Interaction/Public/AskTextRequest.cs rename to src/Repl.Core/Interaction/AskTextRequest.cs diff --git a/src/Repl.Core/Interaction/Public/ClearScreenRequest.cs b/src/Repl.Core/Interaction/ClearScreenRequest.cs similarity index 100% rename from src/Repl.Core/Interaction/Public/ClearScreenRequest.cs rename to src/Repl.Core/Interaction/ClearScreenRequest.cs diff --git a/src/Repl.Core/Interaction/Public/IReplInteractionChannel.cs b/src/Repl.Core/Interaction/IReplInteractionChannel.cs similarity index 100% rename from src/Repl.Core/Interaction/Public/IReplInteractionChannel.cs rename to src/Repl.Core/Interaction/IReplInteractionChannel.cs diff --git a/src/Repl.Core/Interaction/Public/IReplInteractionHandler.cs b/src/Repl.Core/Interaction/IReplInteractionHandler.cs similarity index 100% rename from src/Repl.Core/Interaction/Public/IReplInteractionHandler.cs rename to src/Repl.Core/Interaction/IReplInteractionHandler.cs diff --git a/src/Repl.Core/Interaction/Public/IReplInteractionPresenter.cs b/src/Repl.Core/Interaction/IReplInteractionPresenter.cs similarity index 100% rename from src/Repl.Core/Interaction/Public/IReplInteractionPresenter.cs rename to src/Repl.Core/Interaction/IReplInteractionPresenter.cs diff --git a/src/Repl.Core/Interaction/Public/ITerminalInfo.cs b/src/Repl.Core/Interaction/ITerminalInfo.cs similarity index 100% rename from src/Repl.Core/Interaction/Public/ITerminalInfo.cs rename to src/Repl.Core/Interaction/ITerminalInfo.cs diff --git a/src/Repl.Core/InteractionProgressFactory.cs b/src/Repl.Core/Interaction/InteractionProgressFactory.cs similarity index 100% rename from src/Repl.Core/InteractionProgressFactory.cs rename to src/Repl.Core/Interaction/InteractionProgressFactory.cs diff --git a/src/Repl.Core/Interaction/Public/InteractionRequest.cs b/src/Repl.Core/Interaction/InteractionRequest.cs similarity index 100% rename from src/Repl.Core/Interaction/Public/InteractionRequest.cs rename to src/Repl.Core/Interaction/InteractionRequest.cs diff --git a/src/Repl.Core/Interaction/Public/InteractionResult.cs b/src/Repl.Core/Interaction/InteractionResult.cs similarity index 100% rename from src/Repl.Core/Interaction/Public/InteractionResult.cs rename to src/Repl.Core/Interaction/InteractionResult.cs diff --git a/src/Repl.Core/Interaction/Public/PromptFallback.cs b/src/Repl.Core/Interaction/PromptFallback.cs similarity index 100% rename from src/Repl.Core/Interaction/Public/PromptFallback.cs rename to src/Repl.Core/Interaction/PromptFallback.cs diff --git a/src/Repl.Core/Interaction/Public/ReplClearScreenEvent.cs b/src/Repl.Core/Interaction/ReplClearScreenEvent.cs similarity index 100% rename from src/Repl.Core/Interaction/Public/ReplClearScreenEvent.cs rename to src/Repl.Core/Interaction/ReplClearScreenEvent.cs diff --git a/src/Repl.Core/Interaction/Public/ReplInteractionChannelExtensions.cs b/src/Repl.Core/Interaction/ReplInteractionChannelExtensions.cs similarity index 98% rename from src/Repl.Core/Interaction/Public/ReplInteractionChannelExtensions.cs rename to src/Repl.Core/Interaction/ReplInteractionChannelExtensions.cs index bfd7c9f..81e13ba 100644 --- a/src/Repl.Core/Interaction/Public/ReplInteractionChannelExtensions.cs +++ b/src/Repl.Core/Interaction/ReplInteractionChannelExtensions.cs @@ -81,7 +81,7 @@ public static async ValueTask AskNumberAsync( { var decoratedPrompt = BuildNumberPrompt(prompt, defaultValue, options); var askOptions = options is not null - ? new AskOptions(options.CancellationToken, options.Timeout) + ? new AskOptions(options.Timeout, options.CancellationToken) : null; var defaultText = defaultValue?.ToString(); string? previousLine = null; @@ -177,7 +177,7 @@ public static async ValueTask PressAnyKeyAsync( CancellationToken cancellationToken = default) { await channel.AskTextAsync("__press_any_key__", prompt, string.Empty, - new AskOptions(cancellationToken)) + new AskOptions(CancellationToken: cancellationToken)) .ConfigureAwait(false); } diff --git a/src/Repl.Core/Interaction/Public/ReplInteractionEvent.cs b/src/Repl.Core/Interaction/ReplInteractionEvent.cs similarity index 100% rename from src/Repl.Core/Interaction/Public/ReplInteractionEvent.cs rename to src/Repl.Core/Interaction/ReplInteractionEvent.cs diff --git a/src/Repl.Core/Interaction/Public/ReplProgressEvent.cs b/src/Repl.Core/Interaction/ReplProgressEvent.cs similarity index 100% rename from src/Repl.Core/Interaction/Public/ReplProgressEvent.cs rename to src/Repl.Core/Interaction/ReplProgressEvent.cs diff --git a/src/Repl.Core/Interaction/Public/ReplPromptEvent.cs b/src/Repl.Core/Interaction/ReplPromptEvent.cs similarity index 100% rename from src/Repl.Core/Interaction/Public/ReplPromptEvent.cs rename to src/Repl.Core/Interaction/ReplPromptEvent.cs diff --git a/src/Repl.Core/Interaction/Public/ReplStatusEvent.cs b/src/Repl.Core/Interaction/ReplStatusEvent.cs similarity index 100% rename from src/Repl.Core/Interaction/Public/ReplStatusEvent.cs rename to src/Repl.Core/Interaction/ReplStatusEvent.cs diff --git a/src/Repl.Core/Interaction/RichPromptInteractionHandler.Rendering.cs b/src/Repl.Core/Interaction/RichPromptInteractionHandler.Rendering.cs index e69e299..a1b275f 100644 --- a/src/Repl.Core/Interaction/RichPromptInteractionHandler.Rendering.cs +++ b/src/Repl.Core/Interaction/RichPromptInteractionHandler.Rendering.cs @@ -22,7 +22,9 @@ internal int ReadChoiceInteractiveSync( return ReadChoiceInteractiveRemote(choices, defaultIndex, keyReader, ct); } +#pragma warning disable MA0045 // Intentionally synchronous — interactive menu rendering runs on calling thread ConsoleInputGate.Gate.Wait(ct); +#pragma warning restore MA0045 try { return ReadChoiceInteractiveCore(choices, defaultIndex, ct); @@ -64,7 +66,9 @@ private int RunChoiceKeyLoopSync( { if (!Console.KeyAvailable) { +#pragma warning disable MA0045 // Intentionally synchronous — interactive menu rendering runs on calling thread Thread.Sleep(15); +#pragma warning restore MA0045 continue; } @@ -113,8 +117,10 @@ private int RunChoiceKeyLoopRemote( while (!ct.IsCancellationRequested) { #pragma warning disable VSTHRD002 +#pragma warning disable MA0045 // Intentionally synchronous — sync menu loop for remote key reader var key = keyReader.ReadKeyAsync(ct).AsTask().GetAwaiter().GetResult(); #pragma warning restore VSTHRD002 +#pragma warning restore MA0045 var result = HandleChoiceKey(key, ctx, ref cursor, menuLines); Flush(ct); if (result is not null) @@ -179,8 +185,10 @@ private int RunChoiceKeyLoopRemote( choices, defaults, minSelections, maxSelections, keyReader, ct); } +#pragma warning disable MA0045 // Intentionally synchronous — interactive menu rendering runs on calling thread ConsoleInputGate.Gate.Wait(ct); try +#pragma warning restore MA0045 { return ReadMultiChoiceInteractiveCore(choices, defaults, minSelections, maxSelections, ct); } @@ -228,8 +236,10 @@ private int RunChoiceKeyLoopRemote( { if (!Console.KeyAvailable) { +#pragma warning disable MA0045 // Intentionally synchronous — interactive menu rendering runs on calling thread Thread.Sleep(15); continue; +#pragma warning restore MA0045 } var key = Console.ReadKey(intercept: true); @@ -290,8 +300,10 @@ private int RunChoiceKeyLoopRemote( while (!ct.IsCancellationRequested) { #pragma warning disable VSTHRD002 +#pragma warning disable MA0045 // Intentionally synchronous — sync menu loop for remote key reader var key = keyReader.ReadKeyAsync(ct).AsTask().GetAwaiter().GetResult(); #pragma warning restore VSTHRD002 +#pragma warning restore MA0045 var result = HandleMultiChoiceKey( key, ctx, selected, ref cursor, ref hasError, ref escaped, menuLines, minSelections, maxSelections); @@ -592,14 +604,18 @@ private static bool IsValidSelection(int[] selected, int min, int? max) => // ---------- I/O routing ---------- +#pragma warning disable MA0045 // Intentionally synchronous wrappers — used by sync menu rendering loops private static void Out(string text) => ReplSessionIO.Output.Write(text); private static void OutLine(string text) => ReplSessionIO.Output.WriteLine(text); +#pragma warning restore MA0045 private static void Flush(CancellationToken ct) { #pragma warning disable VSTHRD002 +#pragma warning disable MA0045 // Intentionally synchronous — sync menu rendering cannot use async flush ReplSessionIO.Output.FlushAsync(ct).GetAwaiter().GetResult(); +#pragma warning restore MA0045 #pragma warning restore VSTHRD002 } diff --git a/src/Repl.Core/Interaction/Public/WriteProgressRequest.cs b/src/Repl.Core/Interaction/WriteProgressRequest.cs similarity index 100% rename from src/Repl.Core/Interaction/Public/WriteProgressRequest.cs rename to src/Repl.Core/Interaction/WriteProgressRequest.cs diff --git a/src/Repl.Core/Interaction/Public/WriteStatusRequest.cs b/src/Repl.Core/Interaction/WriteStatusRequest.cs similarity index 100% rename from src/Repl.Core/Interaction/Public/WriteStatusRequest.cs rename to src/Repl.Core/Interaction/WriteStatusRequest.cs diff --git a/src/Repl.Core/HumanOutputTransformer.cs b/src/Repl.Core/Output/HumanOutputTransformer.cs similarity index 100% rename from src/Repl.Core/HumanOutputTransformer.cs rename to src/Repl.Core/Output/HumanOutputTransformer.cs diff --git a/src/Repl.Core/JsonAnsiColorizer.cs b/src/Repl.Core/Output/JsonAnsiColorizer.cs similarity index 100% rename from src/Repl.Core/JsonAnsiColorizer.cs rename to src/Repl.Core/Output/JsonAnsiColorizer.cs diff --git a/src/Repl.Core/JsonOutputTransformer.cs b/src/Repl.Core/Output/JsonOutputTransformer.cs similarity index 100% rename from src/Repl.Core/JsonOutputTransformer.cs rename to src/Repl.Core/Output/JsonOutputTransformer.cs diff --git a/src/Repl.Core/MarkdownOutputTransformer.cs b/src/Repl.Core/Output/MarkdownOutputTransformer.cs similarity index 100% rename from src/Repl.Core/MarkdownOutputTransformer.cs rename to src/Repl.Core/Output/MarkdownOutputTransformer.cs diff --git a/src/Repl.Core/TextTableFormatter.cs b/src/Repl.Core/Output/TextTableFormatter.cs similarity index 100% rename from src/Repl.Core/TextTableFormatter.cs rename to src/Repl.Core/Output/TextTableFormatter.cs diff --git a/src/Repl.Core/TextTableStyle.cs b/src/Repl.Core/Output/TextTableStyle.cs similarity index 100% rename from src/Repl.Core/TextTableStyle.cs rename to src/Repl.Core/Output/TextTableStyle.cs diff --git a/src/Repl.Core/XmlOutputTransformer.cs b/src/Repl.Core/Output/XmlOutputTransformer.cs similarity index 100% rename from src/Repl.Core/XmlOutputTransformer.cs rename to src/Repl.Core/Output/XmlOutputTransformer.cs diff --git a/src/Repl.Core/YamlOutputTransformer.cs b/src/Repl.Core/Output/YamlOutputTransformer.cs similarity index 100% rename from src/Repl.Core/YamlOutputTransformer.cs rename to src/Repl.Core/Output/YamlOutputTransformer.cs diff --git a/src/Repl.Core/ReplDateRange.cs b/src/Repl.Core/Parameters/ReplDateRange.cs similarity index 100% rename from src/Repl.Core/ReplDateRange.cs rename to src/Repl.Core/Parameters/ReplDateRange.cs diff --git a/src/Repl.Core/ReplDateTimeOffsetRange.cs b/src/Repl.Core/Parameters/ReplDateTimeOffsetRange.cs similarity index 100% rename from src/Repl.Core/ReplDateTimeOffsetRange.cs rename to src/Repl.Core/Parameters/ReplDateTimeOffsetRange.cs diff --git a/src/Repl.Core/ReplDateTimeRange.cs b/src/Repl.Core/Parameters/ReplDateTimeRange.cs similarity index 100% rename from src/Repl.Core/ReplDateTimeRange.cs rename to src/Repl.Core/Parameters/ReplDateTimeRange.cs diff --git a/src/Repl.Core/CommandInvoker.cs b/src/Repl.Core/Parsing/CommandInvoker.cs similarity index 100% rename from src/Repl.Core/CommandInvoker.cs rename to src/Repl.Core/Parsing/CommandInvoker.cs diff --git a/src/Repl.Core/GlobalInvocationOptions.cs b/src/Repl.Core/Parsing/GlobalInvocationOptions.cs similarity index 100% rename from src/Repl.Core/GlobalInvocationOptions.cs rename to src/Repl.Core/Parsing/GlobalInvocationOptions.cs diff --git a/src/Repl.Core/GlobalOptionDefinition.cs b/src/Repl.Core/Parsing/GlobalOptionDefinition.cs similarity index 100% rename from src/Repl.Core/GlobalOptionDefinition.cs rename to src/Repl.Core/Parsing/GlobalOptionDefinition.cs diff --git a/src/Repl.Core/GlobalOptionParser.cs b/src/Repl.Core/Parsing/GlobalOptionParser.cs similarity index 100% rename from src/Repl.Core/GlobalOptionParser.cs rename to src/Repl.Core/Parsing/GlobalOptionParser.cs diff --git a/src/Repl.Core/GlobalOptionsSnapshot.cs b/src/Repl.Core/Parsing/GlobalOptionsSnapshot.cs similarity index 100% rename from src/Repl.Core/GlobalOptionsSnapshot.cs rename to src/Repl.Core/Parsing/GlobalOptionsSnapshot.cs diff --git a/src/Repl.Core/HandlerArgumentBinder.cs b/src/Repl.Core/Parsing/HandlerArgumentBinder.cs similarity index 100% rename from src/Repl.Core/HandlerArgumentBinder.cs rename to src/Repl.Core/Parsing/HandlerArgumentBinder.cs diff --git a/src/Repl.Core/IntegerLiteralParser.cs b/src/Repl.Core/Parsing/IntegerLiteralParser.cs similarity index 100% rename from src/Repl.Core/IntegerLiteralParser.cs rename to src/Repl.Core/Parsing/IntegerLiteralParser.cs diff --git a/src/Repl.Core/InvocationBindingContext.cs b/src/Repl.Core/Parsing/InvocationBindingContext.cs similarity index 100% rename from src/Repl.Core/InvocationBindingContext.cs rename to src/Repl.Core/Parsing/InvocationBindingContext.cs diff --git a/src/Repl.Core/InvocationOptionParser.cs b/src/Repl.Core/Parsing/InvocationOptionParser.cs similarity index 99% rename from src/Repl.Core/InvocationOptionParser.cs rename to src/Repl.Core/Parsing/InvocationOptionParser.cs index c91eb8d..c52b27e 100644 --- a/src/Repl.Core/InvocationOptionParser.cs +++ b/src/Repl.Core/Parsing/InvocationOptionParser.cs @@ -553,7 +553,9 @@ private static List ExpandResponseFiles( continue; } + #pragma warning disable MA0045 // Synchronous by design — Parse is a synchronous API; response file expansion cannot be async var content = File.ReadAllText(path); + #pragma warning restore MA0045 var tokenization = ResponseFileTokenizer.Tokenize(content); expanded.AddRange(tokenization.Tokens); if (tokenization.HasTrailingEscape) diff --git a/src/Repl.Core/OptionParsingResult.cs b/src/Repl.Core/Parsing/OptionParsingResult.cs similarity index 100% rename from src/Repl.Core/OptionParsingResult.cs rename to src/Repl.Core/Parsing/OptionParsingResult.cs diff --git a/src/Repl.Core/ParameterValueConverter.cs b/src/Repl.Core/Parsing/ParameterValueConverter.cs similarity index 100% rename from src/Repl.Core/ParameterValueConverter.cs rename to src/Repl.Core/Parsing/ParameterValueConverter.cs diff --git a/src/Repl.Core/ParseDiagnostic.cs b/src/Repl.Core/Parsing/ParseDiagnostic.cs similarity index 100% rename from src/Repl.Core/ParseDiagnostic.cs rename to src/Repl.Core/Parsing/ParseDiagnostic.cs diff --git a/src/Repl.Core/ParseDiagnosticSeverity.cs b/src/Repl.Core/Parsing/ParseDiagnosticSeverity.cs similarity index 100% rename from src/Repl.Core/ParseDiagnosticSeverity.cs rename to src/Repl.Core/Parsing/ParseDiagnosticSeverity.cs diff --git a/src/Repl.Core/ResponseFileTokenizationResult.cs b/src/Repl.Core/Parsing/ResponseFileTokenizationResult.cs similarity index 100% rename from src/Repl.Core/ResponseFileTokenizationResult.cs rename to src/Repl.Core/Parsing/ResponseFileTokenizationResult.cs diff --git a/src/Repl.Core/ResponseFileTokenizer.cs b/src/Repl.Core/Parsing/ResponseFileTokenizer.cs similarity index 100% rename from src/Repl.Core/ResponseFileTokenizer.cs rename to src/Repl.Core/Parsing/ResponseFileTokenizer.cs diff --git a/src/Repl.Core/TemporalLiteralParser.cs b/src/Repl.Core/Parsing/TemporalLiteralParser.cs similarity index 100% rename from src/Repl.Core/TemporalLiteralParser.cs rename to src/Repl.Core/Parsing/TemporalLiteralParser.cs diff --git a/src/Repl.Core/TemporalRangeLiteralParser.cs b/src/Repl.Core/Parsing/TemporalRangeLiteralParser.cs similarity index 100% rename from src/Repl.Core/TemporalRangeLiteralParser.cs rename to src/Repl.Core/Parsing/TemporalRangeLiteralParser.cs diff --git a/src/Repl.Core/TimeSpanLiteralParser.cs b/src/Repl.Core/Parsing/TimeSpanLiteralParser.cs similarity index 100% rename from src/Repl.Core/TimeSpanLiteralParser.cs rename to src/Repl.Core/Parsing/TimeSpanLiteralParser.cs diff --git a/src/Repl.Core/Rendering/Public/AnsiMode.cs b/src/Repl.Core/Rendering/AnsiMode.cs similarity index 100% rename from src/Repl.Core/Rendering/Public/AnsiMode.cs rename to src/Repl.Core/Rendering/AnsiMode.cs diff --git a/src/Repl.Core/Rendering/Public/AnsiPalette.cs b/src/Repl.Core/Rendering/AnsiPalette.cs similarity index 100% rename from src/Repl.Core/Rendering/Public/AnsiPalette.cs rename to src/Repl.Core/Rendering/AnsiPalette.cs diff --git a/src/Repl.Core/AnsiText.cs b/src/Repl.Core/Rendering/AnsiText.cs similarity index 100% rename from src/Repl.Core/AnsiText.cs rename to src/Repl.Core/Rendering/AnsiText.cs diff --git a/src/Repl.Core/DefaultAnsiPaletteProvider.cs b/src/Repl.Core/Rendering/DefaultAnsiPaletteProvider.cs similarity index 100% rename from src/Repl.Core/DefaultAnsiPaletteProvider.cs rename to src/Repl.Core/Rendering/DefaultAnsiPaletteProvider.cs diff --git a/src/Repl.Core/HumanRenderSettings.cs b/src/Repl.Core/Rendering/HumanRenderSettings.cs similarity index 100% rename from src/Repl.Core/HumanRenderSettings.cs rename to src/Repl.Core/Rendering/HumanRenderSettings.cs diff --git a/src/Repl.Core/Rendering/Public/IAnsiPaletteProvider.cs b/src/Repl.Core/Rendering/IAnsiPaletteProvider.cs similarity index 100% rename from src/Repl.Core/Rendering/Public/IAnsiPaletteProvider.cs rename to src/Repl.Core/Rendering/IAnsiPaletteProvider.cs diff --git a/src/Repl.Core/ThemeMode.cs b/src/Repl.Core/Rendering/ThemeMode.cs similarity index 100% rename from src/Repl.Core/ThemeMode.cs rename to src/Repl.Core/Rendering/ThemeMode.cs diff --git a/src/Repl.Core/Repl.Core.csproj b/src/Repl.Core/Repl.Core.csproj index b0c0114..f12e284 100644 --- a/src/Repl.Core/Repl.Core.csproj +++ b/src/Repl.Core/Repl.Core.csproj @@ -4,6 +4,8 @@ net10.0 Dependency-free runtime core for routing, parsing/binding, results, help, and middleware. README.md + + Repl diff --git a/src/Repl.Core/Routing/ActiveRoutingGraph.cs b/src/Repl.Core/Routing/ActiveRoutingGraph.cs new file mode 100644 index 0000000..6e4e098 --- /dev/null +++ b/src/Repl.Core/Routing/ActiveRoutingGraph.cs @@ -0,0 +1,6 @@ +namespace Repl; + +internal readonly record struct ActiveRoutingGraph( + RouteDefinition[] Routes, + ContextDefinition[] Contexts, + ReplRuntimeChannel Channel); diff --git a/src/Repl.Core/ContextDefinition.cs b/src/Repl.Core/Routing/ContextDefinition.cs similarity index 100% rename from src/Repl.Core/ContextDefinition.cs rename to src/Repl.Core/Routing/ContextDefinition.cs diff --git a/src/Repl.Core/ContextMatch.cs b/src/Repl.Core/Routing/ContextMatch.cs similarity index 100% rename from src/Repl.Core/ContextMatch.cs rename to src/Repl.Core/Routing/ContextMatch.cs diff --git a/src/Repl.Core/ContextResolver.cs b/src/Repl.Core/Routing/ContextResolver.cs similarity index 100% rename from src/Repl.Core/ContextResolver.cs rename to src/Repl.Core/Routing/ContextResolver.cs diff --git a/src/Repl.Core/Routing/ContextValidationOutcome.cs b/src/Repl.Core/Routing/ContextValidationOutcome.cs new file mode 100644 index 0000000..8fde7b4 --- /dev/null +++ b/src/Repl.Core/Routing/ContextValidationOutcome.cs @@ -0,0 +1,10 @@ +namespace Repl; + +internal readonly record struct ContextValidationOutcome(bool IsValid, IReplResult? Failure) +{ + public static ContextValidationOutcome Success { get; } = + new(IsValid: true, Failure: null); + + public static ContextValidationOutcome FromFailure(IReplResult failure) => + new(IsValid: false, Failure: failure); +} diff --git a/src/Repl.Core/DynamicRouteSegment.cs b/src/Repl.Core/Routing/DynamicRouteSegment.cs similarity index 100% rename from src/Repl.Core/DynamicRouteSegment.cs rename to src/Repl.Core/Routing/DynamicRouteSegment.cs diff --git a/src/Repl.Core/LiteralRouteSegment.cs b/src/Repl.Core/Routing/LiteralRouteSegment.cs similarity index 100% rename from src/Repl.Core/LiteralRouteSegment.cs rename to src/Repl.Core/Routing/LiteralRouteSegment.cs diff --git a/src/Repl.Core/PrefixResolutionResult.cs b/src/Repl.Core/Routing/PrefixResolutionResult.cs similarity index 100% rename from src/Repl.Core/PrefixResolutionResult.cs rename to src/Repl.Core/Routing/PrefixResolutionResult.cs diff --git a/src/Repl.Core/Routing/PrefixTemplate.cs b/src/Repl.Core/Routing/PrefixTemplate.cs new file mode 100644 index 0000000..ef85863 --- /dev/null +++ b/src/Repl.Core/Routing/PrefixTemplate.cs @@ -0,0 +1,6 @@ +namespace Repl; + +internal readonly record struct PrefixTemplate( + RouteTemplate Template, + bool IsHidden, + IReadOnlyList Aliases); diff --git a/src/Repl.Core/RouteConfigurationValidator.cs b/src/Repl.Core/Routing/RouteConfigurationValidator.cs similarity index 100% rename from src/Repl.Core/RouteConfigurationValidator.cs rename to src/Repl.Core/Routing/RouteConfigurationValidator.cs diff --git a/src/Repl.Core/RouteConstraintEvaluator.cs b/src/Repl.Core/Routing/RouteConstraintEvaluator.cs similarity index 100% rename from src/Repl.Core/RouteConstraintEvaluator.cs rename to src/Repl.Core/Routing/RouteConstraintEvaluator.cs diff --git a/src/Repl.Core/RouteConstraintKind.cs b/src/Repl.Core/Routing/RouteConstraintKind.cs similarity index 100% rename from src/Repl.Core/RouteConstraintKind.cs rename to src/Repl.Core/Routing/RouteConstraintKind.cs diff --git a/src/Repl.Core/RouteDefinition.cs b/src/Repl.Core/Routing/RouteDefinition.cs similarity index 100% rename from src/Repl.Core/RouteDefinition.cs rename to src/Repl.Core/Routing/RouteDefinition.cs diff --git a/src/Repl.Core/RouteMatch.cs b/src/Repl.Core/Routing/RouteMatch.cs similarity index 100% rename from src/Repl.Core/RouteMatch.cs rename to src/Repl.Core/Routing/RouteMatch.cs diff --git a/src/Repl.Core/RouteResolver.cs b/src/Repl.Core/Routing/RouteResolver.cs similarity index 100% rename from src/Repl.Core/RouteResolver.cs rename to src/Repl.Core/Routing/RouteResolver.cs diff --git a/src/Repl.Core/RouteSegment.cs b/src/Repl.Core/Routing/RouteSegment.cs similarity index 100% rename from src/Repl.Core/RouteSegment.cs rename to src/Repl.Core/Routing/RouteSegment.cs diff --git a/src/Repl.Core/RouteTemplate.cs b/src/Repl.Core/Routing/RouteTemplate.cs similarity index 100% rename from src/Repl.Core/RouteTemplate.cs rename to src/Repl.Core/Routing/RouteTemplate.cs diff --git a/src/Repl.Core/RouteTemplateParser.cs b/src/Repl.Core/Routing/RouteTemplateParser.cs similarity index 100% rename from src/Repl.Core/RouteTemplateParser.cs rename to src/Repl.Core/Routing/RouteTemplateParser.cs diff --git a/src/Repl.Core/Routing/RoutingEngine.cs b/src/Repl.Core/Routing/RoutingEngine.cs new file mode 100644 index 0000000..f5c830f --- /dev/null +++ b/src/Repl.Core/Routing/RoutingEngine.cs @@ -0,0 +1,621 @@ +using System.Globalization; +using System.Reflection; +using Repl.Internal.Options; + +namespace Repl; + +/// +/// Encapsulates route registration, prefix resolution, context validation, and banner rendering +/// previously hosted inside CoreReplApp.Routing.cs. +/// +internal sealed class RoutingEngine(CoreReplApp app) +{ + internal ContextDefinition RegisterContext(string template, Delegate? validation, string? description) + { + var parsedTemplate = RouteTemplateParser.Parse(template, app.OptionsSnapshot.Parsing); + var moduleId = app.ResolveCurrentMappingModuleId(); + RouteConfigurationValidator.ValidateUnique( + parsedTemplate, + app.Contexts + .Where(context => context.ModuleId == moduleId) + .Select(context => context.Template) + ); + + var context = new ContextDefinition(parsedTemplate, validation, description, moduleId); + app.Contexts.Add(context); + app.InvalidateRouting(); + return context; + } + + internal async ValueTask ValidateContextsForPathAsync( + IReadOnlyList matchedPathTokens, + IReadOnlyList contexts, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + var contextMatches = ContextResolver.ResolvePrefixes(contexts, matchedPathTokens, app.OptionsSnapshot.Parsing); + foreach (var contextMatch in contextMatches) + { + var validation = await ValidateContextAsync(contextMatch, serviceProvider, cancellationToken).ConfigureAwait(false); + if (!validation.IsValid) + { + return validation.Failure; + } + } + + return null; + } + + internal async ValueTask ValidateContextsForMatchAsync( + RouteMatch match, + IReadOnlyList matchedPathTokens, + IReadOnlyList contexts, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + var contextMatches = ResolveRouteContextPrefixes(match.Route.Template, matchedPathTokens, contexts); + foreach (var contextMatch in contextMatches) + { + var validation = await ValidateContextAsync(contextMatch, serviceProvider, cancellationToken).ConfigureAwait(false); + if (!validation.IsValid) + { + return validation.Failure; + } + } + + return null; + } + + internal List BuildContextHierarchyValues( + RouteTemplate matchedRouteTemplate, + IReadOnlyList matchedPathTokens, + IReadOnlyList contexts) + { + var matches = ResolveRouteContextPrefixes(matchedRouteTemplate, matchedPathTokens, contexts); + var values = new List(); + foreach (var contextMatch in matches) + { + foreach (var dynamicSegment in contextMatch.Context.Template.Segments.OfType()) + { + if (!contextMatch.RouteValues.TryGetValue(dynamicSegment.Name, out var routeValue)) + { + continue; + } + + values.Add(ConvertContextValue(routeValue, dynamicSegment.ConstraintKind)); + } + } + + return values; + } + + private IReadOnlyList ResolveRouteContextPrefixes( + RouteTemplate matchedRouteTemplate, + IReadOnlyList matchedPathTokens, + IReadOnlyList contexts) + { + var matches = ContextResolver.ResolvePrefixes(contexts, matchedPathTokens, app.OptionsSnapshot.Parsing); + return [.. + matches.Where(contextMatch => + IsTemplatePrefix( + contextMatch.Context.Template, + matchedRouteTemplate)), + ]; + } + + private static bool IsTemplatePrefix(RouteTemplate contextTemplate, RouteTemplate routeTemplate) + { + if (contextTemplate.Segments.Count > routeTemplate.Segments.Count) + { + return false; + } + + for (var i = 0; i < contextTemplate.Segments.Count; i++) + { + var contextSegment = contextTemplate.Segments[i]; + var routeSegment = routeTemplate.Segments[i]; + if (!AreSegmentsEquivalent(contextSegment, routeSegment)) + { + return false; + } + } + + return true; + } + + private static bool AreSegmentsEquivalent(RouteSegment left, RouteSegment right) + { + if (left is LiteralRouteSegment leftLiteral && right is LiteralRouteSegment rightLiteral) + { + return string.Equals(leftLiteral.Value, rightLiteral.Value, StringComparison.OrdinalIgnoreCase); + } + + if (left is DynamicRouteSegment leftDynamic && right is DynamicRouteSegment rightDynamic) + { + if (leftDynamic.ConstraintKind != rightDynamic.ConstraintKind) + { + return false; + } + + if (leftDynamic.ConstraintKind != RouteConstraintKind.Custom) + { + return true; + } + + return string.Equals( + leftDynamic.CustomConstraintName, + rightDynamic.CustomConstraintName, + StringComparison.OrdinalIgnoreCase); + } + + return false; + } + + private static object? ConvertContextValue(string routeValue, RouteConstraintKind kind) => + kind switch + { + RouteConstraintKind.Int => ParameterValueConverter.ConvertSingle(routeValue, typeof(int), CultureInfo.InvariantCulture), + RouteConstraintKind.Long => ParameterValueConverter.ConvertSingle(routeValue, typeof(long), CultureInfo.InvariantCulture), + RouteConstraintKind.Bool => ParameterValueConverter.ConvertSingle(routeValue, typeof(bool), CultureInfo.InvariantCulture), + RouteConstraintKind.Guid => ParameterValueConverter.ConvertSingle(routeValue, typeof(Guid), CultureInfo.InvariantCulture), + RouteConstraintKind.Uri => ParameterValueConverter.ConvertSingle(routeValue, typeof(Uri), CultureInfo.InvariantCulture), + RouteConstraintKind.Url => ParameterValueConverter.ConvertSingle(routeValue, typeof(Uri), CultureInfo.InvariantCulture), + RouteConstraintKind.Urn => ParameterValueConverter.ConvertSingle(routeValue, typeof(Uri), CultureInfo.InvariantCulture), + RouteConstraintKind.Time => ParameterValueConverter.ConvertSingle(routeValue, typeof(TimeOnly), CultureInfo.InvariantCulture), + RouteConstraintKind.Date => ParameterValueConverter.ConvertSingle(routeValue, typeof(DateOnly), CultureInfo.InvariantCulture), + RouteConstraintKind.DateTime => ParameterValueConverter.ConvertSingle(routeValue, typeof(DateTime), CultureInfo.InvariantCulture), + RouteConstraintKind.DateTimeOffset => ParameterValueConverter.ConvertSingle(routeValue, typeof(DateTimeOffset), CultureInfo.InvariantCulture), + RouteConstraintKind.TimeSpan => ParameterValueConverter.ConvertSingle(routeValue, typeof(TimeSpan), CultureInfo.InvariantCulture), + _ => routeValue, + }; + + internal async ValueTask ValidateContextAsync( + ContextMatch contextMatch, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + if (contextMatch.Context.Validation is null) + { + return ContextValidationOutcome.Success; + } + + var bindingContext = new InvocationBindingContext( + contextMatch.RouteValues, + new Dictionary>(StringComparer.OrdinalIgnoreCase), + [], + OptionSchema.Empty, + app.OptionsSnapshot.Parsing.OptionCaseSensitivity, + [], + app.OptionsSnapshot.Parsing.NumericFormatProvider, + serviceProvider, + app.OptionsSnapshot.Interaction, + cancellationToken); + var arguments = HandlerArgumentBinder.Bind(contextMatch.Context.Validation, bindingContext); + var validationResult = await CommandInvoker + .InvokeAsync(contextMatch.Context.Validation, arguments) + .ConfigureAwait(false); + return validationResult switch + { + bool value => value + ? ContextValidationOutcome.Success + : ContextValidationOutcome.FromFailure(CreateDefaultContextValidationFailure(contextMatch)), + IReplResult replResult => string.Equals(replResult.Kind, "text", StringComparison.OrdinalIgnoreCase) + ? ContextValidationOutcome.Success + : ContextValidationOutcome.FromFailure(replResult), + string text => string.IsNullOrWhiteSpace(text) + ? ContextValidationOutcome.Success + : ContextValidationOutcome.FromFailure(Results.Validation(text)), + null => ContextValidationOutcome.FromFailure(CreateDefaultContextValidationFailure(contextMatch)), + _ => throw new InvalidOperationException( + "Context validation must return bool, string, IReplResult, or null."), + }; + } + + private static IReplResult CreateDefaultContextValidationFailure(ContextMatch contextMatch) + { + var scope = contextMatch.Context.Template.Template; + var details = contextMatch.RouteValues.Count == 0 + ? null + : contextMatch.RouteValues; + return Results.Validation($"Scope validation failed for '{scope}'.", details); + } + + private IReplResult CreateUnknownCommandResult(IReadOnlyList tokens) + { + var activeGraph = app.ResolveActiveRoutingGraph(); + var discoverableRoutes = ResolveDiscoverableRoutes( + activeGraph.Routes, + activeGraph.Contexts, + tokens, + StringComparison.OrdinalIgnoreCase); + var input = string.Join(' ', tokens); + var visibleRoutes = discoverableRoutes + .Where(route => !route.Command.IsHidden) + .Select(route => route.Template.Template) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var bestSuggestion = FindBestSuggestion(input, visibleRoutes); + if (bestSuggestion is null) + { + return Results.Error("unknown_command", $"Unknown command '{input}'."); + } + + return Results.Error( + code: "unknown_command", + message: $"Unknown command '{input}'. Did you mean '{bestSuggestion}'?"); + } + + internal static IReplResult CreateAmbiguousPrefixResult(PrefixResolutionResult prefixResolution) + { + var message = $"Ambiguous command prefix '{prefixResolution.AmbiguousToken}'. Candidates: {string.Join(", ", prefixResolution.Candidates)}."; + return Results.Validation(message); + } + + private static IReplResult CreateInvalidRouteValueResult(RouteResolver.RouteConstraintFailure failure) + { + var expected = GetConstraintDisplayName(failure.Segment); + var message = $"Invalid value '{failure.Value}' for parameter '{failure.Segment.Name}' (expected: {expected})."; + return Results.Validation(message); + } + + private static IReplResult CreateMissingRouteValuesResult(RouteResolver.RouteMissingArgumentsFailure failure) + { + if (failure.MissingSegments.Length == 1) + { + var segment = failure.MissingSegments[0]; + var expected = GetConstraintDisplayName(segment); + var message = $"Missing value for parameter '{segment.Name}' (expected: {expected})."; + return Results.Validation(message); + } + + var names = string.Join(", ", failure.MissingSegments.Select(segment => segment.Name)); + return Results.Validation($"Missing values for parameters: {names}."); + } + + internal IReplResult CreateRouteResolutionFailureResult( + IReadOnlyList tokens, + RouteResolver.RouteConstraintFailure? constraintFailure, + RouteResolver.RouteMissingArgumentsFailure? missingArgumentsFailure) + { + if (constraintFailure is { } routeConstraintFailure) + { + return CreateInvalidRouteValueResult(routeConstraintFailure); + } + + if (missingArgumentsFailure is { } routeMissingArgumentsFailure) + { + return CreateMissingRouteValuesResult(routeMissingArgumentsFailure); + } + + return CreateUnknownCommandResult(tokens); + } + + private static string GetConstraintDisplayName(DynamicRouteSegment segment) => + segment.ConstraintKind == RouteConstraintKind.Custom && !string.IsNullOrWhiteSpace(segment.CustomConstraintName) + ? segment.CustomConstraintName! + : DocumentationEngine.GetConstraintTypeName(segment.ConstraintKind); + + internal async ValueTask TryRenderCommandBannerAsync( + CommandBuilder command, + string? outputFormat, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + if (command.IsProtocolPassthrough) + { + return; + } + + if (command.Banner is { } banner && ShouldRenderBanner(outputFormat)) + { + await InvokeBannerAsync(banner, serviceProvider, cancellationToken).ConfigureAwait(false); + } + } + + internal bool ShouldRenderBanner(string? requestedOutputFormat) + { + if (app.AllBannersSuppressed.Value || !app.OptionsSnapshot.Output.BannerEnabled) + { + return false; + } + + var format = string.IsNullOrWhiteSpace(requestedOutputFormat) + ? app.OptionsSnapshot.Output.DefaultFormat + : requestedOutputFormat; + return app.OptionsSnapshot.Output.BannerFormats.Contains(format); + } + + internal async ValueTask InvokeBannerAsync( + Delegate banner, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + var bindingContext = new InvocationBindingContext( + routeValues: new Dictionary(StringComparer.OrdinalIgnoreCase), + namedOptions: new Dictionary>(StringComparer.OrdinalIgnoreCase), + positionalArguments: [], + optionSchema: OptionSchema.Empty, + optionCaseSensitivity: app.OptionsSnapshot.Parsing.OptionCaseSensitivity, + contextValues: [ReplSessionIO.Output], + numericFormatProvider: app.OptionsSnapshot.Parsing.NumericFormatProvider, + serviceProvider: serviceProvider, + interactionOptions: app.OptionsSnapshot.Interaction, + cancellationToken: cancellationToken); + var arguments = HandlerArgumentBinder.Bind(banner, bindingContext); + var result = await CommandInvoker.InvokeAsync(banner, arguments).ConfigureAwait(false); + if (result is string text && !string.IsNullOrEmpty(text)) + { + var styled = app.OptionsSnapshot.Output.IsAnsiEnabled() + ? AnsiText.Apply(text, app.OptionsSnapshot.Output.ResolvePalette().BannerStyle) + : text; + await ReplSessionIO.Output.WriteLineAsync(styled).ConfigureAwait(false); + } + } + + internal string BuildBannerText() + { + var assembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly(); + var product = assembly.GetCustomAttribute()?.Product; + var version = assembly.GetCustomAttribute()?.InformationalVersion; + var description = app.Description + ?? assembly.GetCustomAttribute()?.Description; + + var header = string.Join( + ' ', + new[] { product, version } + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value!)); + + if (string.IsNullOrWhiteSpace(header)) + { + return description ?? string.Empty; + } + + return string.IsNullOrWhiteSpace(description) + ? header + : $"{header}{Environment.NewLine}{description}"; + } + + internal PrefixResolutionResult ResolveUniquePrefixes(IReadOnlyList tokens) + { + var activeGraph = app.ResolveActiveRoutingGraph(); + if (tokens.Count == 0) + { + return new PrefixResolutionResult(tokens: []); + } + + var resolved = tokens.ToArray(); + for (var index = 0; index < resolved.Length; index++) + { + // Prefix expansion is only attempted on literal nodes that remain reachable + // after validating previously resolved segments (including typed dynamics). + var candidates = ResolveLiteralCandidatesAtIndex(resolved, index, activeGraph.Routes, activeGraph.Contexts); + if (candidates.Length == 0) + { + continue; + } + + var token = resolved[index]; + var exact = candidates + .Where(candidate => string.Equals(candidate, token, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (exact.Length == 1) + { + resolved[index] = exact[0]; + continue; + } + + var prefixMatches = candidates + .Where(candidate => candidate.StartsWith(token, StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (prefixMatches.Length == 1) + { + resolved[index] = prefixMatches[0]; + continue; + } + + if (prefixMatches.Length > 1) + { + // Ambiguous shorthand must fail fast so users don't execute the wrong command. + return new PrefixResolutionResult( + tokens: resolved, + ambiguousToken: token, + candidates: prefixMatches); + } + } + + return new PrefixResolutionResult(tokens: resolved); + } + + private string[] ResolveLiteralCandidatesAtIndex( + string[] tokens, + int index, + IReadOnlyList routes, + IReadOnlyList contexts) + { + var prefixTokens = tokens.Take(index).ToArray(); + var discoverableRoutes = ResolveDiscoverableRoutes( + routes, + contexts, + prefixTokens, + StringComparison.OrdinalIgnoreCase); + var discoverableContexts = ResolveDiscoverableContexts( + contexts, + prefixTokens, + StringComparison.OrdinalIgnoreCase); + var literals = EnumeratePrefixTemplates(discoverableRoutes, discoverableContexts) + .Where(template => !template.IsHidden) + .SelectMany(template => GetCandidateLiterals(template, tokens, index)) + .Where(candidate => !string.IsNullOrWhiteSpace(candidate)) + .Select(candidate => candidate!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + return literals; + } + + private static IEnumerable EnumeratePrefixTemplates( + IReadOnlyList routes, + IReadOnlyList contexts) + { + foreach (var route in routes) + { + yield return new PrefixTemplate(route.Template, route.Command.IsHidden, route.Command.Aliases); + } + + foreach (var context in contexts) + { + yield return new PrefixTemplate(context.Template, context.IsHidden, Aliases: []); + } + } + + internal RouteDefinition[] ResolveDiscoverableRoutes( + IReadOnlyList routes, + IReadOnlyList contexts, + IReadOnlyList scopeTokens, + StringComparison comparison) => + [.. routes.Where(route => + !IsRouteSuppressedForDiscovery(route.Template, contexts, scopeTokens, comparison)),]; + + internal ContextDefinition[] ResolveDiscoverableContexts( + IReadOnlyList contexts, + IReadOnlyList scopeTokens, + StringComparison comparison) => + [.. contexts.Where(context => + !IsContextSuppressedForDiscovery(context, scopeTokens, comparison)),]; + + internal bool IsRouteSuppressedForDiscovery( + RouteTemplate routeTemplate, + IReadOnlyList contexts, + IReadOnlyList scopeTokens, + StringComparison comparison) + { + foreach (var context in contexts) + { + if (!context.IsHidden + || !IsTemplatePrefix(context.Template, routeTemplate) + || !IsContextSuppressedForDiscovery(context, scopeTokens, comparison)) + { + continue; + } + + return true; + } + + return false; + } + + internal bool IsContextSuppressedForDiscovery( + ContextDefinition context, + IReadOnlyList scopeTokens, + StringComparison comparison) + { + if (!context.IsHidden) + { + return false; + } + + return !IsScopeWithinTemplate(scopeTokens, context.Template, comparison); + } + + private bool IsScopeWithinTemplate( + IReadOnlyList scopeTokens, + RouteTemplate template, + StringComparison comparison) + { + if (scopeTokens.Count < template.Segments.Count) + { + return false; + } + + for (var i = 0; i < template.Segments.Count; i++) + { + var scopeToken = scopeTokens[i]; + var segment = template.Segments[i]; + switch (segment) + { + case LiteralRouteSegment literal + when !string.Equals(literal.Value, scopeToken, comparison): + return false; + case DynamicRouteSegment dynamic + when !RouteConstraintEvaluator.IsMatch(dynamic, scopeToken, app.OptionsSnapshot.Parsing): + return false; + } + } + + return true; + } + + private IReadOnlyList GetCandidateLiterals(PrefixTemplate template, string[] tokens, int index) + { + var routeTemplate = template.Template; + if (routeTemplate.Segments.Count <= index) + { + return []; + } + + for (var i = 0; i < index; i++) + { + var token = tokens[i]; + var segment = routeTemplate.Segments[i]; + // Keep only templates whose resolved prefix still matches the user's input. + if (segment is LiteralRouteSegment literal + && !string.Equals(literal.Value, token, StringComparison.OrdinalIgnoreCase)) + { + return []; + } + + if (segment is DynamicRouteSegment dynamic + && !RouteConstraintEvaluator.IsMatch(dynamic, token, app.OptionsSnapshot.Parsing)) + { + return []; + } + } + + if (routeTemplate.Segments[index] is not LiteralRouteSegment literalSegment) + { + return []; + } + + if (index == routeTemplate.Segments.Count - 1 && template.Aliases.Count > 0) + { + return [literalSegment.Value, .. template.Aliases]; + } + + return [literalSegment.Value]; + } + + private static string? FindBestSuggestion(string input, string[] candidates) + { + if (string.IsNullOrWhiteSpace(input) || candidates.Length == 0) + { + return null; + } + + var exactPrefix = candidates + .FirstOrDefault(candidate => + candidate.StartsWith(input, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(exactPrefix)) + { + return exactPrefix; + } + + var normalizedInput = input.ToLowerInvariant(); + var minDistance = int.MaxValue; + string? best = null; + foreach (var candidate in candidates) + { + var distance = CoreReplApp.ComputeLevenshteinDistance( + normalizedInput, + candidate.ToLowerInvariant()); + if (distance < minDistance) + { + minDistance = distance; + best = candidate; + } + } + + var threshold = Math.Max(2, normalizedInput.Length / 3); + return minDistance <= threshold ? best : null; + } +} diff --git a/src/Repl.Core/Session/AmbientCommandOutcome.cs b/src/Repl.Core/Session/AmbientCommandOutcome.cs new file mode 100644 index 0000000..3b4d8c4 --- /dev/null +++ b/src/Repl.Core/Session/AmbientCommandOutcome.cs @@ -0,0 +1,9 @@ +namespace Repl; + +internal enum AmbientCommandOutcome +{ + NotHandled, + Handled, + HandledError, + Exit, +} diff --git a/src/Repl.Core/ExitResult.cs b/src/Repl.Core/Session/ExitResult.cs similarity index 100% rename from src/Repl.Core/ExitResult.cs rename to src/Repl.Core/Session/ExitResult.cs diff --git a/src/Repl.Core/IReplExecutionObserver.cs b/src/Repl.Core/Session/IReplExecutionObserver.cs similarity index 100% rename from src/Repl.Core/IReplExecutionObserver.cs rename to src/Repl.Core/Session/IReplExecutionObserver.cs diff --git a/src/Repl.Core/InMemoryReplSessionState.cs b/src/Repl.Core/Session/InMemoryReplSessionState.cs similarity index 100% rename from src/Repl.Core/InMemoryReplSessionState.cs rename to src/Repl.Core/Session/InMemoryReplSessionState.cs diff --git a/src/Repl.Core/Session/InteractiveSession.cs b/src/Repl.Core/Session/InteractiveSession.cs new file mode 100644 index 0000000..818a5ad --- /dev/null +++ b/src/Repl.Core/Session/InteractiveSession.cs @@ -0,0 +1,631 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using Repl.Internal.Options; + +namespace Repl; + +/// +/// Encapsulates the interactive REPL session loop and ambient command dispatch. +/// +internal sealed class InteractiveSession(CoreReplApp app) +{ + internal bool ShouldEnterInteractive(GlobalInvocationOptions globalOptions, bool allowAuto) + { + if (globalOptions.InteractivePrevented) + { + return false; + } + + if (globalOptions.InteractiveForced) + { + return true; + } + + return app.OptionsSnapshot.Interactive.InteractivePolicy switch + { + InteractivePolicy.Force => true, + InteractivePolicy.Prevent => false, + _ => allowAuto, + }; + } + + internal async ValueTask RunInteractiveSessionAsync( + IReadOnlyList initialScopeTokens, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + using var runtimeStateScope = app.PushRuntimeState(serviceProvider, isInteractiveSession: true); + using var cancelHandler = new CancelKeyHandler(); + var scopeTokens = initialScopeTokens.ToList(); + var historyProvider = serviceProvider.GetService(typeof(IHistoryProvider)) as IHistoryProvider; + string? lastHistoryEntry = null; + await app.ShellCompletionRuntimeInstance.HandleStartupAsync(serviceProvider, cancellationToken).ConfigureAwait(false); + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var readResult = await ReadInteractiveInputAsync( + scopeTokens, + historyProvider, + serviceProvider, + cancellationToken) + .ConfigureAwait(false); + if (readResult.Escaped) + { + await ReplSessionIO.Output.WriteLineAsync().ConfigureAwait(false); + continue; // Esc at bare prompt → fresh line. + } + + var line = readResult.Line; + if (line is null) + { + return 0; + } + + var inputTokens = TokenizeInteractiveInput(line); + if (inputTokens.Count == 0) + { + continue; + } + + lastHistoryEntry = await TryAppendHistoryAsync( + historyProvider, + lastHistoryEntry, + line, + cancellationToken) + .ConfigureAwait(false); + + var outcome = await DispatchInteractiveCommandAsync( + inputTokens, scopeTokens, serviceProvider, cancelHandler, cancellationToken) + .ConfigureAwait(false); + if (outcome == AmbientCommandOutcome.Exit) + { + return 0; + } + } + } + + private async ValueTask ReadInteractiveInputAsync( + IReadOnlyList scopeTokens, + IHistoryProvider? historyProvider, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + await ReplSessionIO.Output.WriteAsync(BuildPrompt(scopeTokens)).ConfigureAwait(false); + await ReplSessionIO.Output.WriteAsync(' ').ConfigureAwait(false); + var effectiveMode = app.Autocomplete.ResolveEffectiveAutocompleteMode(serviceProvider); + var renderMode = AutocompleteEngine.ResolveAutocompleteRenderMode(effectiveMode); + var colorStyles = app.Autocomplete.ResolveAutocompleteColorStyles(renderMode == ConsoleLineReader.AutocompleteRenderMode.Rich); + return await ConsoleLineReader.ReadLineAsync( + historyProvider, + effectiveMode == AutocompleteMode.Off + ? null + : (request, ct) => app.Autocomplete.ResolveAutocompleteAsync(request, scopeTokens, serviceProvider, ct), + renderMode, + app.OptionsSnapshot.Interactive.Autocomplete.MaxVisibleSuggestions, + app.OptionsSnapshot.Interactive.Autocomplete.Presentation, + app.OptionsSnapshot.Interactive.Autocomplete.LiveHintEnabled + && renderMode == ConsoleLineReader.AutocompleteRenderMode.Rich, + app.OptionsSnapshot.Interactive.Autocomplete.ColorizeInputLine, + app.OptionsSnapshot.Interactive.Autocomplete.ColorizeHintAndMenu, + colorStyles, + cancellationToken) + .ConfigureAwait(false); + } + + private static async ValueTask TryAppendHistoryAsync( + IHistoryProvider? historyProvider, + string? previousEntry, + string line, + CancellationToken cancellationToken) + { + if (historyProvider is null || string.Equals(line, previousEntry, StringComparison.Ordinal)) + { + return previousEntry; + } + + // Persist raw input before dispatch so ambient commands are also traceable. + // Skip consecutive duplicates, like standard shell behavior (ignoredups). + await historyProvider.AddAsync(entry: line, cancellationToken).ConfigureAwait(false); + return line; + } + + private async ValueTask DispatchInteractiveCommandAsync( + List inputTokens, + List scopeTokens, + IServiceProvider serviceProvider, + CancelKeyHandler cancelHandler, + CancellationToken cancellationToken) + { + var ambientOutcome = await TryHandleAmbientCommandAsync( + inputTokens, + scopeTokens, + serviceProvider, + isInteractiveSession: true, + cancellationToken) + .ConfigureAwait(false); + if (ambientOutcome is AmbientCommandOutcome.Exit + or AmbientCommandOutcome.Handled + or AmbientCommandOutcome.HandledError) + { + return ambientOutcome; + } + + var invocationTokens = scopeTokens.Concat(inputTokens).ToArray(); + var globalOptions = GlobalOptionParser.Parse(invocationTokens, app.OptionsSnapshot.Output, app.OptionsSnapshot.Parsing); + app.GlobalOptionsSnapshotInstance.Update(globalOptions.CustomGlobalNamedOptions); + var prefixResolution = app.ResolveUniquePrefixes(globalOptions.RemainingTokens); + if (prefixResolution.IsAmbiguous) + { + var ambiguous = RoutingEngine.CreateAmbiguousPrefixResult(prefixResolution); + _ = await app.RenderOutputAsync(ambiguous, globalOptions.OutputFormat, cancellationToken, isInteractive: true) + .ConfigureAwait(false); + return AmbientCommandOutcome.Handled; + } + + var resolvedOptions = globalOptions with { RemainingTokens = prefixResolution.Tokens }; + await ExecuteWithCancellationAsync(resolvedOptions, scopeTokens, serviceProvider, cancelHandler, cancellationToken) + .ConfigureAwait(false); + return AmbientCommandOutcome.Handled; + } + + private async ValueTask ExecuteWithCancellationAsync( + GlobalInvocationOptions resolvedOptions, + List scopeTokens, + IServiceProvider serviceProvider, + CancelKeyHandler cancelHandler, + CancellationToken cancellationToken) + { + using var commandCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + SetCommandTokenOnChannel(serviceProvider, commandCts.Token); + cancelHandler.SetCommandCts(commandCts); + + try + { + await ExecuteInteractiveInputAsync(resolvedOptions, scopeTokens, serviceProvider, commandCts.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + await ReplSessionIO.Output.WriteLineAsync("Cancelled.").ConfigureAwait(false); + } + finally + { + cancelHandler.SetCommandCts(cts: null); + SetCommandTokenOnChannel(serviceProvider, default); + } + } + + private static void SetCommandTokenOnChannel(IServiceProvider serviceProvider, CancellationToken ct) + { + if (serviceProvider.GetService(typeof(IReplInteractionChannel)) is ICommandTokenReceiver receiver) + { + receiver.SetCommandToken(ct); + } + } + + private async ValueTask ExecuteInteractiveInputAsync( + GlobalInvocationOptions globalOptions, + List scopeTokens, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + var activeGraph = app.ResolveActiveRoutingGraph(); + if (globalOptions.HelpRequested) + { + _ = await app.RenderHelpAsync(globalOptions, cancellationToken).ConfigureAwait(false); + return; + } + + var resolution = app.ResolveWithDiagnostics(globalOptions.RemainingTokens, activeGraph.Routes); + var match = resolution.Match; + if (match is not null) + { + await app.ExecuteMatchedCommandAsync(match, globalOptions, serviceProvider, scopeTokens, cancellationToken).ConfigureAwait(false); + return; + } + + var contextMatch = ContextResolver.ResolveExact(activeGraph.Contexts, globalOptions.RemainingTokens, app.OptionsSnapshot.Parsing); + if (contextMatch is not null) + { + var contextValidation = await app.ValidateContextAsync(contextMatch, serviceProvider, cancellationToken) + .ConfigureAwait(false); + if (!contextValidation.IsValid) + { + _ = await app.RenderOutputAsync( + contextValidation.Failure, + globalOptions.OutputFormat, + cancellationToken, + isInteractive: true) + .ConfigureAwait(false); + return; + } + + scopeTokens.Clear(); + scopeTokens.AddRange(globalOptions.RemainingTokens); + + if (contextMatch.Context.Banner is { } contextBanner && app.ShouldRenderBanner(globalOptions.OutputFormat)) + { + await app.InvokeBannerAsync(contextBanner, serviceProvider, cancellationToken).ConfigureAwait(false); + } + + return; + } + + var failure = app.CreateRouteResolutionFailureResult( + tokens: globalOptions.RemainingTokens, + resolution.ConstraintFailure, + resolution.MissingArgumentsFailure); + _ = await app.RenderOutputAsync( + failure, + globalOptions.OutputFormat, + cancellationToken, + isInteractive: true) + .ConfigureAwait(false); + } + + [SuppressMessage( + "Maintainability", + "MA0051:Method is too long", + Justification = "Ambient command routing keeps dispatch table explicit and easy to scan.")] + internal async ValueTask TryHandleAmbientCommandAsync( + List inputTokens, + List scopeTokens, + IServiceProvider serviceProvider, + bool isInteractiveSession, + CancellationToken cancellationToken) + { + if (inputTokens.Count == 0) + { + return AmbientCommandOutcome.NotHandled; + } + + var token = inputTokens[0]; + if (CoreReplApp.IsHelpToken(token)) + { + var helpPath = scopeTokens.Concat(inputTokens.Skip(1)).ToArray(); + var helpText = app.BuildHumanHelp(helpPath); + await ReplSessionIO.Output.WriteLineAsync(helpText).ConfigureAwait(false); + return AmbientCommandOutcome.Handled; + } + + if (inputTokens.Count == 1 && string.Equals(token, "..", StringComparison.Ordinal)) + { + return await HandleUpAmbientCommandAsync(scopeTokens, isInteractiveSession).ConfigureAwait(false); + } + + if (inputTokens.Count == 1 && string.Equals(token, "exit", StringComparison.OrdinalIgnoreCase)) + { + return await HandleExitAmbientCommandAsync().ConfigureAwait(false); + } + + if (string.Equals(token, "complete", StringComparison.OrdinalIgnoreCase)) + { + _ = await HandleCompletionAmbientCommandAsync( + inputTokens.Skip(1).ToArray(), + scopeTokens, + serviceProvider, + cancellationToken) + .ConfigureAwait(false); + return AmbientCommandOutcome.Handled; + } + + if (string.Equals(token, "autocomplete", StringComparison.OrdinalIgnoreCase)) + { + return await HandleAutocompleteAmbientCommandAsync( + inputTokens.Skip(1).ToArray(), + serviceProvider, + isInteractiveSession) + .ConfigureAwait(false); + } + + if (string.Equals(token, "history", StringComparison.OrdinalIgnoreCase)) + { + return await HandleHistoryAmbientCommandAsync( + inputTokens.Skip(1).ToArray(), + serviceProvider, + isInteractiveSession, + cancellationToken) + .ConfigureAwait(false); + } + + if (app.OptionsSnapshot.AmbientCommands.CustomCommands.TryGetValue(token, out var customAmbient)) + { + await ExecuteCustomAmbientCommandAsync(customAmbient, serviceProvider, cancellationToken) + .ConfigureAwait(false); + return AmbientCommandOutcome.Handled; + } + + return AmbientCommandOutcome.NotHandled; + } + + internal static async ValueTask HandleUpAmbientCommandAsync( + List scopeTokens, + bool isInteractiveSession) + { + if (scopeTokens.Count > 0) + { + scopeTokens.RemoveAt(scopeTokens.Count - 1); + return AmbientCommandOutcome.Handled; + } + + if (!isInteractiveSession) + { + await ReplSessionIO.Output.WriteLineAsync("Error: '..' is available only in interactive mode.").ConfigureAwait(false); + return AmbientCommandOutcome.HandledError; + } + + return AmbientCommandOutcome.Handled; + } + + internal async ValueTask HandleExitAmbientCommandAsync() + { + if (app.OptionsSnapshot.AmbientCommands.ExitCommandEnabled) + { + return AmbientCommandOutcome.Exit; + } + + await ReplSessionIO.Output.WriteLineAsync("Error: exit command is disabled.").ConfigureAwait(false); + return AmbientCommandOutcome.HandledError; + } + + private static async ValueTask HandleHistoryAmbientCommandAsync( + IReadOnlyList commandTokens, + IServiceProvider serviceProvider, + bool isInteractiveSession, + CancellationToken cancellationToken) + { + if (!isInteractiveSession) + { + await ReplSessionIO.Output.WriteLineAsync("Error: history is available only in interactive mode.").ConfigureAwait(false); + return AmbientCommandOutcome.HandledError; + } + + await HandleHistoryAmbientCommandCoreAsync(commandTokens, serviceProvider, cancellationToken).ConfigureAwait(false); + return AmbientCommandOutcome.Handled; + } + + private async ValueTask HandleAutocompleteAmbientCommandAsync( + string[] commandTokens, + IServiceProvider serviceProvider, + bool isInteractiveSession) + { + if (!isInteractiveSession) + { + await ReplSessionIO.Output.WriteLineAsync("Error: autocomplete is available only in interactive mode.") + .ConfigureAwait(false); + return AmbientCommandOutcome.HandledError; + } + + var sessionState = serviceProvider.GetService(typeof(IReplSessionState)) as IReplSessionState; + if (commandTokens.Length == 0 + || (commandTokens.Length == 1 && string.Equals(commandTokens[0], "show", StringComparison.OrdinalIgnoreCase))) + { + var configured = app.OptionsSnapshot.Interactive.Autocomplete.Mode; + var overrideMode = sessionState?.Get(AutocompleteEngine.AutocompleteModeSessionStateKey); + var effective = app.Autocomplete.ResolveEffectiveAutocompleteMode(serviceProvider); + await ReplSessionIO.Output.WriteLineAsync( + $"Autocomplete mode: configured={configured}, override={(overrideMode ?? "none")}, effective={effective}") + .ConfigureAwait(false); + return AmbientCommandOutcome.Handled; + } + + if (commandTokens.Length == 2 && string.Equals(commandTokens[0], "mode", StringComparison.OrdinalIgnoreCase)) + { + if (!Enum.TryParse(commandTokens[1], ignoreCase: true, out var mode)) + { + await ReplSessionIO.Output.WriteLineAsync("Error: autocomplete mode must be one of off|auto|basic|rich.") + .ConfigureAwait(false); + return AmbientCommandOutcome.HandledError; + } + + sessionState?.Set(AutocompleteEngine.AutocompleteModeSessionStateKey, mode.ToString()); + var effective = app.Autocomplete.ResolveEffectiveAutocompleteMode(serviceProvider); + await ReplSessionIO.Output.WriteLineAsync($"Autocomplete mode set to {mode} (effective: {effective}).") + .ConfigureAwait(false); + return AmbientCommandOutcome.Handled; + } + + await ReplSessionIO.Output.WriteLineAsync("Error: usage: autocomplete [show] | autocomplete mode .") + .ConfigureAwait(false); + return AmbientCommandOutcome.HandledError; + } + + internal async ValueTask HandleCompletionAmbientCommandAsync( + IReadOnlyList commandTokens, + IReadOnlyList scopeTokens, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + var parsed = InvocationOptionParser.Parse(commandTokens); + if (!parsed.NamedOptions.TryGetValue("target", out var targetValues) || targetValues.Count == 0) + { + await ReplSessionIO.Output.WriteLineAsync("Error: complete requires --target .").ConfigureAwait(false); + return false; + } + + var target = targetValues[0]; + var input = parsed.NamedOptions.TryGetValue("input", out var inputValues) && inputValues.Count > 0 + ? inputValues[0] + : string.Empty; + var fullCommandPath = scopeTokens.Concat(parsed.PositionalArguments).ToArray(); + var resolvedPath = app.ResolveUniquePrefixes(fullCommandPath); + if (resolvedPath.IsAmbiguous) + { + var ambiguous = RoutingEngine.CreateAmbiguousPrefixResult(resolvedPath); + _ = await app.RenderOutputAsync(ambiguous, requestedFormat: null, cancellationToken).ConfigureAwait(false); + return false; + } + + var match = app.Resolve(resolvedPath.Tokens); + if (match is null || match.RemainingTokens.Count > 0) + { + await ReplSessionIO.Output.WriteLineAsync("Error: complete requires a terminal command path.").ConfigureAwait(false); + return false; + } + + if (!match.Route.Command.Completions.TryGetValue(target, out var completion)) + { + await ReplSessionIO.Output.WriteLineAsync($"Error: no completion provider registered for '{target}'.").ConfigureAwait(false); + return false; + } + + var context = new CompletionContext(serviceProvider); + var candidates = await completion(context, input, cancellationToken).ConfigureAwait(false); + if (candidates.Count == 0) + { + await ReplSessionIO.Output.WriteLineAsync("(none)").ConfigureAwait(false); + return true; + } + + foreach (var candidate in candidates) + { + await ReplSessionIO.Output.WriteLineAsync(candidate).ConfigureAwait(false); + } + + return true; + } + + [SuppressMessage( + "Maintainability", + "MA0051:Method is too long", + Justification = "History command parsing and rendering are intentionally kept together.")] + private static async ValueTask HandleHistoryAmbientCommandCoreAsync( + IReadOnlyList commandTokens, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + var parsed = InvocationOptionParser.Parse(commandTokens); + var limit = 20; + if (parsed.NamedOptions.TryGetValue("limit", out var limitValues) && limitValues.Count > 0) + { + limit = int.TryParse( + limitValues[0], + style: NumberStyles.Integer, + provider: CultureInfo.InvariantCulture, + result: out var parsedLimit) && parsedLimit > 0 + ? parsedLimit + : throw new InvalidOperationException("history --limit must be a positive integer."); + } + + var historyProvider = serviceProvider.GetService(typeof(IHistoryProvider)) as IHistoryProvider; + if (historyProvider is null) + { + await ReplSessionIO.Output.WriteLineAsync("(history unavailable)").ConfigureAwait(false); + return; + } + + var entries = await historyProvider.GetRecentAsync(maxCount: limit, cancellationToken).ConfigureAwait(false); + if (entries.Count == 0) + { + await ReplSessionIO.Output.WriteLineAsync("(empty)").ConfigureAwait(false); + return; + } + + foreach (var entry in entries) + { + await ReplSessionIO.Output.WriteLineAsync(entry).ConfigureAwait(false); + } + } + + private async ValueTask ExecuteCustomAmbientCommandAsync( + AmbientCommandDefinition command, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + var bindingContext = new InvocationBindingContext( + routeValues: new Dictionary(StringComparer.OrdinalIgnoreCase), + namedOptions: new Dictionary>(StringComparer.OrdinalIgnoreCase), + positionalArguments: [], + optionSchema: Internal.Options.OptionSchema.Empty, + optionCaseSensitivity: app.OptionsSnapshot.Parsing.OptionCaseSensitivity, + contextValues: [], + numericFormatProvider: app.OptionsSnapshot.Parsing.NumericFormatProvider ?? CultureInfo.InvariantCulture, + serviceProvider: serviceProvider, + interactionOptions: app.OptionsSnapshot.Interaction, + cancellationToken: cancellationToken); + var arguments = HandlerArgumentBinder.Bind(command.Handler, bindingContext); + await CommandInvoker.InvokeAsync(command.Handler, arguments).ConfigureAwait(false); + } + + internal string[] GetDeepestContextScopePath(IReadOnlyList matchedPathTokens) + { + var activeGraph = app.ResolveActiveRoutingGraph(); + var contextMatches = ContextResolver.ResolvePrefixes(activeGraph.Contexts, matchedPathTokens, app.OptionsSnapshot.Parsing); + var longestPrefixLength = 0; + foreach (var contextMatch in contextMatches) + { + var prefixLength = contextMatch.Context.Template.Segments.Count; + if (prefixLength > longestPrefixLength) + { + longestPrefixLength = prefixLength; + } + } + + return longestPrefixLength == 0 + ? [] + : matchedPathTokens.Take(longestPrefixLength).ToArray(); + } + + private string BuildPrompt(IReadOnlyList scopeTokens) + { + var basePrompt = app.OptionsSnapshot.Interactive.Prompt; + if (scopeTokens.Count == 0) + { + return basePrompt; + } + + var promptWithoutSuffix = basePrompt.EndsWith('>') + ? basePrompt[..^1] + : basePrompt; + var scope = string.Join('/', scopeTokens); + return string.IsNullOrWhiteSpace(promptWithoutSuffix) + ? $"[{scope}]>" + : $"{promptWithoutSuffix} [{scope}]>"; + } + + internal static List TokenizeInteractiveInput(string input) + { + if (string.IsNullOrWhiteSpace(input)) + { + return []; + } + + var tokens = new List(); + var current = new System.Text.StringBuilder(); + char? quote = null; + foreach (var ch in input) + { + if (quote is null && (ch == '"' || ch == '\'')) + { + quote = ch; + continue; + } + + if (quote is not null && ch == quote.Value) + { + quote = null; + continue; + } + + if (quote is null && char.IsWhiteSpace(ch)) + { + if (current.Length > 0) + { + tokens.Add(current.ToString()); + current.Clear(); + } + + continue; + } + + current.Append(ch); + } + + if (current.Length > 0) + { + tokens.Add(current.ToString()); + } + + return tokens; + } +} diff --git a/src/Repl.Core/LiveReplIoContext.cs b/src/Repl.Core/Session/LiveReplIoContext.cs similarity index 100% rename from src/Repl.Core/LiveReplIoContext.cs rename to src/Repl.Core/Session/LiveReplIoContext.cs diff --git a/src/Repl.Core/LiveSessionInfo.cs b/src/Repl.Core/Session/LiveSessionInfo.cs similarity index 100% rename from src/Repl.Core/LiveSessionInfo.cs rename to src/Repl.Core/Session/LiveSessionInfo.cs diff --git a/src/Repl.Core/ReplSessionIO.cs b/src/Repl.Core/Session/ReplSessionIO.cs similarity index 100% rename from src/Repl.Core/ReplSessionIO.cs rename to src/Repl.Core/Session/ReplSessionIO.cs diff --git a/src/Repl.Core/ShellCompletion/ShellCompletionEngine.cs b/src/Repl.Core/ShellCompletion/ShellCompletionEngine.cs new file mode 100644 index 0000000..7ece0d9 --- /dev/null +++ b/src/Repl.Core/ShellCompletion/ShellCompletionEngine.cs @@ -0,0 +1,429 @@ +using Repl.Internal.Options; + +namespace Repl; + +/// +/// Provides shell (bash/zsh/fish/etc.) completion candidates for the Repl routing graph. +/// +internal sealed class ShellCompletionEngine(CoreReplApp app) +{ + private static readonly string[] StaticShellGlobalOptions = + [ + "--help", + "--interactive", + "--no-interactive", + "--no-logo", + "--output:", + ]; + + public string[] ResolveShellCompletionCandidates(string line, int cursor) + { + var activeGraph = app.ResolveActiveRoutingGraph(); + var state = AnalyzeShellCompletionInput(line, cursor); + if (state.PriorTokens.Length == 0) + { + return []; + } + + var parsed = state.PriorTokens.Length <= 1 + ? InvocationOptionParser.Parse(Array.Empty()) + : InvocationOptionParser.Parse(new ArraySegment( + state.PriorTokens, + offset: 1, + count: state.PriorTokens.Length - 1)); + var commandPrefix = parsed.PositionalArguments as string[] ?? [.. parsed.PositionalArguments]; + var currentTokenPrefix = state.CurrentTokenPrefix; + var currentTokenIsOption = AutocompleteEngine.IsGlobalOptionToken(currentTokenPrefix); + var routeMatch = app.Resolve(commandPrefix, activeGraph.Routes); + 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( + commandPrefix, + currentTokenPrefix, + activeGraph.Routes, + activeGraph.Contexts, + dedupe, + candidates); + } + + if (currentTokenIsOption || (string.IsNullOrEmpty(currentTokenPrefix) && hasTerminalRoute)) + { + AddShellOptionCandidates( + hasTerminalRoute ? routeMatch!.Route : null, + currentTokenPrefix, + dedupe, + candidates); + } + + candidates.Sort(StringComparer.OrdinalIgnoreCase); + 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 ?? app.OptionsSnapshot.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 (!AutocompleteEngine.IsGlobalOptionToken(previousToken)) + { + return false; + } + + var separatorIndex = previousToken.IndexOfAny(['=', ':']); + if (separatorIndex >= 0) + { + return false; + } + + var matches = route.OptionSchema.ResolveToken(previousToken, app.OptionsSnapshot.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, + List candidates) + { + if (string.IsNullOrWhiteSpace(candidate) || !dedupe.Add(candidate)) + { + return; + } + + candidates.Add(candidate); + } + + private void AddShellCommandCandidates( + string[] commandPrefix, + string currentTokenPrefix, + IReadOnlyList routes, + IReadOnlyList contexts, + HashSet dedupe, + List candidates) + { + var matchingRoutes = app.Autocomplete.CollectVisibleMatchingRoutes( + commandPrefix, + StringComparison.OrdinalIgnoreCase, + routes, + contexts); + foreach (var route in matchingRoutes) + { + if (commandPrefix.Length >= route.Template.Segments.Count + || route.Template.Segments[commandPrefix.Length] is not LiteralRouteSegment literal + || !literal.Value.StartsWith(currentTokenPrefix, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + TryAddShellCompletionCandidate(literal.Value, dedupe, candidates); + } + } + + private void AddShellOptionCandidates( + RouteDefinition? route, + string currentTokenPrefix, + HashSet dedupe, + List candidates) + { + AddGlobalShellOptionCandidates(currentTokenPrefix, dedupe, candidates); + + if (route is null) + { + return; + } + + AddRouteShellOptionCandidates(route, currentTokenPrefix, dedupe, candidates); + } + + private void AddGlobalShellOptionCandidates( + string currentTokenPrefix, + HashSet dedupe, + List candidates) + { + var options = app.OptionsSnapshot; + var comparison = options.Parsing.OptionCaseSensitivity == ReplCaseSensitivity.CaseInsensitive + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + foreach (var option in StaticShellGlobalOptions) + { + if (option.StartsWith(currentTokenPrefix, comparison)) + { + TryAddShellCompletionCandidate(option, dedupe, candidates); + } + } + + foreach (var alias in options.Output.Aliases.Keys) + { + var opt = $"--{alias}"; + if (opt.StartsWith(currentTokenPrefix, comparison)) + { + TryAddShellCompletionCandidate(opt, dedupe, candidates); + } + } + + foreach (var format in options.Output.Transformers.Keys) + { + var opt = $"--output:{format}"; + if (opt.StartsWith(currentTokenPrefix, comparison)) + { + TryAddShellCompletionCandidate(opt, dedupe, candidates); + } + } + + foreach (var custom in options.Parsing.GlobalOptions.Values) + { + if (custom.CanonicalToken.StartsWith(currentTokenPrefix, comparison)) + { + TryAddShellCompletionCandidate(custom.CanonicalToken, dedupe, candidates); + } + + foreach (var alias in custom.Aliases) + { + if (alias.StartsWith(currentTokenPrefix, comparison)) + { + TryAddShellCompletionCandidate(alias, dedupe, candidates); + } + } + } + } + + private void AddRouteShellOptionCandidates( + RouteDefinition route, + string currentTokenPrefix, + HashSet dedupe, + List candidates) + { + var comparison = app.OptionsSnapshot.Parsing.OptionCaseSensitivity == ReplCaseSensitivity.CaseInsensitive + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + foreach (var token in route.OptionSchema.KnownTokens) + { + if (token.StartsWith(currentTokenPrefix, comparison)) + { + TryAddShellCompletionCandidate(token, dedupe, candidates); + } + } + } + + internal static ShellCompletionInputState AnalyzeShellCompletionInput(string input, int cursor) + { + input ??= string.Empty; + cursor = Math.Clamp(cursor, 0, input.Length); + var tokens = AutocompleteEngine.TokenizeInputSpans(input); + for (var i = 0; i < tokens.Count; i++) + { + var token = tokens[i]; + if (cursor < token.Start || cursor > token.End) + { + continue; + } + + var prior = new string[i]; + for (var priorIndex = 0; priorIndex < i; priorIndex++) + { + prior[priorIndex] = tokens[priorIndex].Value; + } + + var prefix = input[token.Start..cursor]; + return new ShellCompletionInputState(prior, prefix); + } + + var trailingPriorCount = 0; + foreach (var token in tokens) + { + if (token.End <= cursor) + { + trailingPriorCount++; + } + } + + if (trailingPriorCount == 0) + { + return new ShellCompletionInputState([], CurrentTokenPrefix: string.Empty); + } + + var trailingPrior = new string[trailingPriorCount]; + var index = 0; + foreach (var token in tokens) + { + if (token.End <= cursor) + { + trailingPrior[index++] = token.Value; + } + } + + return new ShellCompletionInputState(trailingPrior, CurrentTokenPrefix: string.Empty); + } + + internal readonly record struct ShellCompletionInputState( + string[] PriorTokens, + string CurrentTokenPrefix); + + internal static string ResolveShellCompletionCommandName( + IReadOnlyList? commandLineArgs, + string? processPath, + string? fallbackName) + { + if (commandLineArgs is { Count: > 0 }) + { + var commandHead = TryGetCommandHead(commandLineArgs[0]); + if (!string.IsNullOrWhiteSpace(commandHead)) + { + return commandHead; + } + } + + var processHead = TryGetCommandHead(processPath); + if (!string.IsNullOrWhiteSpace(processHead)) + { + return processHead; + } + + return string.IsNullOrWhiteSpace(fallbackName) ? "repl" : fallbackName; + } + + private static string? TryGetCommandHead(string? pathLike) + { + if (string.IsNullOrWhiteSpace(pathLike)) + { + return null; + } + + var fileName = Path.GetFileName(pathLike.Trim()); + if (string.IsNullOrWhiteSpace(fileName)) + { + return null; + } + + foreach (var extension in KnownExecutableExtensions) + { + if (fileName.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) + { + var head = fileName[..^extension.Length]; + return string.IsNullOrWhiteSpace(head) ? null : head; + } + } + + return fileName; + } + + private static readonly string[] KnownExecutableExtensions = + [ + ".exe", + ".cmd", + ".bat", + ".com", + ".ps1", + ".dll", + ]; + + public string ResolveShellCompletionCommandName() + { + var docApp = app.BuildDocumentationApp(); + return ResolveShellCompletionCommandName( + Environment.GetCommandLineArgs(), + Environment.ProcessPath, + docApp.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/ShellCompletionHostValidator.cs b/src/Repl.Core/ShellCompletion/ShellCompletionHostValidator.cs similarity index 100% rename from src/Repl.Core/ShellCompletionHostValidator.cs rename to src/Repl.Core/ShellCompletion/ShellCompletionHostValidator.cs diff --git a/src/Repl.Core/ShellCompletion/Public/ShellCompletionOptions.cs b/src/Repl.Core/ShellCompletion/ShellCompletionOptions.cs similarity index 100% rename from src/Repl.Core/ShellCompletion/Public/ShellCompletionOptions.cs rename to src/Repl.Core/ShellCompletion/ShellCompletionOptions.cs diff --git a/src/Repl.Core/ShellCompletion/ShellCompletionRuntime.Detection.cs b/src/Repl.Core/ShellCompletion/ShellCompletionRuntime.Detection.cs index c4dffe7..88c6d50 100644 --- a/src/Repl.Core/ShellCompletion/ShellCompletionRuntime.Detection.cs +++ b/src/Repl.Core/ShellCompletion/ShellCompletionRuntime.Detection.cs @@ -310,7 +310,9 @@ private static bool TryGetParentProcessIdLinux(int processId, out int parentProc return false; } - var stat = File.ReadAllText(statPath); + #pragma warning disable MA0045 // Synchronous by design — process tree walking is synchronous utility code + var stat = File.ReadAllText(statPath); +#pragma warning restore MA0045 var endCommand = stat.LastIndexOf(')'); if (endCommand < 0 || endCommand + 2 >= stat.Length) { @@ -352,7 +354,9 @@ private static bool TryGetParentProcessIdMac(int processId, out int parentProces return false; } - var output = process.StandardOutput.ReadToEnd(); + #pragma warning disable MA0045 // Synchronous by design — process tree walking is synchronous utility code + var output = process.StandardOutput.ReadToEnd(); +#pragma warning restore MA0045 process.WaitForExit(milliseconds: 250); return int.TryParse(output.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out parentProcessId); } diff --git a/src/Repl.Core/ShellCompletion/ShellCompletionRuntime.Persistence.cs b/src/Repl.Core/ShellCompletion/ShellCompletionRuntime.Persistence.cs index 60a15c2..de44ec0 100644 --- a/src/Repl.Core/ShellCompletion/ShellCompletionRuntime.Persistence.cs +++ b/src/Repl.Core/ShellCompletion/ShellCompletionRuntime.Persistence.cs @@ -283,8 +283,10 @@ private bool IsShellCompletionInstalled(ShellKind shellKind, ShellDetectionResul try { +#pragma warning disable MA0045 // Synchronous by design — called from sync status-checking method var content = File.ReadAllText(profilePath); return ContainsShellCompletionManagedBlock(content, shellKind, appId); +#pragma warning restore MA0045 } catch { diff --git a/src/Repl.Core/ShellCompletion/ShellCompletionRuntime.cs b/src/Repl.Core/ShellCompletion/ShellCompletionRuntime.cs index c093c9b..14782e8 100644 --- a/src/Repl.Core/ShellCompletion/ShellCompletionRuntime.cs +++ b/src/Repl.Core/ShellCompletion/ShellCompletionRuntime.cs @@ -251,7 +251,7 @@ public async ValueTask HandleStartupAsync(IServiceProvider serviceProvider, Canc "shell_completion_install", $"Install shell completion for {FormatShellKind(detection.Kind)} now?", defaultValue: true, - new AskOptions(cancellationToken)) + new AskOptions(CancellationToken: cancellationToken)) .ConfigureAwait(false); } else @@ -403,8 +403,10 @@ private ShellProfileProbe ProbeShellProfilePath( try { +#pragma warning disable MA0045 // Synchronous by design — sync delegate used for profile content probing return File.ReadAllText(profilePath); } +#pragma warning restore MA0045 catch { return null; diff --git a/src/Repl.Core/ShellCompletion/Public/ShellCompletionSetupMode.cs b/src/Repl.Core/ShellCompletion/ShellCompletionSetupMode.cs similarity index 100% rename from src/Repl.Core/ShellCompletion/Public/ShellCompletionSetupMode.cs rename to src/Repl.Core/ShellCompletion/ShellCompletionSetupMode.cs diff --git a/src/Repl.Core/ShellCompletion/Public/ShellKind.cs b/src/Repl.Core/ShellCompletion/ShellKind.cs similarity index 100% rename from src/Repl.Core/ShellCompletion/Public/ShellKind.cs rename to src/Repl.Core/ShellCompletion/ShellKind.cs diff --git a/src/Repl.Core/Terminal/Public/TerminalCapabilities.cs b/src/Repl.Core/Terminal/TerminalCapabilities.cs similarity index 100% rename from src/Repl.Core/Terminal/Public/TerminalCapabilities.cs rename to src/Repl.Core/Terminal/TerminalCapabilities.cs diff --git a/src/Repl.Core/TerminalCapabilitiesClassifier.cs b/src/Repl.Core/Terminal/TerminalCapabilitiesClassifier.cs similarity index 100% rename from src/Repl.Core/TerminalCapabilitiesClassifier.cs rename to src/Repl.Core/Terminal/TerminalCapabilitiesClassifier.cs diff --git a/src/Repl.Core/Terminal/Public/TerminalControlMessage.cs b/src/Repl.Core/Terminal/TerminalControlMessage.cs similarity index 100% rename from src/Repl.Core/Terminal/Public/TerminalControlMessage.cs rename to src/Repl.Core/Terminal/TerminalControlMessage.cs diff --git a/src/Repl.Core/Terminal/Public/TerminalControlMessageKind.cs b/src/Repl.Core/Terminal/TerminalControlMessageKind.cs similarity index 100% rename from src/Repl.Core/Terminal/Public/TerminalControlMessageKind.cs rename to src/Repl.Core/Terminal/TerminalControlMessageKind.cs diff --git a/src/Repl.Core/Terminal/Public/TerminalControlProtocol.cs b/src/Repl.Core/Terminal/TerminalControlProtocol.cs similarity index 100% rename from src/Repl.Core/Terminal/Public/TerminalControlProtocol.cs rename to src/Repl.Core/Terminal/TerminalControlProtocol.cs diff --git a/src/Repl.Core/WindowSizeEventArgs.cs b/src/Repl.Core/Terminal/WindowSizeEventArgs.cs similarity index 100% rename from src/Repl.Core/WindowSizeEventArgs.cs rename to src/Repl.Core/Terminal/WindowSizeEventArgs.cs diff --git a/src/Repl.IntegrationTests/Given_PromptTimeout.cs b/src/Repl.IntegrationTests/Given_PromptTimeout.cs index 0eee9dd..ab3b53f 100644 --- a/src/Repl.IntegrationTests/Given_PromptTimeout.cs +++ b/src/Repl.IntegrationTests/Given_PromptTimeout.cs @@ -37,7 +37,7 @@ public void When_AskOptionsHasExplicitToken_Then_TokenIsUsed() { var result = await channel.AskTextAsync( "name", "Name?", defaultValue: "default", - new AskOptions(CancellationToken.None)).ConfigureAwait(false); + new AskOptions(CancellationToken: CancellationToken.None)).ConfigureAwait(false); return result; }); diff --git a/src/Repl.Mcp/McpInteractionChannel.cs b/src/Repl.Mcp/McpInteractionChannel.cs index 3780cd4..4f8eefc 100644 --- a/src/Repl.Mcp/McpInteractionChannel.cs +++ b/src/Repl.Mcp/McpInteractionChannel.cs @@ -52,9 +52,9 @@ public async ValueTask AskChoiceAsync( return ResolveChoiceIndex(sampled, choices); } - if (defaultIndex.HasValue) + if (defaultIndex is { } idx) { - return defaultIndex.Value; + return idx; } if (_mode == InteractivityMode.PrefillThenDefaults) @@ -88,9 +88,9 @@ public async ValueTask AskConfirmationAsync( return ParseBool(sampled); } - if (defaultValue.HasValue) + if (defaultValue is { } val) { - return defaultValue.Value; + return val; } if (_mode == InteractivityMode.PrefillThenDefaults) diff --git a/src/Repl.ShellCompletionTestHost/Program.cs b/src/Repl.ShellCompletionTestHost/Program.cs index 98152ff..d12f195 100644 --- a/src/Repl.ShellCompletionTestHost/Program.cs +++ b/src/Repl.ShellCompletionTestHost/Program.cs @@ -16,7 +16,9 @@ private static int Main(string[] args) app.UseDefaultInteractive(); } +#pragma warning disable MA0045 // Sync entry point is intentional for this test host. return app.Run(args); +#pragma warning restore MA0045 } private static void ConfigureScenario(ReplApp app, string? scenario) diff --git a/src/Repl.Spectre/SpectreHumanOutputTransformer.cs b/src/Repl.Spectre/SpectreHumanOutputTransformer.cs index 82e4fdf..27cfdbd 100644 --- a/src/Repl.Spectre/SpectreHumanOutputTransformer.cs +++ b/src/Repl.Spectre/SpectreHumanOutputTransformer.cs @@ -228,7 +228,9 @@ private static string RenderValue(object? value) private static string RenderToString(IRenderable renderable) { +#pragma warning disable MA0045 // StringWriter is disposed synchronously; no async benefit here. using var writer = new StringWriter(); +#pragma warning restore MA0045 var width = 120; if (ReplSessionIO.WindowSize is { } size && size.Width > 0) diff --git a/src/Repl.Spectre/SpectreInteractionHandler.cs b/src/Repl.Spectre/SpectreInteractionHandler.cs index 46470a1..36aaa04 100644 --- a/src/Repl.Spectre/SpectreInteractionHandler.cs +++ b/src/Repl.Spectre/SpectreInteractionHandler.cs @@ -68,7 +68,10 @@ private static async ValueTask HandleChoiceAsync( prompt.HighlightStyle(new Style(Color.Blue)); } + // Spectre.Console's Prompt() is inherently synchronous (console I/O); Task.Run is the intended pattern. +#pragma warning disable MA0045 var selected = await Task.Run(() => console.Prompt(prompt), ct).ConfigureAwait(false); +#pragma warning restore MA0045 // Map back to original index (account for potential reorder). var selectedIndex = MapBackToOriginalIndex(selected, r.Choices); @@ -97,7 +100,9 @@ private static async ValueTask HandleMultiChoiceAsync( prompt.Required(); } +#pragma warning disable MA0045 var selected = await Task.Run(() => console.Prompt(prompt), ct).ConfigureAwait(false); +#pragma warning restore MA0045 var indices = selected .Select(s => MapBackToOriginalIndex(s, r.Choices)) @@ -117,7 +122,9 @@ private static async ValueTask HandleConfirmationAsync( DefaultValue = r.DefaultValue, }; +#pragma warning disable MA0045 var result = await Task.Run(() => console.Prompt(prompt), ct).ConfigureAwait(false); +#pragma warning restore MA0045 return InteractionResult.Success(result); } @@ -133,7 +140,9 @@ private static async ValueTask HandleTextAsync( prompt.DefaultValue(r.DefaultValue); } +#pragma warning disable MA0045 var result = await Task.Run(() => console.Prompt(prompt), ct).ConfigureAwait(false); +#pragma warning restore MA0045 return InteractionResult.Success(result); } @@ -151,7 +160,9 @@ private static async ValueTask HandleSecretAsync( prompt.AllowEmpty(); } +#pragma warning disable MA0045 var result = await Task.Run(() => console.Prompt(prompt), ct).ConfigureAwait(false); +#pragma warning restore MA0045 return InteractionResult.Success(result); } diff --git a/src/Repl.Tests/Given_InteractiveAutocomplete_LiveHint.cs b/src/Repl.Tests/Given_InteractiveAutocomplete_LiveHint.cs index 79d3da3..ae23245 100644 --- a/src/Repl.Tests/Given_InteractiveAutocomplete_LiveHint.cs +++ b/src/Repl.Tests/Given_InteractiveAutocomplete_LiveHint.cs @@ -357,7 +357,9 @@ private static void RunInteractive(ReplApp sut, TerminalHarness harness, FakeKey ReplSessionIO.WindowSize = (80, 12); ReplSessionIO.AnsiSupport = true; ReplSessionIO.TerminalCapabilities = TerminalCapabilities.Ansi | TerminalCapabilities.VtInput; +#pragma warning disable MA0045 // Sync Run is required here — console I/O drives the test. _ = sut.Run([]); +#pragma warning restore MA0045 } finally { diff --git a/src/Repl.slnx b/src/Repl.slnx index 0a647e1..b64c5eb 100644 --- a/src/Repl.slnx +++ b/src/Repl.slnx @@ -1,4 +1,11 @@ + + + + + + +