diff --git a/README.md b/README.md index b398c3d..e18a618 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/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/CShell.csproj b/src/CShell.csproj index b4a2505..d55e61e 100644 --- a/src/CShell.csproj +++ b/src/CShell.csproj @@ -16,8 +16,8 @@ git scripting dotnet csharp CShell - 3.0.3 - 3.0.3 + 3.2.0 + 3.2.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) 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 /// /// ///