diff --git a/CommandDotNet.Example.Tests/ExampleCommandTests.cs b/CommandDotNet.Example.Tests/ExampleCommandTests.cs index 084b5f3e3..ca151e121 100644 --- a/CommandDotNet.Example.Tests/ExampleCommandTests.cs +++ b/CommandDotNet.Example.Tests/ExampleCommandTests.cs @@ -14,7 +14,7 @@ public void Help_Should_Include_ExtendedHelpText() Program.GetAppRunner() .Verify(new Scenario { - When = {Args = null}, + When = {Args = "-h"}, Then = { OutputContainsTexts = @@ -41,7 +41,7 @@ public void Should_Include_Version_Option() Program.GetAppRunner() .Verify(new Scenario { - When = { Args = null }, + When = {Args = "-h"}, Then = { OutputContainsTexts = diff --git a/CommandDotNet.Example/Examples.cs b/CommandDotNet.Example/Examples.cs index c165a4941..39dcd6be0 100644 --- a/CommandDotNet.Example/Examples.cs +++ b/CommandDotNet.Example/Examples.cs @@ -1,4 +1,5 @@ using CommandDotNet.Example.Commands; +using CommandDotNet.Repl; using Git = CommandDotNet.Example.Commands.Git; namespace CommandDotNet.Example @@ -17,24 +18,28 @@ namespace CommandDotNet.Example // end-snippet internal class Examples { - private static bool _inSession; - - [DefaultCommand] - public void StartSession( - CommandContext context, - InteractiveSession interactiveSession, - [Option('i')] bool interactive) + [Subcommand] + public class Sessions { - if (interactive && !_inSession) - { - context.Console.WriteLine("start session"); - _inSession = true; - interactiveSession.Start(); - } - else + private static bool _inSession; + + [DefaultCommand] + public void StartSession( + CommandContext context, + ReplSession replSession, + [Option('i')] bool interactive) { - context.Console.WriteLine($"no session {interactive} {_inSession}"); - context.ShowHelpOnExit = true; + if (interactive && !_inSession) + { + context.Console.WriteLine("start session"); + _inSession = true; + replSession.Start(); + } + else + { + context.Console.WriteLine($"no session {interactive} {_inSession}"); + context.ShowHelpOnExit = true; + } } } diff --git a/CommandDotNet.Example/InteractiveMiddleware.cs b/CommandDotNet.Example/InteractiveMiddleware.cs deleted file mode 100644 index 57c21f627..000000000 --- a/CommandDotNet.Example/InteractiveMiddleware.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace CommandDotNet.Example -{ - public static class InteractiveMiddleware - { - public static AppRunner UseInteractiveMode(this AppRunner appRunner, string appName) - { - return appRunner.Configure(c => - { - // use the existing appRunner to reuse the configuration. - c.UseParameterResolver(ctx => new InteractiveSession(appRunner, appName, ctx)); - }); - } - } -} \ No newline at end of file diff --git a/CommandDotNet.Example/Program.cs b/CommandDotNet.Example/Program.cs index 663ca64bb..c3fec0e5d 100644 --- a/CommandDotNet.Example/Program.cs +++ b/CommandDotNet.Example/Program.cs @@ -3,6 +3,7 @@ using CommandDotNet.FluentValidation; using CommandDotNet.NameCasing; using CommandDotNet.Spectre; +using CommandDotNet.Repl; namespace CommandDotNet.Example { @@ -30,7 +31,8 @@ public static AppRunner GetAppRunner(NameValueCollection? appConfigSettings = nu .UseLocalizeDirective() .UseLog2ConsoleDirective() .UseFluentValidation() - .UseInteractiveMode("Example") + //.UseRepl() + .UseRepl(replOptionInfoForRootCommand: ReplOptionInfo.Default) .UseDefaultsFromAppSetting(appConfigSettings, includeNamingConventions: true); } } diff --git a/CommandDotNet.Tests/FeatureTests/Repl/ReplTests.cs b/CommandDotNet.Tests/FeatureTests/Repl/ReplTests.cs new file mode 100644 index 000000000..bc3b1ed28 --- /dev/null +++ b/CommandDotNet.Tests/FeatureTests/Repl/ReplTests.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; +using System.Text; +using CommandDotNet.TestTools.Scenarios; +using Xunit; +using Xunit.Abstractions; + +namespace CommandDotNet.Tests.FeatureTests.Repl +{ + +} diff --git a/CommandDotNet/Execution/MiddlewareSteps.cs b/CommandDotNet/Execution/MiddlewareSteps.cs index 3a5759375..acac19fde 100644 --- a/CommandDotNet/Execution/MiddlewareSteps.cs +++ b/CommandDotNet/Execution/MiddlewareSteps.cs @@ -58,6 +58,8 @@ public static class Help public static MiddlewareStep PrintHelpOnExit { get; } = DependencyResolver.BeginScope + 1000; } + public static MiddlewareStep ReplSession { get; } = Help.CheckIfShouldShowHelp - 2000; + public static MiddlewareStep PipedInput { get; } = new(MiddlewareStages.PostParseInputPreBindValues, 0); diff --git a/CommandDotNet/Extensions/ObjectExtensions.cs b/CommandDotNet/Extensions/ObjectExtensions.cs index eb517b7dd..ca194a63e 100644 --- a/CommandDotNet/Extensions/ObjectExtensions.cs +++ b/CommandDotNet/Extensions/ObjectExtensions.cs @@ -77,6 +77,12 @@ public static string ToStringFromPublicProperties(this object item, Indent? inde : value?.ToString(); } + internal static T CloneWithPublicProperties(this T original, bool recurse = true) + where T: class + { + return (T) ((object)original).CloneWithPublicProperties(recurse); + } + internal static object CloneWithPublicProperties(this object original, bool recurse = true) { if (original == null) diff --git a/CommandDotNet/Repl/ReplConfig.cs b/CommandDotNet/Repl/ReplConfig.cs new file mode 100644 index 000000000..a46bd85b7 --- /dev/null +++ b/CommandDotNet/Repl/ReplConfig.cs @@ -0,0 +1,68 @@ +using System; +using CommandDotNet.Builders; + +namespace CommandDotNet.Repl +{ + public class ReplConfig + { + // TEST: + // Default ReplConfig + // - uses ReplOptionInfo.Default + // - Default PromptTextCallback + // - Default SessionInitMessageCallback + // - uses Config.AppName ?? UsageAppName ?? AppInfo.FileName + // - includes AppInfo.Version + // - includes DefaultSessionHelp + // - Default SessionHelpMessageCallback + // - Default ReadLine is ctx.Console.In.ReadLine + // - cannot set to null: ReadLine, PromptTextCallback, SessionInitMessageCallback, SessionHelpMessageCallback + // - can set to null: ReplOptionInfo, AppName + + public string? AppName { get; set; } + + private Func? _readLine; + public Func ReadLine + { + get => _readLine ?? (ctx => ctx.Console.In.ReadLine()); + set => _readLine = value ?? throw new ArgumentNullException(nameof(value)); + } + + private Func? _promptTextCallback; + public Func PromptTextCallback + { + get => _promptTextCallback ?? (ctx => ">>> "); + set => _promptTextCallback = value ?? throw new ArgumentNullException(nameof(value)); + } + + private Func? _sessionInitMessage; + public Func SessionInitMessageCallback + { + get => _sessionInitMessage ?? DefaultSessionInit; + set => _sessionInitMessage = value ?? throw new ArgumentNullException(nameof(value)); + } + + private Func? _sessionHelpMessage; + public Func SessionHelpMessageCallback + { + get => _sessionHelpMessage ?? DefaultSessionHelp; + set => _sessionHelpMessage = value ?? throw new ArgumentNullException(nameof(value)); + } + + private string DefaultSessionInit(CommandContext context) + { + var appInfo = AppInfo.Instance; + var appName = AppName + ?? context.AppConfig.AppSettings.Help.UsageAppName + ?? appInfo.FileName; + return @$"{appName} {appInfo.Version} +Type 'help' to see interactive options +{DefaultSessionHelp(context)}"; + } + + private string DefaultSessionHelp(CommandContext context) + { + return @"Type '-h' or '--help' for the list of commands +Type 'exit', 'quit' or 'Ctrl+C then Enter' to exit."; + } + } +} diff --git a/CommandDotNet/Repl/ReplMiddleware.cs b/CommandDotNet/Repl/ReplMiddleware.cs new file mode 100644 index 000000000..1040a5d68 --- /dev/null +++ b/CommandDotNet/Repl/ReplMiddleware.cs @@ -0,0 +1,127 @@ +using System; +using System.Threading.Tasks; +using CommandDotNet.Execution; +using CommandDotNet.Extensions; + +namespace CommandDotNet.Repl +{ + public static class ReplMiddleware + { + public static AppRunner UseRepl(this AppRunner appRunner, + ReplConfig? replConfig = null, + ReplOptionInfo? replOptionInfoForRootCommand = null) + { + /* + * TEST: + * - ReplSession with parameter resolver + * - + * + * - when using option + * - show option only + * - for root command + * - while not in a session + */ + + replConfig ??= new ReplConfig(); + + return appRunner.Configure(c => + { + var config = new Config(appRunner, replConfig); + c.Services.Add(config); + + c.UseParameterResolver(ctx => new ReplSession(appRunner, replConfig.CloneWithPublicProperties(), ctx)); + + if (replOptionInfoForRootCommand is not null) + { + c.UseMiddleware(ReplSession, MiddlewareSteps.ReplSession); + + var option = new Option(replOptionInfoForRootCommand.LongName, replOptionInfoForRootCommand.ShortName, TypeInfo.Flag, ArgumentArity.Zero) + { + Description = replOptionInfoForRootCommand.Description + }; + config.Option = option; + + c.BuildEvents.OnCommandCreated += args => + { + var builder = args.CommandBuilder; + + // do not include option if already in a session + var command = builder.Command; + if (!config.InSession && command.IsRootCommand()) + { + if (command.IsExecutable) + { + throw new InvalidConfigurationException($"Root command {command.Name} has been defined as executable using [DefaultCommand] on method {command.DefinitionSource}. This is not suitable for hosting an interactive session. " + + "Either: A) Do not use this method as the default for the root command " + + $"or B) do not define {nameof(replOptionInfoForRootCommand)} and define a " + + $"command with a parameter of type {nameof(ReplSession)} to initiate the session."); + } + builder.AddArgument(option); + } + }; + } + }); + } + + private class Config + { + public AppRunner AppRunner { get; } + public ReplConfig ReplConfig { get; } + public bool InSession { get; set; } + public Option? Option { get; set; } + + public Config(AppRunner appRunner, ReplConfig replConfig) + { + AppRunner = appRunner ?? throw new ArgumentNullException(nameof(appRunner)); + ReplConfig = replConfig ?? throw new ArgumentNullException(nameof(replConfig)); + } + } + + private static Task ReplSession(CommandContext ctx, ExecutionDelegate next) + { + + /* Test: + * - session not entered when + * - ParseError + * - Help requested + * - already in session + * - option is { } but not provided + * - option is null & cmd is root and not executable + * + * - should we check if option is specified but other options also provided? + * - No! The app may have provided other options as a config for the session, + * available via ReplSession.SessionContext + */ + var parseResult = ctx.ParseResult!; + var cmd = parseResult.TargetCommand; + + if (parseResult.ParseError is not null + || parseResult.HelpWasRequested()) + { + return next(ctx); + } + + var config = ctx.AppConfig.Services.GetOrThrow(); + if (config.InSession) + { + return next(ctx); + } + + var option = config.Option; + + bool ReplSessionWasRequested() + { + return cmd.HasInputValues(option.Name); + } + + if (option is not null && cmd.IsRootCommand() && ReplSessionWasRequested()) + { + config.InSession = true; + new ReplSession(config.AppRunner, config.ReplConfig.CloneWithPublicProperties(), ctx).Start(); + return ExitCodes.Success; + } + + return next(ctx); + } + } +} \ No newline at end of file diff --git a/CommandDotNet/Repl/ReplOptionInfo.cs b/CommandDotNet/Repl/ReplOptionInfo.cs new file mode 100644 index 000000000..14b3558d3 --- /dev/null +++ b/CommandDotNet/Repl/ReplOptionInfo.cs @@ -0,0 +1,24 @@ +namespace CommandDotNet.Repl +{ + public class ReplOptionInfo + { + public static readonly ReplOptionInfo Default = new ( + "interactive", 'i', "enter an interactive session"); + + public string? LongName { get; } + public char? ShortName { get; } + public string? Description { get; } + + public ReplOptionInfo(string? longName = null, char? shortName = null, string? description = null) + { + LongName = longName; + ShortName = shortName; + Description = description; + + if (longName is null && shortName is null) + { + throw new InvalidConfigurationException($"must define either {longName} or {shortName} or both"); + } + } + } +} \ No newline at end of file diff --git a/CommandDotNet.Example/InteractiveSession.cs b/CommandDotNet/Repl/ReplSession.cs similarity index 50% rename from CommandDotNet.Example/InteractiveSession.cs rename to CommandDotNet/Repl/ReplSession.cs index 526bfa5fb..20ba33b81 100644 --- a/CommandDotNet.Example/InteractiveSession.cs +++ b/CommandDotNet/Repl/ReplSession.cs @@ -1,27 +1,54 @@ using System; using System.Linq; -using CommandDotNet.Builders; using CommandDotNet.Tokens; -namespace CommandDotNet.Example +namespace CommandDotNet.Repl { - public class InteractiveSession + public class ReplSession { + /* Test: + * SessionContext contains parent + * - options provided during session creation are available + * + * - CtrlC + * - if command running in session + * - cancels command + * - else closes session + * - prints session init message + * - exit & quit closes session + * - help shows session help + * - -h & --help show command help + * - can run commands and return to session + * + * - to support nested sessions + * - ReplSession via parameter resolver + * - ReplConfig can be changed and doesn't change the parent. i.e. different prompt text + * + * - ReplSession.Start works when called within an async method + */ + private readonly AppRunner _appRunner; - private readonly string _appName; - private readonly CommandContext _context; + private ReplConfig _replConfig; + + public ReplConfig ReplConfig + { + get => _replConfig; + set => _replConfig = value ?? throw new ArgumentNullException(nameof(value)); + } + + public CommandContext SessionContext { get; } - public InteractiveSession(AppRunner appRunner, string appName, CommandContext context) + public ReplSession(AppRunner appRunner, ReplConfig replConfig, CommandContext sessionContext) { - _appRunner = appRunner; - _appName = appName; - _context = context; + _appRunner = appRunner ?? throw new ArgumentNullException(nameof(appRunner)); + _replConfig = replConfig ?? throw new ArgumentNullException(nameof(replConfig)); + SessionContext = sessionContext ?? throw new ArgumentNullException(nameof(sessionContext)); } public void Start() { - var console = _context.Console; - var cancellationToken = _context.CancellationToken; + var console = SessionContext.Console; + var cancellationToken = SessionContext.CancellationToken; bool pressedCtrlC = false; Console.CancelKeyPress += (sender, args) => @@ -29,7 +56,10 @@ public void Start() pressedCtrlC = true; }; - PrintSessionInit(); + var sessionInitMessage = ReplConfig.SessionInitMessageCallback(SessionContext); + var sessionHelpMessage = ReplConfig.SessionHelpMessageCallback(SessionContext); + + console.WriteLine(sessionInitMessage); bool pendingNewLine = false; void Write(string? value = null) @@ -55,8 +85,8 @@ void EnsureNewLine() while (!cancellationToken.IsCancellationRequested) { EnsureNewLine(); - Write(">>>"); - var input = console.In.ReadLine(); + Write(ReplConfig.PromptTextCallback(SessionContext)); + var input = ReplConfig.ReadLine!(SessionContext); if (input is null || pressedCtrlC) { pressedCtrlC = false; @@ -79,7 +109,7 @@ void EnsureNewLine() case "quit": return; case "help": - PrintSessionHelp(); + console.WriteLine(sessionHelpMessage); continue; } if (singleArg == Environment.NewLine) @@ -93,22 +123,5 @@ void EnsureNewLine() } EnsureNewLine(); } - - private void PrintSessionInit() - { - var appInfo = AppInfo.Instance; - var console = _context.Console; - console.WriteLine($"{_appName} {appInfo.Version}"); - console.WriteLine("Type 'help' to see interactive options"); - console.WriteLine("Type '-h' or '--help' to options for commands"); - console.WriteLine("Type 'exit', 'quit' or 'Ctrl+C' to exit."); - } - - private void PrintSessionHelp() - { - var console = _context.Console; - console.WriteLine("Type '-h' or '--help' to options for commands"); - console.WriteLine("Type 'exit', 'quit' or 'Ctrl+C' to exit."); - } } } \ No newline at end of file