From f1911dc998550738cc90e1c200cef8dcdd09caec Mon Sep 17 00:00:00 2001 From: Tom Laird-McConnell Date: Thu, 10 Sep 2026 12:58:37 -0700 Subject: [PATCH 1/2] Add Exec() and ExecAsync(): run a program attached to the console Run() always captures, which is the inverse of what a shell does. In bash, `ssh host` or `vim` takes over the terminal and you opt into capture with $(...). CShell had no way to express the default case, so anything that owns the terminal - ssh, vim, git rebase -i, gh auth login, an agent CLI - either hung on a stdin pipe nothing would write to, or drew a full screen UI into a pipe where nobody could see it. Run()'s own remarks already described the trap; this gives it a way out. Exec() returns Task rather than a Command because there is genuinely nothing to expose: with no redirection, StandardOutput, PipeTo and RedirectTo would all have to throw. A separate method whose return type says so beats a Command that is half unusable. ExecOptions is likewise smaller than Shell.Options - an attached process has nothing to redirect, pipe or encode - and keeps working directory, environment, cancellation, and a StartInfo escape hatch. ConsoleState restores the terminal afterwards. A full screen program turns off echo and line input on the way in and restores them on the way out, unless it is killed - leaving the console silently swallowing keystrokes and the user typing a command they cannot see to fix it. Windows uses GetConsoleMode/SetConsoleMode; Unix shells out to `stty -g`, which avoids a pile of termios interop for the one thing needed. Since Exec exists to run exactly that sort of program, and to be interrupted, the library is the right place for it rather than every caller. Verified against a real caller: Airlock's interactive ssh hand-off now goes through ExecAsync, and a full session including a remote `exit 42` comes back intact. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SQo1wkkf7nu2n1BZN3Uozm --- Tests/CShell.Tests/Exec.Tests.cs | 168 +++++++++++++++++++++++++++++++ src/CShell.cs | 138 ++++++++++++++++++++++++- src/ConsoleState.cs | 138 +++++++++++++++++++++++++ src/ExecOptions.cs | 83 +++++++++++++++ src/Globals.cs | 57 ++++++++++- 5 files changed, 582 insertions(+), 2 deletions(-) create mode 100644 Tests/CShell.Tests/Exec.Tests.cs create mode 100644 src/ConsoleState.cs create mode 100644 src/ExecOptions.cs diff --git a/Tests/CShell.Tests/Exec.Tests.cs b/Tests/CShell.Tests/Exec.Tests.cs new file mode 100644 index 0000000..f413e56 --- /dev/null +++ b/Tests/CShell.Tests/Exec.Tests.cs @@ -0,0 +1,168 @@ +using CShellNet; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace CShellLibTests +{ + /// + /// Exec() attaches a process to this console instead of capturing it, so there is no output to + /// assert on. These check the things that are left: the exit code, where it ran, what it + /// inherited, and that cancelling it actually stops it. Anything the process should "say" is + /// written to a file by the process itself. + /// + [TestClass] + public class ExecTests + { + private static bool IsWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + private static string ShellExe => IsWindows ? "cmd" : "bash"; + + private static string ShellFlag => IsWindows ? "/c" : "-c"; + + private string tempFolder; + + [TestInitialize] + public void Init() + { + this.tempFolder = Path.Combine(Path.GetTempPath(), "cshell-exec-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(this.tempFolder); + } + + [TestCleanup] + public void Cleanup() + { + try + { + Directory.Delete(this.tempFolder, true); + } + catch + { + } + } + + [TestMethod] + public void Exec_ReturnsTheExitCode() + { + var shell = new CShell() { Echo = false }; + + Assert.AreEqual(0, shell.Exec(ShellExe, ShellFlag, "exit 0")); + Assert.AreEqual(42, shell.Exec(ShellExe, ShellFlag, "exit 42")); + } + + [TestMethod] + public async Task ExecAsync_ReturnsTheExitCode() + { + var shell = new CShell() { Echo = false }; + + Assert.AreEqual(7, await shell.ExecAsync(ShellExe, ShellFlag, "exit 7")); + } + + [TestMethod] + public async Task Exec_RunsInTheShellsCurrentFolder() + { + var shell = new CShell(this.tempFolder) { Echo = false }; + var marker = Path.Combine(this.tempFolder, "cwd.txt"); + + // The process writes the file itself, since nothing is captured. + await shell.ExecAsync(ShellExe, ShellFlag, IsWindows ? "cd > cwd.txt" : "pwd > cwd.txt"); + + Assert.IsTrue(File.Exists(marker), "the process should have run in the shell's folder"); + StringAssert.Contains(File.ReadAllText(marker).Trim(), Path.GetFileName(this.tempFolder)); + } + + [TestMethod] + public async Task Exec_WorkingDirectoryOptionWins() + { + var shell = new CShell() { Echo = false }; + + await shell.ExecAsync( + opt => opt.WorkingDirectory(this.tempFolder), + ShellExe, + ShellFlag, + IsWindows ? "cd > where.txt" : "pwd > where.txt"); + + Assert.IsTrue(File.Exists(Path.Combine(this.tempFolder, "where.txt"))); + } + + [TestMethod] + public async Task Exec_PassesEnvironmentVariables() + { + var shell = new CShell(this.tempFolder) { Echo = false }; + + await shell.ExecAsync( + opt => opt.EnvironmentVariable("CSHELL_EXEC_TEST", "hello"), + ShellExe, + ShellFlag, + IsWindows ? "echo %CSHELL_EXEC_TEST% > env.txt" : "echo $CSHELL_EXEC_TEST > env.txt"); + + var written = File.ReadAllText(Path.Combine(this.tempFolder, "env.txt")).Trim(); + + Assert.AreEqual("hello", written); + } + + [TestMethod] + public async Task Exec_ArgumentsWithSpacesStayOneArgument() + { + var shell = new CShell(this.tempFolder) { Echo = false }; + + await shell.ExecAsync( + ShellExe, + ShellFlag, + IsWindows ? "echo a b c > spaces.txt" : "echo 'a b c' > spaces.txt"); + + Assert.AreEqual("a b c", File.ReadAllText(Path.Combine(this.tempFolder, "spaces.txt")).Trim()); + } + + [TestMethod] + public async Task Exec_CancellationStopsTheProcess() + { + var shell = new CShell() { Echo = false }; + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + + var sleep = IsWindows ? "ping -n 30 127.0.0.1 > nul" : "sleep 30"; + + var cancelled = false; + try + { + await shell.ExecAsync(opt => opt.CancellationToken(cts.Token), ShellExe, ShellFlag, sleep); + } + catch (OperationCanceledException) + { + cancelled = true; + } + + Assert.IsTrue(cancelled, "cancelling the token should stop the process"); + } + + [TestMethod] + public void Exec_UnknownProgramThrows() + { + var shell = new CShell() { Echo = false }; + + var threw = false; + try + { + shell.Exec("this-program-does-not-exist-cshell-test"); + } + catch (System.ComponentModel.Win32Exception) + { + threw = true; + } + + Assert.IsTrue(threw, "starting a program that does not exist should throw"); + } + + [TestMethod] + public async Task ExecGlobal_ReturnsTheExitCode() + { + CShellNet.Globals.ResetShell(); + CShellNet.Globals.Echo = false; + + Assert.AreEqual(3, await CShellNet.Globals.ExecAsync(ShellExe, ShellFlag, "exit 3")); + } + } +} diff --git a/src/CShell.cs b/src/CShell.cs index 95a57a5..9b61756 100644 --- a/src/CShell.cs +++ b/src/CShell.cs @@ -4,6 +4,9 @@ using System.IO; using System.Linq; using System.Runtime.InteropServices; +using System.Threading.Tasks; +using System.Threading; +using System.Diagnostics; namespace CShellNet { @@ -155,7 +158,140 @@ public Command Run(Action options, string executable, params Obje } /// - /// Start a process detached + /// Run a process attached to this console and wait for it, returning its exit code. + /// + /// + /// This is what a shell does by default. In bash, `ssh host` or `vim` takes over the + /// terminal, and you opt into capture with $(...). Run() is the other way round -- it + /// always captures -- so Exec() is the one to reach for whenever the program needs the + /// terminal rather than a pipe: + /// + /// var exitCode = Exec("ssh", "user@host"); + /// Exec("gh", "auth", "login"); + /// Exec("git", "rebase", "-i", "HEAD~3"); + /// + /// Nothing is redirected, so the program owns stdin, stdout and stderr. That is what lets a + /// full screen UI draw, arrow keys work, Ctrl+C reach the program rather than this one, and + /// the window size follow the terminal. It is also why there is no output to return: use + /// Run() when you want to read what it printed, and Exec() when the user needs to see and + /// answer it. + /// + /// The terminal's settings are put back afterwards, so a program killed before it can tidy + /// up does not leave the console with echo turned off. + /// + /// program to run + /// arguments to pass to it + /// the process's exit code + public int Exec(string executable, params Object[] arguments) + { + return ExecAsync((opt) => { }, executable, arguments).GetAwaiter().GetResult(); + } + + /// + /// Run a process attached to this console and wait for it, returning its exit code. + /// + /// options function + /// program to run + /// arguments to pass to it + /// the process's exit code + public int Exec(Action options, string executable, params Object[] arguments) + { + return ExecAsync(options, executable, arguments).GetAwaiter().GetResult(); + } + + /// + /// Run a process attached to this console, returning its exit code when it finishes. + /// + /// program to run + /// arguments to pass to it + /// the process's exit code + public Task ExecAsync(string executable, params Object[] arguments) + { + return ExecAsync((opt) => { }, executable, arguments); + } + + /// + /// Run a process attached to this console, returning its exit code when it finishes. + /// + /// options function + /// program to run + /// arguments to pass to it + /// the process's exit code + public async Task ExecAsync(Action options, string executable, params Object[] arguments) + { + if (this.Echo) + { + Console.WriteLine($"{executable} {String.Join(" ", arguments)}"); + } + + var execOptions = new ExecOptions(); + options?.Invoke(execOptions); + + var startInfo = new ProcessStartInfo(executable) + { + // Not UseShellExecute: that would launch a separate window instead of using this one. + UseShellExecute = false, + + // The whole point. Inheriting the console is what a pipe cannot give you. + RedirectStandardInput = false, + RedirectStandardOutput = false, + RedirectStandardError = false, + + WorkingDirectory = execOptions.workingDirectory ?? this.CurrentFolder.FullName, + }; + + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument?.ToString() ?? String.Empty); + } + + foreach (var variable in execOptions.environment) + { + startInfo.Environment[variable.Key] = variable.Value; + } + + execOptions.startInfo?.Invoke(startInfo); + + using (var consoleState = ConsoleState.Capture()) + using (var process = Process.Start(startInfo)) + { + if (process == null) + { + throw new InvalidOperationException($"Unable to start '{executable}'."); + } + + try + { + await process.WaitForExitAsync(execOptions.cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + TryKill(process); + throw; + } + + return process.ExitCode; + } + } + + private static void TryKill(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch + { + // It exited between the check and the kill, or we are not allowed to. Either way + // the cancellation is what the caller cares about. + } + } + + /// + /// Start a process detached /// /// /// diff --git a/src/ConsoleState.cs b/src/ConsoleState.cs new file mode 100644 index 0000000..fd9eb09 --- /dev/null +++ b/src/ConsoleState.cs @@ -0,0 +1,138 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace CShellNet +{ + /// + /// Remembers how the terminal was set up, so a child process that dies badly cannot leave it + /// unusable. + /// + /// + /// A full screen program turns off echo and line input on the way in and restores them on the + /// way out -- unless it is killed, in which case the terminal is left silently swallowing + /// keystrokes and the user has to type a command they cannot see to fix it. Since Exec exists + /// to run exactly that sort of program, and to be interrupted, it puts the settings back. + /// + internal sealed class ConsoleState : IDisposable + { + private const int STD_INPUT_HANDLE = -10; + private const int STD_OUTPUT_HANDLE = -11; + + private readonly IntPtr stdIn; + private readonly IntPtr stdOut; + private readonly uint? inMode; + private readonly uint? outMode; + private readonly string sttyState; + + private ConsoleState(IntPtr stdIn, IntPtr stdOut, uint? inMode, uint? outMode, string sttyState) + { + this.stdIn = stdIn; + this.stdOut = stdOut; + this.inMode = inMode; + this.outMode = outMode; + this.sttyState = sttyState; + } + + /// + /// Snapshot the current terminal settings. Returns an object that restores them on Dispose. + /// + internal static ConsoleState Capture() + { + // No console attached means nothing to protect: output is going to a pipe or a file. + if (Console.IsInputRedirected && Console.IsOutputRedirected) + { + return new ConsoleState(IntPtr.Zero, IntPtr.Zero, null, null, null); + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var stdIn = GetStdHandle(STD_INPUT_HANDLE); + var stdOut = GetStdHandle(STD_OUTPUT_HANDLE); + + return new ConsoleState( + stdIn, + stdOut, + GetConsoleMode(stdIn, out uint modeIn) ? modeIn : (uint?)null, + GetConsoleMode(stdOut, out uint modeOut) ? modeOut : (uint?)null, + null); + } + + // `stty -g` prints the whole terminal state in a form stty itself can restore, which + // avoids a pile of termios interop for the one thing we actually need. + return new ConsoleState(IntPtr.Zero, IntPtr.Zero, null, null, ReadSttyState()); + } + + public void Dispose() + { + try + { + if (this.inMode.HasValue) + { + SetConsoleMode(this.stdIn, this.inMode.Value); + } + + if (this.outMode.HasValue) + { + SetConsoleMode(this.stdOut, this.outMode.Value); + } + + if (this.sttyState != null) + { + RunStty(this.sttyState); + } + } + catch + { + // Restoring is a courtesy. Failing at it must not replace whatever the caller was + // actually doing with an exception about terminal modes. + } + } + + private static string ReadSttyState() + { + try + { + using (var process = Process.Start(new ProcessStartInfo("stty", "-g") + { + RedirectStandardOutput = true, + UseShellExecute = false, + })) + { + var state = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(); + + return process.ExitCode == 0 && state.Length > 0 ? state : null; + } + } + catch + { + return null; + } + } + + private static void RunStty(string state) + { + using (var process = Process.Start(new ProcessStartInfo("stty", state) + { + UseShellExecute = false, + })) + { + process.WaitForExit(); + } + } + + // Plain DllImport rather than LibraryImport: every parameter is blittable, so the source + // generator buys nothing and would force AllowUnsafeBlocks on the whole assembly. + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr GetStdHandle(int nStdHandle); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetConsoleMode(IntPtr hConsoleHandle, uint dwMode); + } +} diff --git a/src/ExecOptions.cs b/src/ExecOptions.cs new file mode 100644 index 0000000..4588aea --- /dev/null +++ b/src/ExecOptions.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; + +namespace CShellNet +{ + /// + /// Options for . + /// + /// + /// Deliberately smaller than Shell.Options. An attached process owns the console, so + /// there is nothing to redirect, pipe, or encode, and no output to throw on. What is left is + /// where it runs, what it inherits, and how to stop it. + /// + public class ExecOptions + { + internal string workingDirectory; + internal System.Threading.CancellationToken cancellationToken = System.Threading.CancellationToken.None; + internal Dictionary environment = new Dictionary(); + internal Action startInfo; + + /// + /// Run in this folder instead of the shell's current folder. + /// + public ExecOptions WorkingDirectory(string path) + { + this.workingDirectory = path; + return this; + } + + /// + /// Set an environment variable for the process, on top of the ones it inherits. + /// + public ExecOptions EnvironmentVariable(string name, string value) + { + this.environment[name] = value; + return this; + } + + /// + /// Set several environment variables for the process. + /// + public ExecOptions EnvironmentVariables(IEnumerable> variables) + { + if (variables != null) + { + foreach (var variable in variables) + { + this.environment[variable.Key] = variable.Value; + } + } + + return this; + } + + /// + /// Kill the process when the token is cancelled. + /// + /// + /// Note that Ctrl+C reaches an attached process directly, because it shares this console. + /// A token is for stopping it from somewhere else in the program. + /// + public ExecOptions CancellationToken(System.Threading.CancellationToken cancellationToken) + { + this.cancellationToken = cancellationToken; + return this; + } + + /// + /// Adjust the ProcessStartInfo directly, for anything not covered here. + /// + /// + /// Turning redirection back on here defeats the purpose of Exec and will leave the process + /// without a console. Use if you want its output. + /// + public ExecOptions StartInfo(Action startInfo) + { + this.startInfo = startInfo; + return this; + } + } +} diff --git a/src/Globals.cs b/src/Globals.cs index 9adfbc8..e28efe6 100644 --- a/src/Globals.cs +++ b/src/Globals.cs @@ -1,4 +1,5 @@ using Medallion.Shell; +using System.Threading.Tasks; using System; using System.Collections.Generic; using System.IO; @@ -83,7 +84,61 @@ public static Command Run(Action options, string executable, para => _shell.Run(options, executable, arguments); /// - /// Start a process detached + /// Run a process attached to this console and wait for it, returning its exit code. + /// + /// + /// This is what a shell does by default. In bash, `ssh host` or `vim` takes over the + /// terminal, and you opt into capture with $(...). Run() is the other way round -- it + /// always captures -- so Exec() is the one to reach for whenever the program needs the + /// terminal rather than a pipe: + /// + /// var exitCode = Exec("ssh", "user@host"); + /// Exec("gh", "auth", "login"); + /// Exec("git", "rebase", "-i", "HEAD~3"); + /// + /// Nothing is redirected, so the program owns stdin, stdout and stderr. That is what lets a + /// full screen UI draw, arrow keys work, Ctrl+C reach the program rather than this one, and + /// the window size follow the terminal. It is also why there is no output to return: use + /// Run() when you want to read what it printed, and Exec() when the user needs to see and + /// answer it. + /// + /// program to run + /// arguments to pass to it + /// the process's exit code + public static int Exec(string executable, params Object[] arguments) + => _shell.Exec(executable, arguments); + + /// + /// Run a process attached to this console and wait for it, returning its exit code. + /// + /// options function + /// program to run + /// arguments to pass to it + /// the process's exit code + public static int Exec(Action options, string executable, params Object[] arguments) + => _shell.Exec(options, executable, arguments); + + /// + /// Run a process attached to this console, returning its exit code when it finishes. + /// + /// program to run + /// arguments to pass to it + /// the process's exit code + public static Task ExecAsync(string executable, params Object[] arguments) + => _shell.ExecAsync(executable, arguments); + + /// + /// Run a process attached to this console, returning its exit code when it finishes. + /// + /// options function + /// program to run + /// arguments to pass to it + /// the process's exit code + public static Task ExecAsync(Action options, string executable, params Object[] arguments) + => _shell.ExecAsync(options, executable, arguments); + + /// + /// Start a process detached /// /// /// From 86306a8cf3835e7abd4583672c4a93b1409a5203 Mon Sep 17 00:00:00 2001 From: Tom Laird-McConnell Date: Thu, 10 Sep 2026 14:19:31 -0700 Subject: [PATCH 2/2] Cli: nested commands, dotnet style Cli's stated ceiling was "no subcommands", so a script with verbs had to take the verb as a positional and hand-roll a second parse per branch -- losing generated help, losing the unknown-switch error, and putting every verb's switches in one undifferentiated list. Command(name, help, c => ...) gives a verb a command line of its own, and because the thing handed to the lambda is a Cli, commands nest: `svc nuget push` reads the way `dotnet nuget push` does. Every level has its own help, its own unknown-switch error, and its own name in the message. The lambda runs only for the command that was actually typed. That is what makes typed out-variables safe inside it -- nothing binds for a command nobody asked for -- and it lets --help list the commands without building any of them. Run()/RunAsync() declare what a command does, beside the declarations it uses, because an out variable cannot be read after its lambda returns. RunAsync is a separate name rather than a Run overload so that `Run(async () => ...)` cannot bind to the void one as an async void that nobody awaits; it pairs with ParseAsync(), and Parse() throws and says so. What a handler returns lands on CliResult.ExitCode without exiting the process; ShouldExit still tells that apart from a line that could not be read. The parse boundary is the first token that is not a switch, which is the same rule Rest() already uses, and it is only unambiguous because an option's value attaches. Switches at a command level are globals, readable from the command's own result; a typed one must be declared after the first Command(), since before then the parser does not know the line splits. One typed after the command word is refused with the fix rather than a dead end: svc start: '--verbose' is a global switch -- write it before the command: 'svc --verbose start'. CliResult gains Command, CommandPath, Parent and HandlerRan, and its Switch, Option and WhatIf walk the parent chain. Nothing changes for a Cli that declares no commands: all 110 existing Cli tests pass untouched, alongside 47 new ones. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Vga8G5aDrA2qYn8BSCgTsd --- README.md | 110 +++++- Tests/CShell.Tests/Cli.Tests.cs | 578 +++++++++++++++++++++++++++ src/CShell.csproj | 4 +- src/Cli.cs | 664 ++++++++++++++++++++++++++++++-- src/CliResult.cs | 152 ++++++-- 5 files changed, 1451 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 9f4a822..d67da01 100644 --- a/README.md +++ b/README.md @@ -176,17 +176,79 @@ Cli.For(Args) | **Switch(out value, help, aliases)** | a switch that is on or off; aliases go in the name after a pipe: `"whatif\|n"` | | **Option(out value, help)** | a switch carrying a value, written attached: `-out:file` | | **WhatIf(out value)** | declares the conventional dry run: `-whatif`, also `--dry-run` or `-n` | -| **Rest(out value, help)** | a tail collecting everything left, verbatim | +| **Rest(name, help)** | a tail collecting everything left, verbatim | | **Description(text)** | the paragraph shown above the usage line | | **Example(commandLine, help)** | a worked example for the bottom of the help | +| **Command(name, help, declare)** | a verb with a command line of its own; nests to any depth | +| **Run(handler)** / **RunAsync(handler)** | what a command does when it is the one that was typed | | **Program(name)** | override the name in the usage line | | **UsageWhenEmpty()** | print the usage when run with no arguments at all | | **Parse()** | read the command line; prints and exits if it was not valid or help was asked for | -| **TryParse()** | the same, reported through ShouldExit rather than acted on | +| **ParseAsync()** | the same, awaiting a command that declared RunAsync() | +| **TryParse()** / **TryParseAsync()** | the same, reported through ShouldExit rather than acted on | Parse and TryParse return a **CliResult** object that gives access to the values. -* No subcommands, repeated options, or separated values. For more complex cli support use something like [System.CommandLine](https://www.nuget.org/packages/System.CommandLine) directly. +#### Commands + +A script with verbs declares a **Command** for each, and each one declares its own line the same +three ways. Commands nest, so `svc nuget push` reads the way `dotnet nuget push` does: + +```CSharp +await Cli.For(Args) + .Description("Manages the service.") + .Command("start", "start the service", c => + { + c.Argument(out string file, "the file to start"); + c.Switch(out bool force, "start it even if one is already running"); + c.Run(() => Start(file, force)); + }) + .Command("nuget", "work with the feed", c => c + .Command("push", "push a package", p => + { + p.Argument(out string package, "the .nupkg to push"); + p.RunAsync(async () => await Push(package)); + })) + .Switch(out bool verbose, "say what is happening") + .ParseAsync(); +``` + +Every level has its own generated help (`svc --help`, `svc nuget push --help`), its own +unknown-switch error, and its own name in the message -- `svc nuget push: missing .` +Only the command that was actually typed is ever built, so nothing binds for a command nobody +asked for. + +A script that would rather switch on the verb itself declares the values with the (name, help) +overloads and reads them back: + +```CSharp +var cmd = Cli.For(Args) + .Command("start", "start it", c => c.Argument("file", "the file").Switch("force", "start anyway")) + .Command("stop", "stop it", c => c.Option("timeout", "seconds to wait")) + .Parse(); + +if (cmd.Command == "start") + Start(cmd.Argument("file"), cmd.Switch("force")); +``` + +* **Run(handler)** is the only way to use a command's *typed* variables -- an `out` variable + belongs to the block it was declared in, so it cannot be read after the lambda returns. +* What a handler returns lands on **CliResult.ExitCode**, and Parse() returns rather than exiting: + `return Cli.For(Args)....Parse().ExitCode;`. `ShouldExit` still tells a handler's code apart + from a command line that could not be read. +* **RunAsync()** is a separate name rather than a Run() overload, because `Run(async () => ...)` + would bind to the void one as an async void that nobody awaits. It needs **ParseAsync()**; + Parse() throws and says so. +* Switches at a level with commands are **global** -- typed before the command word, + `svc --verbose start foo` -- and stay readable from the command's own result. A typed global + must be declared *after* the first Command(), because a typed declaration reads its value as it + runs and before then the parser does not know that the line splits. One typed after the command + word is refused with the fix: *'--verbose' is a global switch -- write it before the command.* +* A level with commands cannot also have positionals: the first bare word is the command. +* `cmd.Command` is the verb that was typed, `cmd.CommandPath` is the whole chain, and + `cmd.Parent` is the level above. + +* No repeated options or separated values. For more complex cli support use something like [System.CommandLine](https://www.nuget.org/packages/System.CommandLine) directly. ### Process Methods CShell is built using [MedallionShell](https://github.com/madelson/MedallionShell), which provides a great set of functionality for easily invoking @@ -197,11 +259,44 @@ to make it even easier to work with the output of processes. |------------------|--------------------------------------------------------------------------------------------------| | **ReadFile()/cat()/type()** | read a file and create a stream | | **echo(text/lines/stream)** | echo text,lines from memory to a stream | -| **Run(program, arg1, ..., argN)** | run a program directly with the given args (aka Process.Start(program, args) | +| **Run(program, arg1, ..., argN)** | run a program and CAPTURE its output, for reading and piping | +| **Exec()/ExecAsync(program, arg1, ..., argN)** | run a program ATTACHED to this console, returning its exit code | | **Start(program, arg1, ..., argN)** | run a DETACHED program directly with the given args (aka Process.Start(program, args)| | **Cmd(cmd)** | run the cmd inside a cmd.exe, allow you to execute shell commands (like dir /b *.* | | **Bash(bash)** | run the program in bash environment, allow you to execute bash shell commands (like ls -al * | +Three ways to run something, and the difference is where its input and output go: + +| | Output | Use it for | +|---|---|---| +| **Run()** | captured | anything you want to read, pipe, or parse | +| **Exec()** | your console | anything the user has to see and answer | +| **Start()**| its own window | anything you are not waiting for | + +`Exec()` is what a shell does by default. In bash, `ssh host` or `vim` takes over the terminal and +you opt into capture with `$(...)`; `Run()` is the other way round, so anything that needs the +terminal rather than a pipe wants `Exec()`: + +```CSharp +var exitCode = Exec("ssh", "user@host"); +Exec("gh", "auth", "login"); +Exec("git", "rebase", "-i", "HEAD~3"); + +await ExecAsync(opt => opt.WorkingDirectory(repo) + .EnvironmentVariable("EDITOR", "code --wait"), + "git", "commit"); +``` + +Nothing is redirected, so the program owns stdin, stdout and stderr. That is what lets a full screen +UI draw, arrow keys work, Ctrl+C reach the program rather than your script, and the window size +follow the terminal - none of which survive a pipe. It is also why there is no output to return: +reach for `Run()` when you want to read what a program printed, and `Exec()` when the user needs to +see and answer it. Passing a program that stops to ask a question to `Run()` hangs it forever on a +stdin pipe nothing will write to. + +Terminal settings are restored afterwards, so a program killed before it can tidy up does not leave +your console with echo switched off. + ```CSharp // Invoke multiple commands using fluent style var cmd1= await Run("cmd1", "args1") @@ -369,6 +464,13 @@ chmod +x example.csx ``` ## CHANGELOG +### v3.1.0 +* **Cli** now models nested commands, the way `dotnet build` and `dotnet nuget push` do + * `.Command(name, help, c => ...)` gives a verb a command line of its own, and commands nest to any depth + * `.Run(...)` / `.RunAsync(...)` declare what a command does; `ParseAsync()` awaits an async one + * every level has its own generated help and its own name in its errors; only the command that was typed is built + * switches at a command level are globals, readable from the command's result; `CliResult` gains Command, CommandPath, Parent and HandlerRan + ### v3.0.0 * Added **Cli**, a declarative command line parser with generated --help * **Cli** binds straight into typed variables: `.Option(out int queueLength, "how many to queue")` declares `--queue-length` and converts it diff --git a/Tests/CShell.Tests/Cli.Tests.cs b/Tests/CShell.Tests/Cli.Tests.cs index 247435a..1aea6a4 100644 --- a/Tests/CShell.Tests/Cli.Tests.cs +++ b/Tests/CShell.Tests/Cli.Tests.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading.Tasks; namespace CShellLibTests { @@ -1228,5 +1229,582 @@ public void Parse_AnEmptyCommandLineIsFineWhenNothingIsRequired() Assert.IsFalse(cmd.ShouldExit); Assert.IsFalse(cmd.Switch("whatif")); } + + // ------------------------------------------------------------------ commands + + [TestMethod] + public void Command_DispatchesToTheCommandThatWasTyped() + { + var ran = "nothing"; + + Given("start") + .Command("start", "start the service", c => c.Run(() => { ran = "start"; })) + .Command("stop", "stop the service", c => c.Run(() => { ran = "stop"; })) + .TryParse(); + + Assert.AreEqual("start", ran); + } + + [TestMethod] + public void Command_IsMatchedIgnoringCaseHyphensAndUnderscores() + { + foreach (var typed in new[] { "dry-run", "dryrun", "DRY_RUN", "Dry-Run" }) + { + Capture(); + var ran = false; + var cmd = Given(typed).Command("dry-run", "rehearse it", c => c.Run(() => { ran = true; })).TryParse(); + + Assert.IsFalse(cmd.ShouldExit, typed); + Assert.IsTrue(ran, typed); + } + } + + [TestMethod] + public void Command_AliasesAfterThePipeSelectTheSameCommand() + { + foreach (var typed in new[] { "remove", "rm" }) + { + Capture(); + var cmd = Given(typed).Command("remove|rm", "take one away", c => { }).TryParse(); + + Assert.IsFalse(cmd.ShouldExit, typed); + Assert.AreEqual("remove", cmd.Command, "the primary spelling, whichever alias was typed"); + } + } + + [TestMethod] + public void Command_ReadsBackOffTheResultForScriptsThatSwitchThemselves() + { + // The other half of the contract: a script that would rather not declare a handler + // reads which command was chosen and takes its values off the same result. + var cmd = Given("start", "foo.txt", "--force") + .Command("start", "start it", c => c + .Argument("file", "the file") + .Switch("force", "start anyway")) + .Command("stop", "stop it", c => c.Option("timeout", "seconds to wait")) + .TryParse(); + + Assert.AreEqual("start", cmd.Command); + Assert.AreEqual("foo.txt", cmd.Argument("file")); + Assert.IsTrue(cmd.Switch("force")); + } + + [TestMethod] + public void Command_OnlyTheMatchedCommandsDeclarationsAreEverRun() + { + // What makes typed variables safe inside a command: nothing binds for a command + // nobody asked for. + var built = new List(); + + Given("start") + .Command("start", "start it", c => { built.Add("start"); }) + .Command("stop", "stop it", c => { built.Add("stop"); }) + .TryParse(); + + CollectionAssert.AreEqual(new[] { "start" }, built.ToArray()); + } + + [TestMethod] + public void Command_NestsToAnyDepth() + { + string pushed = null; + + var cmd = Given("nuget", "push", "x.nupkg") + .Command("nuget", "work with the feed", c => c + .Command("push", "push a package", p => + { + p.Argument(out string package, "the .nupkg to push"); + p.Run(() => { pushed = package; }); + })) + .TryParse(); + + Assert.IsFalse(cmd.ShouldExit); + Assert.AreEqual("x.nupkg", pushed); + Assert.AreEqual("demo nuget push", cmd.ProgramName); + } + + [TestMethod] + public void Command_PathNamesEveryLevelThatWasTyped() + { + var cmd = Given("nuget", "push", "x.nupkg") + .Command("nuget", "work with the feed", c => c + .Command("push", "push a package", p => p.Argument("package", "the .nupkg"))) + .TryParse(); + + CollectionAssert.AreEqual(new[] { "nuget", "push" }, cmd.CommandPath.ToArray()); + Assert.AreEqual("push", cmd.Command); + } + + [TestMethod] + public void Command_TheResultIsTheLeafAndKnowsItsParent() + { + var cmd = Given("start", "foo.txt") + .Command("start", "start it", c => c.Argument("file", "the file")) + .TryParse(); + + Assert.AreEqual("demo start", cmd.ProgramName); + Assert.IsNotNull(cmd.Parent); + Assert.AreEqual("demo", cmd.Parent.ProgramName); + Assert.IsNull(cmd.Parent.Command); + } + + [TestMethod] + public void Command_TypedVariablesAreBoundFromTheTokensAfterTheCommandWord() + { + string seen = null; + var forced = false; + + Given("start", "foo.txt", "--force") + .Command("start", "start it", c => + { + c.Argument(out string file, "the file to start"); + c.Switch(out bool force, "start it anyway"); + c.Run(() => { seen = file; forced = force; }); + }) + .TryParse(); + + Assert.AreEqual("foo.txt", seen); + Assert.IsTrue(forced); + } + + [TestMethod] + public void Command_AMissingArgumentIsReportedAgainstTheCommandNotTheProgram() + { + var cmd = Given("start").Command("start", "start it", c => c.Argument("file", "the file")).TryParse(); + + Assert.IsTrue(cmd.ShouldExit); + StringAssert.Contains(this.Errors, "demo start: missing "); + StringAssert.Contains(this.Errors, "Try 'demo start --help'"); + } + + [TestMethod] + public void Command_ARestInsideACommandCollectsWhatFollowsIt() + { + var cmd = Given("exec", "cmd.exe", "/k", "dir") + .Command("exec", "run something", c => c + .Argument("program", "what to run") + .Rest("args", "passed through")) + .TryParse(); + + Assert.AreEqual("cmd.exe", cmd.Argument("program")); + CollectionAssert.AreEqual(new[] { "/k", "dir" }, cmd.Rest.ToArray()); + } + + [TestMethod] + public void Command_GlobalSwitchesBeforeTheCommandAreTheProgramsOwn() + { + var cmd = Given("--verbose", "start") + .Command("start", "start it", c => { }) + .Switch("verbose", "say what is happening") + .TryParse(); + + Assert.IsFalse(cmd.ShouldExit); + Assert.AreEqual("start", cmd.Command); + Assert.IsTrue(cmd.Switch("verbose"), "a global is readable from the command's own result"); + } + + [TestMethod] + public void Command_TheProgramsOptionsAndWhatIfAreReadableFromTheCommandsResult() + { + var cmd = Given("--source:https://nuget.org", "-whatif", "push") + .Command("push", "push a package", c => { }) + .Option("source", "the feed to use") + .WhatIf() + .TryParse(); + + Assert.AreEqual("https://nuget.org", cmd.Option("source")); + Assert.IsTrue(cmd.WhatIf); + } + + [TestMethod] + public void Command_ATypedGlobalBindsOnlyFromBeforeTheCommandWord() + { + // Declared after the first Command(), so the boundary is known when it reads. + var cli = Given("--verbose", "start", "foo.txt") + .Command("start", "start it", c => c.Argument("file", "the file")) + .Switch(out bool verbose, "say what is happening"); + + Assert.IsTrue(verbose); + Assert.IsFalse(cli.TryParse().ShouldExit); + } + + [TestMethod] + public void Command_AGlobalWrittenAfterTheCommandIsRejectedAndSaysWhereItGoes() + { + var cmd = Given("start", "--verbose") + .Command("start", "start it", c => { }) + .Switch("verbose", "say what is happening") + .TryParse(); + + Assert.IsTrue(cmd.ShouldExit); + StringAssert.Contains(this.Errors, "'--verbose' is a global switch"); + StringAssert.Contains(this.Errors, "'demo --verbose start'"); + } + + [TestMethod] + public void Command_AnUnknownGlobalIsReportedBeforeTheCommandIsResolved() + { + var built = false; + var cmd = Given("--nope", "start").Command("start", "start it", c => { built = true; }).TryParse(); + + Assert.IsTrue(cmd.ShouldExit); + StringAssert.Contains(this.Errors, "demo: unknown switch '--nope'"); + Assert.IsFalse(built, "once the globals were misread there is nothing to say about the rest"); + } + + [TestMethod] + public void Command_AnUnknownSwitchInsideACommandNamesTheCommandInTheError() + { + var cmd = Given("start", "--nope").Command("start", "start it", c => { }).TryParse(); + + Assert.IsTrue(cmd.ShouldExit); + StringAssert.Contains(this.Errors, "demo start: unknown switch '--nope'"); + } + + [TestMethod] + public void Command_ADoubleDashEndsTheGlobalsAndTheNextWordIsTheCommand() + { + var cmd = Given("--", "start").Command("start", "start it", c => { }).TryParse(); + + Assert.IsFalse(cmd.ShouldExit); + Assert.AreEqual("start", cmd.Command); + } + + [TestMethod] + public void Command_NoCommandGivenIsAnErrorThatListsTheCommands() + { + var cmd = Given() + .Command("start", "start it", c => { }) + .Command("stop", "stop it", c => { }) + .TryParse(); + + Assert.IsTrue(cmd.ShouldExit); + Assert.AreEqual(1, cmd.ExitCode); + StringAssert.Contains(this.Errors, "demo: no command given -- one of: start, stop."); + StringAssert.Contains(this.Errors, "Try 'demo --help' for the commands it takes."); + } + + [TestMethod] + public void Command_AnUnknownCommandIsAnErrorThatListsTheCommands() + { + var cmd = Given("strat").Command("start", "start it", c => { }).TryParse(); + + Assert.IsTrue(cmd.ShouldExit); + StringAssert.Contains(this.Errors, "demo: unknown command 'strat' -- expected one of: start."); + } + + [TestMethod] + public void Command_UsageWhenEmptyPrintsTheCommandListAndExitsZero() + { + var cmd = Given().UsageWhenEmpty().Command("start", "start it", c => { }).TryParse(); + + Assert.IsTrue(cmd.ShouldExit); + Assert.AreEqual(0, cmd.ExitCode); + StringAssert.Contains(this.Screen, "Commands:"); + Assert.AreEqual(String.Empty, this.Errors); + } + + [TestMethod] + public void Help_ListsCommandsWithTheirAliasesAndTeachesTheGrammar() + { + Given("--help") + .Description("Manages the service.") + .Command("start", "start the service", c => { }) + .Command("remove|rm", "take one away", c => { }) + .Switch("verbose", "say what is happening") + .TryParse(); + + StringAssert.Contains(this.Screen, "Manages the service."); + StringAssert.Contains(this.Screen, "Commands:"); + StringAssert.Contains(this.Screen, "start the service"); + StringAssert.Contains(this.Screen, "remove, rm"); + StringAssert.Contains(this.Screen, " ..."); + StringAssert.Contains(this.Screen, "See 'demo --help' for what a command takes."); + } + + [TestMethod] + public void Help_AfterTheCommandIsTheCommandsOwnHelp() + { + var cmd = Given("start", "--help") + .Command("start", "start it", c => c.Argument("file", "the file to start")) + .TryParse(); + + Assert.IsTrue(cmd.HelpRequested); + Assert.AreEqual(0, cmd.ExitCode); + Assert.AreEqual("demo start", cmd.ProgramName); + StringAssert.Contains(this.Screen, "demo start "); + StringAssert.Contains(this.Screen, "the file to start"); + } + + [TestMethod] + public void Help_BeforeTheCommandIsTheProgramsAndDoesNotBuildIt() + { + var built = false; + Given("--help", "start").Command("start", "start it", c => { built = true; }).TryParse(); + + StringAssert.Contains(this.Screen, "Commands:"); + Assert.IsFalse(built, "listing the commands does not build any of them"); + } + + [TestMethod] + public void Help_WinsOverAnUnknownCommand() + { + var cmd = Given("--help", "strat").Command("start", "start it", c => { }).TryParse(); + + Assert.IsTrue(cmd.HelpRequested); + Assert.AreEqual(0, cmd.ExitCode); + } + + [TestMethod] + public void Run_TheReturnValueBecomesTheExitCodeWithoutExitingTheProcess() + { + var cmd = Given("start").Command("start", "start it", c => c.Run(() => 3)).TryParse(); + + Assert.IsFalse(cmd.ShouldExit, "a command that ran is not a command line that could not be read"); + Assert.IsTrue(cmd.HandlerRan); + Assert.AreEqual(3, cmd.ExitCode); + } + + [TestMethod] + public void Run_AValueReturningMethodStillBindsToTheExitCodeOverload() + { + // Run(Action) and Run(Func) both accept `() => Three()`. If the void one won, + // the code would be silently dropped -- so this pins which overload C# picks. + var cmd = Given("start").Command("start", "start it", c => c.Run(() => Three())).TryParse(); + + Assert.AreEqual(3, cmd.ExitCode); + } + + private static int Three() + { + return 3; + } + + [TestMethod] + public void Run_ACleanLineWithNoHandlerLeavesTheExitCodeAtZero() + { + var cmd = Given("start").Command("start", "start it", c => { }).TryParse(); + + Assert.IsFalse(cmd.ShouldExit); + Assert.IsFalse(cmd.HandlerRan); + Assert.AreEqual(0, cmd.ExitCode); + } + + [TestMethod] + public void Run_TheHandlerDoesNotRunForABadCommandLine() + { + var ran = false; + + var cmd = Given("start") + .Command("start", "start it", c => + { + c.Argument(out string file, "the file"); + c.Run(() => { ran = true; }); + }) + .TryParse(); + + Assert.IsTrue(cmd.ShouldExit); + Assert.IsFalse(ran); + } + + [TestMethod] + public void Run_TheHandlerDoesNotRunForAValueThatWouldNotConvert() + { + var ran = false; + + var cmd = Given("start", "abc") + .Command("start", "start it", c => + { + c.Argument(out int count, "how many to start"); + c.Run(() => { ran = true; }); + }) + .TryParse(); + + Assert.IsTrue(cmd.ShouldExit); + Assert.IsFalse(ran, "a Run closure can never see a value that failed to convert"); + StringAssert.Contains(this.Errors, "demo start: expects a whole number, but got 'abc'."); + } + + [TestMethod] + public void Run_TheHandlerDoesNotRunForHelp() + { + var ran = false; + Given("start", "--help").Command("start", "start it", c => c.Run(() => { ran = true; })).TryParse(); + + Assert.IsFalse(ran); + } + + [TestMethod] + public void Run_AnExceptionFromTheHandlerIsNotSwallowed() + { + Assert.Throws( + () => Given("start") + .Command("start", "start it", c => c.Run(() => { throw new InvalidTimeZoneException(); })) + .TryParse()); + } + + [TestMethod] + public async Task RunAsync_IsAwaitedByTheAsyncParse() + { + var ran = false; + + var cmd = await Given("start") + .Command("start", "start it", c => c.RunAsync(async () => { await Task.Yield(); ran = true; })) + .TryParseAsync(); + + Assert.IsTrue(ran); + Assert.IsFalse(cmd.ShouldExit); + Assert.IsTrue(cmd.HandlerRan); + } + + [TestMethod] + public async Task RunAsync_ItsReturnValueBecomesTheExitCode() + { + var cmd = await Given("start") + .Command("start", "start it", c => c.RunAsync(async () => { await Task.Yield(); return 4; })) + .TryParseAsync(); + + Assert.AreEqual(4, cmd.ExitCode); + } + + [TestMethod] + public async Task RunAsync_TheAsyncParseStillRunsAPlainHandler() + { + var cmd = await Given("start").Command("start", "start it", c => c.Run(() => 5)).TryParseAsync(); + + Assert.AreEqual(5, cmd.ExitCode); + } + + [TestMethod] + public void RunAsync_UnderTheSyncParseThrowsAndSaysToUseParseAsync() + { + // Blocking on it would be the quiet answer; the loud one is what this type is for. + var thrown = Assert.Throws( + () => Given("start") + .Command("start", "start it", c => c.RunAsync(async () => { await Task.Yield(); })) + .TryParse()); + + StringAssert.Contains(thrown.Message, "ParseAsync()"); + StringAssert.Contains(thrown.Message, "demo start"); + } + + [TestMethod] + public void Declaring_ACommandBesideAPositionalThrows() + { + Assert.Throws( + () => Given().Argument("file", "the file").Command("start", "start it", c => { })); + + Assert.Throws( + () => Given().Command("start", "start it", c => { }).Argument("file", "the file")); + } + + [TestMethod] + public void Declaring_ACommandAfterATypedDeclarationThrows() + { + // The mirror of the Rest() rule: the command word is where this level's switches + // stop, and the typed value was already read without knowing that. + var thrown = Assert.Throws( + () => Given().Switch(out bool verbose, "say more").Command("start", "start it", c => { })); + + StringAssert.Contains(thrown.Message, "typed declaration"); + } + + [TestMethod] + public void Declaring_TwoCommandsWithOneNameThrows() + { + var thrown = Assert.Throws( + () => Given().Command("dry-run", "rehearse it", c => { }).Command("dryrun", "again", c => { })); + + StringAssert.Contains(thrown.Message, "collides"); + } + + [TestMethod] + public void Declaring_ACommandAndASwitchWithOneNameThrows() + { + Assert.Throws( + () => Given().Command("start", "start it", c => { }).Switch("start", "something else")); + + Assert.Throws( + () => Given().Switch("start", "something else").Command("start", "start it", c => { })); + } + + [TestMethod] + public void Declaring_ACommandThatRedeclaresAGlobalThrows() + { + // Which --verbose you got would otherwise depend on which side of the command word + // it was typed. + var thrown = Assert.Throws( + () => Given("start") + .Command("start", "start it", c => c.Switch("verbose", "say more here")) + .Switch("verbose", "say more") + .TryParse()); + + StringAssert.Contains(thrown.Message, "already declared by 'demo'"); + } + + [TestMethod] + public void Declaring_ACommandWithoutALambdaThrows() + { + Assert.Throws(() => Given().Command("start", "start it", null)); + } + + [TestMethod] + public void Declaring_ACommandHelpThatLooksLikeAnAliasThrows() + { + var thrown = Assert.Throws(() => Given().Command("remove", "rm", c => { })); + StringAssert.Contains(thrown.Message, "help text"); + } + + [TestMethod] + public void Declaring_RunAtTheTopLevelThrows() + { + var thrown = Assert.Throws(() => Given().Run(() => { })); + StringAssert.Contains(thrown.Message, "top level"); + } + + [TestMethod] + public void Declaring_RunBesideCommandsThrows() + { + Assert.Throws( + () => Given("nuget") + .Command("nuget", "the feed", c => + { + c.Command("push", "push one", p => { }); + c.Run(() => { }); + }) + .TryParse()); + + Capture(); + Assert.Throws( + () => Given("nuget") + .Command("nuget", "the feed", c => + { + c.Run(() => { }); + c.Command("push", "push one", p => { }); + }) + .TryParse()); + } + + [TestMethod] + public void Declaring_TwoHandlersOnOneCommandThrows() + { + Assert.Throws( + () => Given("start") + .Command("start", "start it", c => + { + c.Run(() => { }); + c.Run(() => { }); + }) + .TryParse()); + } + + [TestMethod] + public void Declaring_ProgramInsideACommandThrows() + { + var thrown = Assert.Throws( + () => Given("start").Command("start", "start it", c => c.Program("other")).TryParse()); + + StringAssert.Contains(thrown.Message, "named by Command()"); + } } } diff --git a/src/CShell.csproj b/src/CShell.csproj index 9e78627..4012e4b 100644 --- a/src/CShell.csproj +++ b/src/CShell.csproj @@ -16,8 +16,8 @@ git scripting dotnet csharp CShell - 3.0.2 - 3.0.2 + 3.1.0 + 3.1.0 true snupkg diff --git a/src/Cli.cs b/src/Cli.cs index 83f30bc..bc1c2a8 100644 --- a/src/Cli.cs +++ b/src/Cli.cs @@ -6,6 +6,7 @@ using System.Reflection; using System.Runtime.CompilerServices; using System.Text; +using System.Threading.Tasks; namespace CShellNet { @@ -52,30 +53,65 @@ namespace CShellNet /// Help is generated from the declarations, so it cannot drift from what the script accepts, /// and `-help`, `-h` and `-?` are always understood without asking. /// - /// The ceiling, stated so nobody has to discover it: no subcommands, no repeated options, and - /// values ATTACH (`-out:file`, never `-out file` -- see Option). A script that needs more than - /// this should reference System.CommandLine directly rather than growing this into a - /// half-framework. + /// A script with VERBS declares a Command for each, and each one declares its own line the + /// same three ways. Commands nest, so `svc nuget push` reads the way `dotnet nuget push` does: + /// + /// await Cli.For(Args) + /// .Command("start", "start the service", c => + /// { + /// c.Argument(out string file, "the file to start"); + /// c.Run(() => Start(file)); + /// }) + /// .Command("nuget", "work with the feed", c => c + /// .Command("push", "push a package", p => + /// { + /// p.Argument(out string package, "the .nupkg to push"); + /// p.RunAsync(async () => await Push(package)); + /// })) + /// .ParseAsync(); + /// + /// Every level has its own generated help, its own unknown-switch error, and its own name in + /// the message -- `svc nuget push: missing <package>.` -- and only the command that was typed + /// is ever built. See Command(). + /// + /// The ceiling, stated so nobody has to discover it: no repeated options, and values ATTACH + /// (`-out:file`, never `-out file` -- see Option). A script that needs more than this should + /// reference System.CommandLine directly rather than growing this into a half-framework. /// public class Cli { private readonly List tokens; private readonly List switches = new List(); private readonly List arguments = new List(); + private readonly List commands = new List(); private readonly List> examples = new List>(); private readonly List conversionErrors = new List(); + // The level above, and the name that got here from it. Null at the top, which is how + // Program(), Run() and the "write it before the command" advice all tell where they are. + private readonly Cli parent; + private readonly string commandName; + private string program; private string description; private bool usageWhenEmpty; private bool whatIfDeclared; private bool typedDeclared; - private Cli(List tokens, string program) + private Func handler; + private Func> asyncHandler; + + // What the level above parsed, kept so this level's result can chain back to it and a + // global declared at the top stays readable from the leaf. + private CliResult parentResult; + + private Cli(List tokens, string program, Cli parent, string commandName) { this.tokens = tokens; this.program = program; + this.parent = parent; + this.commandName = commandName; // Help always exists. No script is better off without it when it is generated free, // and a script wanting different wording just declares its own, which replaces this. @@ -111,7 +147,7 @@ public static Cli For(IEnumerable args, [CallerFilePath] string scriptPa throw new ArgumentNullException(nameof(args), "Cli.For() needs the command line, not null."); } - return new Cli(args.ToList(), ProgramFrom(scriptPath)); + return new Cli(args.ToList(), ProgramFrom(scriptPath), null, null); } static string ProgramFrom(string scriptPath) @@ -156,6 +192,16 @@ static string ProgramFrom(string scriptPath) /// the builder, to go on declaring public Cli Program(string name) { + // A command is already named -- by Command() -- and its name is the whole path that + // got to it, which is what every message at that level is printed under. Letting + // Program() overwrite that would quietly break the trail back to what to type. + if (this.parent != null) + { + throw new InvalidOperationException( + $"Program(\"{name}\") cannot be called inside a command -- a command is named by Command(), " + + $"and this one is already '{this.program}'. Program() names the whole program, at the top."); + } + if (String.IsNullOrWhiteSpace(name)) { throw new ArgumentException("Program() needs a name.", nameof(name)); @@ -257,6 +303,15 @@ Cli AddArgument(string name, string help, bool required, bool isRest) $"\"{name}\" cannot be declared after a Rest -- a rest collects everything left, so nothing can follow it."); } + // A command word and a positional are the same shape of token -- a bare word in the + // same place -- so one level cannot have both without the first one being a guess. + if (this.commands.Count > 0) + { + throw new InvalidOperationException( + $"\"{name}\" cannot be declared beside commands -- the first bare word is the command, " + + $"so there is nowhere for a positional to go. Declare it inside the command that takes it."); + } + // A Rest changes what counts as a switch from the first positional onward, and a typed // declaration has already read its value by then. Rather than hand back a value read // under rules that no longer hold, say so at the declaration that made it ambiguous. @@ -382,6 +437,29 @@ Cli AddSwitch(string name, string help, bool takesValue) $"\"{parts[0]}\" is already declared as an argument -- one name cannot mean both."); } + foreach (var key in keys) + { + if (FindCommand(key) != null) + { + throw new InvalidOperationException( + $"\"{parts[0]}\" is already declared as a command -- one name cannot mean both."); + } + + // A global and a command's own switch of the same name are two different values + // that read identically, and which one a reader gets depends on which side of the + // command word it was typed. That is the quiet kind of wrong, so it is refused. + for (var above = this.parent; above != null; above = above.parent) + { + var global = above.Find(key); + if (global != null && !global.BuiltIn) + { + throw new InvalidOperationException( + $"\"{parts[0]}\" is already declared by '{above.program}' -- a command cannot redeclare a global. " + + $"Read it from the result instead; a global stays readable from the command's result."); + } + } + } + // A user declaration REPLACES a built-in of the same name. That is how a script gives // -help its own wording without having to opt out of anything. foreach (var key in keys) @@ -915,6 +993,275 @@ public Cli Example(string commandLine, string help) return this; } + // ------------------------------------------------------------------ commands + + /// + /// Declare a verb with a command line of its own. + /// + /// + /// A command is a Cli in its own right. It gets the tokens after its own name, declares + /// them with the same three words, and has its own generated help and its own name in + /// every message it prints -- `svc nuget push: missing <package>.` + /// + /// Cli.For(Args) + /// .Command("start", "start the service", c => + /// { + /// c.Argument(out string file, "the file to start"); + /// c.Run(() => Start(file)); + /// }) + /// .Parse(); + /// + /// Commands NEST, because the thing handed to the lambda is a Cli and a Cli takes + /// commands: `.Command("nuget", ..., c => c.Command("push", ...))` is `svc nuget push`. + /// + /// The lambda runs only for the command that was actually typed. That is what lets each + /// command declare typed variables of its own -- nothing binds for a command nobody asked + /// for -- and it is why `--help` at this level can list the commands without building any + /// of them. The cost, stated so it is not a surprise: a mistake INSIDE a lambda, such as + /// asking for a type that cannot be made from a string, is found the first time that + /// command is typed rather than the first time the script is run. + /// + /// Aliases go in the name after a pipe -- `Command("remove|rm", "...")` -- exactly as they + /// do on a Switch, and the name is matched the same way: case, hyphens and underscores are + /// ignored, so `Command("dry-run", ...)` also answers to `dryrun`. + /// + /// A level with commands cannot also have positionals: the first bare word is the command, + /// so there is nowhere for one to go. Switches at this level are GLOBAL -- they are typed + /// before the command word, `svc --verbose start foo`, and stay readable from the + /// command's own result. Typed globals must be declared AFTER the first Command(), because + /// a typed declaration reads its value as it runs and before the first Command() this + /// level does not yet know that the line splits. + /// + /// WATCH THE VARIABLE NAMES. The lambda's body is nested inside the script's own scope, so + /// `c.Argument(out string file, ...)` will not compile if the script already has a `file` + /// further down (CS0136). Two sibling commands may each declare `file`; the enclosing + /// script may not have one too. + /// + /// the verb, optionally followed by |aliases + /// the one line shown beside it in the command list + /// what the command accepts, and what it does + /// the builder, to go on declaring + /// declare is null + /// the name or help is unusable + /// it cannot follow what is already declared + public Cli Command(string name, string help, Action declare) + { + CheckName(name, help, "Command"); + + if (declare == null) + { + throw new ArgumentNullException(nameof(declare), + $"Command(\"{name}\") needs the lambda that declares what it accepts."); + } + + var parts = name.Split('|').Select(p => p.Trim()).ToArray(); + if (parts.Any(p => p.Length == 0)) + { + throw new ArgumentException($"\"{name}\" has an empty name or alias between its pipes.", nameof(name)); + } + + if (parts.Any(p => p.Any(Char.IsWhiteSpace))) + { + throw new ArgumentException($"\"{name}\" has whitespace inside a name or alias.", nameof(name)); + } + + var keys = parts.Select(Normalize).ToArray(); + if (keys.Distinct().Count() != keys.Length) + { + throw new ArgumentException($"\"{name}\" names the same thing twice.", nameof(name)); + } + + // The same reason Rest() cannot follow one: a command word moves where this level's + // switches stop, and a typed declaration has already read its value without knowing + // that. Rather than hand back a value read under rules that no longer hold, say so at + // the declaration that made it ambiguous. + if (this.typedDeclared) + { + throw new InvalidOperationException( + $"Command(\"{parts[0]}\") cannot come after a typed declaration -- the command word is where this " + + "level's switches stop, and the typed values were read without knowing that. Declare every " + + "Command() first, then the typed globals after them."); + } + + if (this.arguments.Count > 0) + { + throw new InvalidOperationException( + $"Command(\"{parts[0]}\") cannot be declared beside <{this.arguments[0].Name}> -- the first bare " + + "word is either a command or a positional, and it cannot be worked out which."); + } + + if (this.handler != null || this.asyncHandler != null) + { + throw new InvalidOperationException( + $"Command(\"{parts[0]}\") cannot be declared beside a Run() -- a level either does something " + + "itself or hands off to commands that do."); + } + + foreach (var key in keys) + { + if (FindCommand(key) != null) + { + throw new InvalidOperationException( + $"\"{parts[0]}\" collides with \"{FindCommand(key).Primary}\" -- they are the same command " + + "once case, hyphens and underscores are ignored."); + } + + var clash = Find(key); + if (clash != null && !clash.BuiltIn) + { + throw new InvalidOperationException( + $"\"{parts[0]}\" is already declared as a switch -- one name cannot mean both."); + } + } + + this.commands.Add(new CommandSpec(parts, keys, help, declare)); + return this; + } + + /// + /// Say what this command does when it is the one that was typed. + /// + /// + /// Declared beside the command's own declarations, so the variables it uses are the ones + /// just declared and there is nothing to pass: + /// + /// .Command("start", "start the service", c => + /// { + /// c.Argument(out string file, "the file to start"); + /// c.Run(() => Start(file)); + /// }) + /// + /// This is the ONLY way to use a command's typed variables, and not by choice: an out + /// variable belongs to the block it was declared in, so it cannot be read after the lambda + /// returns. A script that would rather read its values back at the end declares them with + /// the (name, help) overloads and reads them off the result, which knows which command was + /// chosen -- see CliResult.Command. + /// + /// The handler runs only after the whole line has been read and found good, so a value + /// that was missing or would not convert stops it from running at all. + /// + /// Run() belongs to a command. At the top level the script's own code is the handler -- + /// the variables are already in scope where Parse() returns -- and a level that has + /// commands hands off to them rather than doing anything itself. + /// + /// what to do + /// the builder, to go on declaring + /// handler is null + /// this is not a leaf command, or it already has a handler + public Cli Run(Action handler) + { + if (handler == null) { throw new ArgumentNullException(nameof(handler), "Run() needs something to do."); } + + CheckHandler("Run"); + this.handler = () => { handler(); return 0; }; + return this; + } + + /// + /// Say what this command does, and what the script should exit with. + /// + /// + /// What comes back lands on CliResult.ExitCode, and Parse() returns rather than exiting -- + /// a command that ran is not a command line that could not be read, and the script says + /// how it ends: `return Cli.For(Args)....Parse().ExitCode;` + /// + /// ShouldExit tells the two apart. It is false here, whatever the code; it is true only + /// when the line was not understood, which is the case Parse() exits for. + /// + /// what to do, and what to exit with + /// the builder, to go on declaring + /// handler is null + /// this is not a leaf command, or it already has a handler + public Cli Run(Func handler) + { + if (handler == null) { throw new ArgumentNullException(nameof(handler), "Run() needs something to do."); } + + CheckHandler("Run"); + this.handler = handler; + return this; + } + + /// + /// Say what this command does, when doing it is asynchronous. + /// + /// + /// .Command("push", "push a package", p => + /// { + /// p.Argument(out string package, "the .nupkg to push"); + /// p.RunAsync(async () => await Push(package)); + /// }) + /// + /// then `await ...ParseAsync()` rather than Parse(). + /// + /// Its own name rather than another Run() overload, because `Run(async () => ...)` would + /// happily bind to Run(Action) as an async void that nobody ever awaits -- the script + /// would exit while the work was still running, and nothing would say so. A separate name + /// cannot be got wrong that way. + /// + /// Parse() throws when an async handler was declared, naming ParseAsync(), rather than + /// blocking on it. + /// + /// what to do + /// the builder, to go on declaring + /// handler is null + /// this is not a leaf command, or it already has a handler + public Cli RunAsync(Func handler) + { + if (handler == null) { throw new ArgumentNullException(nameof(handler), "RunAsync() needs something to do."); } + + CheckHandler("RunAsync"); + this.asyncHandler = async () => { await handler().ConfigureAwait(false); return 0; }; + return this; + } + + /// + /// Say what this command does asynchronously, and what the script should exit with. + /// + /// + /// The pairing of RunAsync(Func<Task>) and Run(Func<int>): awaited by ParseAsync(), + /// and what it returns lands on CliResult.ExitCode without exiting the process. + /// + /// what to do, and what to exit with + /// the builder, to go on declaring + /// handler is null + /// this is not a leaf command, or it already has a handler + public Cli RunAsync(Func> handler) + { + if (handler == null) { throw new ArgumentNullException(nameof(handler), "RunAsync() needs something to do."); } + + CheckHandler("RunAsync"); + this.asyncHandler = handler; + return this; + } + + void CheckHandler(string what) + { + if (this.parent == null) + { + throw new InvalidOperationException( + $"{what}() belongs to a command, and this is the top level -- the script's own code runs when " + + "Parse() returns, with the variables already in scope. Declare a Command() and put it there."); + } + + if (this.commands.Count > 0) + { + throw new InvalidOperationException( + $"{what}() cannot be declared beside commands -- '{this.program}' hands off to its commands, " + + "so put the handler on the one that does the work."); + } + + if (this.handler != null || this.asyncHandler != null) + { + throw new InvalidOperationException( + $"'{this.program}' already has a handler -- a command does one thing."); + } + } + + CommandSpec FindCommand(string key) + { + return this.commands.FirstOrDefault(c => c.Keys.Contains(key)); + } + SwitchSpec Find(string key) { return this.switches.FirstOrDefault(s => s.Keys.Contains(key)); @@ -969,6 +1316,31 @@ public CliResult Parse() return cmd; } + /// + /// Read the command line and await the command's handler, stopping the script if it was + /// not valid or help was asked for. + /// + /// + /// Parse() for a script whose commands declared RunAsync(). Everything else is the same, + /// including exiting for a line that could not be read and NOT exiting for a handler that + /// returned a code -- that lands on ExitCode for the script to return. + /// + /// Parse() throws rather than blocking when an async handler was declared, so a script + /// that forgot the await is told, not left to exit while the work is still running. + /// + /// the parsed command line, always readable + public async Task ParseAsync() + { + var cmd = await TryParseAsync().ConfigureAwait(false); + + if (cmd.ShouldExit) + { + Environment.Exit(cmd.ExitCode); + } + + return cmd; + } + /// /// Read the command line without ever exiting the process. /// @@ -982,6 +1354,69 @@ public CliResult Parse() /// the parsed command line, which may be one that should not be used public CliResult TryParse() { + Cli leaf; + var cmd = Resolve(null, out leaf); + + if (cmd.ShouldExit) + { + return cmd; + } + + if (leaf.asyncHandler != null) + { + throw new InvalidOperationException( + $"'{leaf.program}' declared an async handler with RunAsync(), so it has to be awaited -- " + + "call ParseAsync() instead of Parse()."); + } + + if (leaf.handler != null) + { + cmd.Ran(leaf.handler()); + } + + return cmd; + } + + /// + /// Read the command line and await the command's handler, without ever exiting the process. + /// + /// + /// TryParse() for a script whose commands declared RunAsync(). A command that declared a + /// plain Run() still works here, so a script with a mix of both needs only this one. + /// + /// the parsed command line, which may be one that should not be used + public async Task TryParseAsync() + { + Cli leaf; + var cmd = Resolve(null, out leaf); + + if (cmd.ShouldExit) + { + return cmd; + } + + if (leaf.asyncHandler != null) + { + cmd.Ran(await leaf.asyncHandler().ConfigureAwait(false)); + } + else if (leaf.handler != null) + { + cmd.Ran(leaf.handler()); + } + + return cmd; + } + + // Everything TryParse() does except run the handler: read this level, and either finish + // here or hand what is left to the command that was named and let it do the same. What + // comes back is the LEAF's result -- the level whose line was actually being read -- with + // Parent chaining back up, and `leaf` is the Cli it came from, which is where the handler + // lives. + CliResult Resolve(CliResult above, out Cli leaf) + { + leaf = this; + this.parentResult = above; + var scan = ScanTokens(); var values = scan.Values; var flags = scan.Flags; @@ -1003,28 +1438,48 @@ public CliResult TryParse() if (this.usageWhenEmpty && this.tokens.Count == 0) { Console.Out.WriteLine(usage); - return CliResult.Exiting(this.program, 0, null, true, usage); + return CliResult.Exiting(this.program, 0, null, true, usage, this.parentResult, this.commandName); } if (helpAsked) { Console.Out.WriteLine(usage); - return CliResult.Exiting(this.program, 0, null, true, usage); + return CliResult.Exiting(this.program, 0, null, true, usage, this.parentResult, this.commandName); } // Switch-level trouble is reported on its own. Once the switches were misread the // positional list means nothing, and reporting it as well would echo tokens -- possibly - // a secret -- that the user never meant as arguments. + // a secret -- that the user never meant as arguments. It is also reported BEFORE the + // command is resolved, so a command whose globals were misread is never built. if (unknown.Count > 0 || badValues.Count > 0) { var lines = new List(); - if (unknown.Count == 1) + var strays = new List(); + + foreach (var token in unknown) + { + var declaredAbove = DeclaredAbove(token); + if (declaredAbove == null) + { + strays.Add(token); + } + else + { + // Typed on the wrong side of the command word. Saying where it goes is the + // difference between a dead end and a fix. + var below = this.program.Substring(declaredAbove.program.Length).Trim(); + lines.Add($"{this.program}: '{token}' is a global switch -- write it before the command: " + + $"'{declaredAbove.program} {token} {below}'."); + } + } + + if (strays.Count == 1) { - lines.Add($"{this.program}: unknown switch '{unknown[0]}'"); + lines.Add($"{this.program}: unknown switch '{strays[0]}'"); } - else if (unknown.Count > 1) + else if (strays.Count > 1) { - lines.Add($"{this.program}: unknown switches: {String.Join(" ", unknown.Select(u => "'" + u + "'"))}"); + lines.Add($"{this.program}: unknown switches: {String.Join(" ", strays.Select(u => "'" + u + "'"))}"); } foreach (var bad in badValues) @@ -1035,6 +1490,38 @@ public CliResult TryParse() return Failed(String.Join(Environment.NewLine, lines), usage); } + // A level with commands reads its own globals and then gets out of the way: everything + // from the command word on belongs to the command, which reads it the same way. + if (this.commands.Count > 0) + { + var mine = CliResult.Parsed(this.program, usage, flags, values, + new Dictionary(StringComparer.OrdinalIgnoreCase), + new List(), this.switches, this.arguments, + this.whatIfDeclared, this.parentResult, this.commandName); + + if (scan.CommandIndex < 0) + { + return Failed($"{this.program}: no command given -- one of: {CommandList()}.", usage); + } + + var word = this.tokens[scan.CommandIndex]; + var chosen = FindCommand(Normalize(word)); + + if (chosen == null) + { + return Failed($"{this.program}: unknown command '{word}' -- expected one of: {CommandList()}.", usage); + } + + var child = new Cli(this.tokens.Skip(scan.CommandIndex + 1).ToList(), + this.program + " " + chosen.Primary, this, chosen.Primary); + + // Only now, and only for the command that was actually typed: this is where its + // typed declarations bind, against its own tokens. + chosen.Declare(child); + + return child.Resolve(mine, out leaf); + } + // Fill the declared positionals in order, then the rest. var taken = new Dictionary(StringComparer.OrdinalIgnoreCase); var tail = new List(); @@ -1078,14 +1565,42 @@ public CliResult TryParse() } return CliResult.Parsed(this.program, usage, flags, values, taken, tail, - this.switches, this.arguments, this.whatIfDeclared); + this.switches, this.arguments, this.whatIfDeclared, + this.parentResult, this.commandName); } CliResult Failed(string error, string usage) { + var takes = this.commands.Count > 0 ? "commands" : "switches"; + Console.Error.WriteLine(error); - Console.Error.WriteLine($"Try '{this.program} --help' for the switches it takes."); - return CliResult.Exiting(this.program, 1, error, false, usage); + Console.Error.WriteLine($"Try '{this.program} --help' for the {takes} it takes."); + return CliResult.Exiting(this.program, 1, error, false, usage, this.parentResult, this.commandName); + } + + string CommandList() + { + return String.Join(", ", this.commands.Select(c => c.Primary)); + } + + // Which level above declared this switch, if any. An unknown switch inside a command is + // very often a global typed after the command word instead of before it. + Cli DeclaredAbove(string raw) + { + var body = raw.TrimStart('-'); + var sep = body.IndexOfAny(new[] { ':', '=' }); + var key = Normalize(sep >= 0 ? body.Substring(0, sep) : body); + + for (var above = this.parent; above != null; above = above.parent) + { + var spec = above.Find(key); + if (spec != null && !spec.BuiltIn) + { + return above; + } + } + + return null; } static string Dash(string name) @@ -1102,6 +1617,10 @@ class Scanned public readonly List Positionals = new List(); public readonly List Unknown = new List(); public readonly List BadValues = new List(); + + // Where the command word is, at a level that has commands; -1 when none was given. + // Everything from here on belongs to the command, so the scan stops. + public int CommandIndex = -1; } // The one place the command line is turned into flags, values and positionals. Both the @@ -1115,10 +1634,25 @@ Scanned ScanTokens() var stopSwitches = false; var restDeclared = this.arguments.Any(a => a.IsRest); - foreach (var raw in this.tokens) + // Where a level has commands, the first thing that is not a switch is the command + // word, and the scan stops there: the rest is the command's line, not this one's. + // The boundary is only unambiguous because an option's value ATTACHES -- with a + // separated value, `svc --out foo build` could not be told apart from `svc --out:foo + // build` with a stray positional. + var hasCommands = this.commands.Count > 0; + + for (int i = 0; i < this.tokens.Count; i++) { + var raw = this.tokens[i]; + if (terminated || stopSwitches) { + if (hasCommands) + { + scan.CommandIndex = i; + return scan; + } + scan.Positionals.Add(raw); continue; } @@ -1131,6 +1665,12 @@ Scanned ScanTokens() if (raw.Length == 0 || raw == "-" || raw[0] != '-') { + if (hasCommands) + { + scan.CommandIndex = i; + return scan; + } + scan.Positionals.Add(raw); // A declared Rest hands everything from the first positional onward to whatever @@ -1161,6 +1701,12 @@ Scanned ScanTokens() // dash was meant as a switch, so say that it is not one. if (namePart.Length > 0 && Char.IsDigit(namePart[0])) { + if (hasCommands) + { + scan.CommandIndex = i; + return scan; + } + scan.Positionals.Add(raw); if (restDeclared) { stopSwitches = true; } } @@ -1220,24 +1766,45 @@ internal string RenderUsage() } var spelled = this.switches.Select(Spelling).ToList(); - var line = new StringBuilder(" " + this.program); - foreach (var arg in this.arguments) - { - line.Append(arg.IsRest ? $" [{arg.Name}...]" : arg.Required ? $" <{arg.Name}>" : $" [{arg.Name}]"); - } + var named = this.commands.Select(c => String.Join(", ", c.Spellings)).ToList(); - var withSwitches = new StringBuilder(line.ToString()); - foreach (var s in this.switches) + text.AppendLine("Usage:"); + + if (this.commands.Count > 0) { - withSwitches.Append(" [" + Spelling(s) + "]"); + // Switches come BEFORE the command word here, because that is where they have to + // be typed, so the usage line teaches the grammar in the order the tokens go. + var head = " " + this.program; + var withGlobals = new StringBuilder(head); + foreach (var s in this.switches) + { + withGlobals.Append(" [" + Spelling(s) + "]"); + } + + withGlobals.Append(" ..."); + text.AppendLine(withGlobals.Length <= 78 ? withGlobals.ToString() : head + " [switches] ..."); } + else + { + var line = new StringBuilder(" " + this.program); + foreach (var arg in this.arguments) + { + line.Append(arg.IsRest ? $" [{arg.Name}...]" : arg.Required ? $" <{arg.Name}>" : $" [{arg.Name}]"); + } - text.AppendLine("Usage:"); - text.AppendLine(withSwitches.Length <= 78 ? withSwitches.ToString() : line + " [switches]"); + var withSwitches = new StringBuilder(line.ToString()); + foreach (var s in this.switches) + { + withSwitches.Append(" [" + Spelling(s) + "]"); + } - // One column across both sections, so the two lists line up as one block. + text.AppendLine(withSwitches.Length <= 78 ? withSwitches.ToString() : line + " [switches]"); + } + + // One column across every section, so the lists line up as one block. var widest = 0; foreach (var a in this.arguments) { widest = Math.Max(widest, a.Name.Length); } + foreach (var c in named) { widest = Math.Max(widest, c.Length); } foreach (var s in spelled) { widest = Math.Max(widest, s.Length); } var column = Math.Min(2 + widest + 2, 30); @@ -1251,6 +1818,16 @@ internal string RenderUsage() } } + if (this.commands.Count > 0) + { + text.AppendLine(); + text.AppendLine("Commands:"); + for (int i = 0; i < this.commands.Count; i++) + { + Row(text, named[i], this.commands[i].Help, column); + } + } + text.AppendLine(); text.AppendLine("Switches:"); for (int i = 0; i < this.switches.Count; i++) @@ -1272,6 +1849,12 @@ internal string RenderUsage() } } + if (this.commands.Count > 0) + { + text.AppendLine(); + text.AppendLine($"See '{this.program} --help' for what a command takes."); + } + return text.ToString().TrimEnd(); } @@ -1342,6 +1925,31 @@ public SwitchSpec(string[] spellings, string[] keys, string help, bool takesValu public bool BuiltIn { get; private set; } } + internal class CommandSpec + { + public CommandSpec(string[] spellings, string[] keys, string help, Action declare) + { + this.Spellings = spellings; + this.Primary = spellings[0]; + this.Keys = keys; + this.Help = help; + this.Declare = declare; + } + + public string Primary { get; private set; } + + // As the author wrote them, for the command list. Keys are what a typed word is matched + // against, normalized the same way a switch's are. + public string[] Spellings { get; private set; } + + public string[] Keys { get; private set; } + + public string Help { get; private set; } + + // Held, not run. It runs once, for the command that was actually typed. + public Action Declare { get; private set; } + } + internal class ArgSpec { public ArgSpec(string name, string help, bool required, bool isRest) diff --git a/src/CliResult.cs b/src/CliResult.cs index 59043c7..4de12ec 100644 --- a/src/CliResult.cs +++ b/src/CliResult.cs @@ -32,19 +32,22 @@ public class CliResult private readonly List arguments; private readonly bool whatIfDeclared; - private CliResult(string program, int exitCode, string error, bool helpRequested, string usage) + private CliResult(string program, int exitCode, string error, bool helpRequested, string usage, + CliResult parent, string command) { this.ProgramName = program; this.ExitCode = exitCode; this.Error = error; this.HelpRequested = helpRequested; this.UsageText = usage; + this.Parent = parent; + this.Command = command; this.ShouldExit = true; } private CliResult(string program, string usage, HashSet flags, Dictionary values, Dictionary args, List rest, List switches, - List arguments, bool whatIfDeclared) + List arguments, bool whatIfDeclared, CliResult parent, string command) { this.ProgramName = program; this.UsageText = usage; @@ -55,19 +58,29 @@ private CliResult(string program, string usage, HashSet flags, Dictionar this.switches = switches; this.arguments = arguments; this.whatIfDeclared = whatIfDeclared; + this.Parent = parent; + this.Command = command; } - internal static CliResult Exiting(string program, int exitCode, string error, bool helpRequested, string usage) + internal static CliResult Exiting(string program, int exitCode, string error, bool helpRequested, string usage, + CliResult parent, string command) { - return new CliResult(program, exitCode, error, helpRequested, usage); + return new CliResult(program, exitCode, error, helpRequested, usage, parent, command); } internal static CliResult Parsed(string program, string usage, HashSet flags, Dictionary values, Dictionary args, List rest, List switches, List arguments, - bool whatIfDeclared) + bool whatIfDeclared, CliResult parent, string command) { - return new CliResult(program, usage, flags, values, args, rest, switches, arguments, whatIfDeclared); + return new CliResult(program, usage, flags, values, args, rest, switches, arguments, whatIfDeclared, + parent, command); + } + + internal void Ran(int exitCode) + { + this.HandlerRan = true; + this.ExitCode = exitCode; } /// The name shown in the usage line. @@ -83,9 +96,68 @@ internal static CliResult Parsed(string program, string usage, HashSet f /// public bool ShouldExit { get; private set; } - /// What to return: 0 for help, 1 for a command line that was not valid. + /// + /// What to return: 0 for help, 1 for a command line that was not valid, and whatever a + /// command's handler returned. + /// + /// + /// ShouldExit tells the two apart, and it is the only thing that can. True means the line + /// was not read and this is a parse outcome. False with a non-zero code means the line was + /// read, the handler ran, and the handler said so -- see HandlerRan. + /// public int ExitCode { get; private set; } + /// True when a command's Run() or RunAsync() handler was invoked. + public bool HandlerRan { get; private set; } + + /// + /// The command that was typed at this level, or null when the script declared none. + /// + /// + /// What a script switches on when it declared its commands with the (name, help) overloads + /// rather than giving each a Run(): + /// + /// if (cmd.Command == "start") { Start(cmd.Argument("file")); } + /// + /// It is the PRIMARY spelling, whichever alias was typed, so a switch on it does not have + /// to list them. Readable whatever happened to the command line -- it says which level the + /// error came from -- unlike the values, which are not. + /// + public string Command { get; private set; } + + /// + /// The level above this one, or null at the top. + /// + /// + /// A command's result chains back to the program's, which is how a global declared at the + /// top stays readable from the command's result. Switch, Option and WhatIf already walk + /// it; this is for a script that wants to be explicit about which level it is asking. + /// + public CliResult Parent { get; private set; } + + /// Every command that was typed, outermost first: `nuget`, then `push`. + /// + /// What to read instead of Command when the same verb appears under two parents and the + /// leaf name alone does not say which one ran. Empty when no command was typed. + /// + public IReadOnlyList CommandPath + { + get + { + var path = new List(); + for (var level = this; level != null; level = level.Parent) + { + if (level.Command != null) + { + path.Add(level.Command); + } + } + + path.Reverse(); + return path; + } + } + /// What was wrong with the command line, or null when nothing was. public string Error { get; private set; } @@ -125,8 +197,21 @@ public string Argument(string name) public bool Switch(string name) { Readable(); - var spec = Spec(name, false); - return this.flags.Contains(spec.Keys[0]); + + // Up the chain, so a global declared by the program is readable from the command's + // result -- the script that declared it should not have to know which level it + // happens to be reading from. + var key = Cli.Normalize(name ?? ""); + for (var level = this; level != null; level = level.Parent) + { + var found = level.Declared(key, false); + if (found != null) + { + return level.flags.Contains(found.Keys[0]); + } + } + + throw Undeclared("switch", name, false); } /// @@ -143,10 +228,19 @@ public bool Switch(string name) public string Option(string name) { Readable(); - var spec = Spec(name, true); - string value; - return this.values.TryGetValue(spec.Keys[0], out value) ? value : null; + var key = Cli.Normalize(name ?? ""); + for (var level = this; level != null; level = level.Parent) + { + var found = level.Declared(key, true); + if (found != null) + { + string value; + return level.values.TryGetValue(found.Keys[0], out value) ? value : null; + } + } + + throw Undeclared("option", name, true); } /// @@ -164,13 +258,16 @@ public bool WhatIf { Readable(); - if (!this.whatIfDeclared) + for (var level = this; level != null; level = level.Parent) { - throw new InvalidOperationException( - "WhatIf was never declared -- add .WhatIf() to the Cli chain, or this script has no dry run to report."); + if (level.whatIfDeclared) + { + return level.flags.Contains("whatif"); + } } - return this.flags.Contains("whatif"); + throw new InvalidOperationException( + "WhatIf was never declared -- add .WhatIf() to the Cli chain, or this script has no dry run to report."); } } @@ -217,18 +314,27 @@ void Readable() } } - SwitchSpec Spec(string name, bool wantValue) + SwitchSpec Declared(string key, bool wantValue) { - var key = Cli.Normalize(name ?? ""); - var spec = this.switches.FirstOrDefault(s => s.Keys.Contains(key) && s.TakesValue == wantValue); + return this.switches == null + ? null + : this.switches.FirstOrDefault(s => s.Keys.Contains(key) && s.TakesValue == wantValue); + } - if (spec == null) + // Everything declared by this level and every level above it: the name may have been the + // program's rather than the command's, and either is a fair thing to have meant. + ArgumentException Undeclared(string what, string name, bool wantValue) + { + var known = new List(); + for (var level = this; level != null; level = level.Parent) { - throw Undeclared(wantValue ? "option" : "switch", name, - this.switches.Where(s => s.TakesValue == wantValue).Select(s => s.Primary)); + if (level.switches != null) + { + known.AddRange(level.switches.Where(s => s.TakesValue == wantValue).Select(s => s.Primary)); + } } - return spec; + return Undeclared(what, name, known); } static ArgumentException Undeclared(string what, string name, IEnumerable declared)