diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..ae48191 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(dotnet:*)", + "Bash(git:*)", + "Bash(xargs grep:*)" + ] + } +} diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 0000000..e86c203 --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,45 @@ +name: Claude Code Review + +on: + pull_request: + # No `synchronize`. That fires on every push to a PR, so a branch pushed five times gets + # five reviews of overlapping diffs. Once when it opens and once when it leaves draft is + # when a review is actually worth reading; push a fresh @claude comment for anything else. + types: [opened, ready_for_review, reopened] + +jobs: + claude-review: + # Reviewable pull requests only. Three cases this cannot or should not review: + # head.repo.fork -- a FORK pull request. GitHub gives these a read-only token, no + # id-token: write and NO SECRETS, so ANTHROPIC_API_KEY arrives + # empty and the action dies on OIDC. Not fixable by permissions; + # pull_request_target would fix it by running fork code WITH the + # secrets, which is worse than no review. + # user.type -- the PR was OPENED by a bot (Copilot and friends). + # github.actor -- a human's PR that Claude then PUSHED to. The push fires + # `synchronize`, and Claude would review its own commit. + # All three are still reviewable on demand: comment @claude on the PR. That runs on + # issue_comment, which is a base-repo event and does get the secrets. + if: github.event.pull_request.head.repo.fork == false && github.event.pull_request.user.type != 'Bot' && github.actor != 'claude[bot]' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + # The Action does NOT read .claude/settings.json -- claude_args is what gates it. + claude_args: '--allowedTools "Bash(dotnet:*),Bash(gh pr edit:*),Bash(gh pr ready:*),Bash(gh pr view:*)"' + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' + plugins: 'code-review@claude-code-plugins' + prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 0000000..382dbb7 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,41 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + # The Action does NOT read .claude/settings.json -- claude_args is what gates it. + claude_args: '--allowedTools "Bash(dotnet:*),Bash(gh pr edit:*),Bash(gh pr ready:*),Bash(gh pr view:*)"' + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + additional_permissions: | + actions: read diff --git a/README.md b/README.md index eee6163..68514d8 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,10 @@ CShell provides: By maintaining the concept of a current folder all file and folder commands can be take absolute or relative paths just like a normal shell. +CShell targets **net8.0** and depends only on +[MedallionShell](https://github.com/madelson/MedallionShell). JSON is read with the in-box +System.Text.Json. + ### Properties CShell exposes 3 properties which are the working environment of your script. The CurrentFolder is used to resolve relative paths for most methods, so if you call **MoveFile(@"..\foo.txt", @"..\..\bar")** it will resolve the paths and execute just like a normal shell. @@ -26,6 +30,8 @@ most methods, so if you call **MoveFile(@"..\foo.txt", @"..\..\bar")** it will r | **FolderStack** | current stack from Push/Pop operations | | **Echo** | Controls whether commands are echoed to output | | **ThrowOnError** | Controls whether to throw exception when commands have non-sucess error code | +| **RichPrompts** | Whether the Ask methods use arrow keys or read a typed line. Null (the default) decides by asking whether standard input is redirected | +| **ReadKey** | Where the Ask methods get their keystrokes. Null reads the console | ### Folder Methods CShell defines a number of methods which work relative to the current folder to make it easy @@ -69,6 +75,111 @@ print("Hello world!"); error("ohoh!"); ``` +### Prompting the user +The **Ask**() family methods ask the user a question and return the answer. + +| Method | Description | +|------------------|--------------------------------------------------------------------------------------------------| +| **AskText(question)** | read a line of text, trimmed | +| **AskSecret(question)** | read without echoing anything, for tokens and passwords | +| **AskYesNo(question)** | a yes/no question, returning bool | +| **AskYesNo(question, default)** | the same, where enter accepts the default | +| **AskNumber(question)** | a whole number | +| **AskNumber(question, min, max)** | a whole number held inside a range | +| **AskChoice(question, options, label)** | pick one from a list; returns the option itself | +| **AskChoice(question, style, options, label)** | the same, choosing how the options are labelled | +| **AskMultiChoice(question, options, label)** | pick any number of them; returns an array | +| **AskMultiChoice(question, style, options, label)** | the same, choosing how the options are labelled | + +```CSharp +var name = AskText("What should I call you?"); +var token = AskSecret("Paste a token:"); // nothing appears as it is typed +var retries = AskNumber("How many retries?", 1, 5); +var push = AskYesNo("Push straight to main?", false); + +string[] fruits = ["apple", "banana", "cherry"]; +var fruit = AskChoice("Pick a fruit:", fruits); // returns "banana", not 2 +var repo = AskChoice("Pick a repo:", repos, r => r.Name); // returns the Repo itself +var extra = AskMultiChoice("Choose your toppings:", toppings); +``` + +* **AskChoice** and **AskMultiChoice** are generic and return the option itself, not its position. + The optional selector says what to show for each; without one they use `ToString()`. +* With a console they draw arrow-key prompts; with input redirected they read a typed line. + `RichPrompts` forces either mode, `ReadKey` supplies the keystrokes. +* An option's own text is matched before its position, so a list of `"3", "1", "2"` answers the + way it reads. +* At end of stream they throw, naming the question, rather than returning an empty answer. + +`ChoiceStyle` sets the labels, and under `Letters` also what may be typed: + +| Style | Renders | A typed answer may be | +|------------|--------------------|------------------------------------| +| **Auto** | nothing with arrow keys, numbers when typed | the option's text, or its number | +| **Numbers**| `1) 2) 3)` | the option's text, or its number | +| **Letters**| `a) b) c)` | the option's text, or its letter | +| **None** | nothing | the option's text only | + +See **askdemo.csx** for a guided tour that shows each call and then runs it. + +### Command line +**Cli** declares what a script accepts and reads the command line against it. An **Argument** is a +positional, a **Switch** is on or off, an **Option** carries a value. The same three words read +the values back. + +```CSharp +var cmd = Cli.For(Args) + .Description("Opens a repository in GitHub Desktop.") + .OptionalArgument("path", "the repository to open; defaults to the current directory") + .WhatIf() + .Option("source", "the feed to use; defaults to nuget.org") + .Parse(); + +var path = cmd.Argument("path") ?? Directory.GetCurrentDirectory(); +var source = cmd.Option("source") ?? "https://api.nuget.org/v3/index.json"; +var whatIf = cmd.WhatIf; +``` + +| Declare on Cli | Description | +|------------------|--------------------------------------------------------------------------------------------------| +| **Argument(name, help)** | a required positional, filled in declaration order | +| **OptionalArgument(name, help)** | a positional that may be left out; reads back null | +| **Rest(name, help)** | a tail collecting everything left, verbatim | +| **Switch(name, help)** | a switch that is on or off; aliases go in the name after a pipe: `"whatif\|n"` | +| **Option(name, help)** | a switch carrying a value, written attached: `-out:file` | +| **WhatIf()** | declares the conventional dry run: `-whatif`, also `--dry-run` or `-n` | +| **Description(text)** | the paragraph shown above the usage line | +| **Example(commandLine, help)** | a worked example for the bottom of the help | +| **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 | + +| Read on CliResult | Description | +|------------------|--------------------------------------------------------------------------------------------------| +| **Argument(name)** | what was given for a positional, or null when an optional one was omitted | +| **Switch(name)** | whether a switch was given | +| **Option(name)** | the value given for an option, or null | +| **WhatIf** | whether the dry-run switch was given | +| **Rest** | everything the declared Rest collected | +| **Arguments** | every positional given, in order | +| **Error** | what was wrong with the command line, or null | +| **UsageText** | the generated help, whether or not it was shown | +| **ProgramName** | the name shown in the usage line | +| **ShouldExit** | *(TryParse only)* true when the script should stop | +| **ExitCode** | *(TryParse only)* 0 for help, 1 for a command line that was not valid | + +* Anything undeclared is an error. Bare words are positionals, not unknown switches. +* Values attach: `-out:file` or `-out=file`, never `-out file`. Only the name is normalized, so + `-source:https://api.nuget.org/v3/index.json` arrives intact. +* `-whatif`, `--whatif` and `--what-if` are one switch -- dashes come off, inner hyphens and + underscores go, case is ignored. `/` is not a prefix. +* `-help`, `-h` and `-?` work without being declared, and the help is generated from the + declarations. The program name comes from the calling script's file name. +* `Parse()` exits on a bad command line, so what it returns is always usable. `TryParse()` reports + through `ShouldExit`/`ExitCode` instead, and every other value on it throws until you check. +* No subcommands, repeated options, typed binding, 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 processes and piping data between them. CShell adds on location awareness and helper methods @@ -112,7 +223,7 @@ CShell adds on helper methods to make it even easier to work with the result of |------------------|------------------------------------------------------------------------------| | **Execute(log)** | get the CommandResult (with stdout/stderr) of the last command | | **AsString(log)** | get the standard out of the last command a string | -| **AsJson(log)** | JSON Deserialize the standard out of the last command into a JObject/dynamic | +| **AsJson(log)** | Parse the standard out of the last command as JSON: `json.owner.login`, `json["owner"]`, or assign it to a JsonObject | | **AsJson\(log)** | JSON Deserialize the standard out of the last command into a typed T | | **AsXml\(log)** | XML Deserialize the standard out of the last command intoa typed T | | **AsFile()** | Write the stdout/stderr of the last command to a file | @@ -123,6 +234,21 @@ To call a program you await on: 2. call any chaining commands 3. end with a result call like Execute()/AsJson()/AsString()/AsXml()etc. +`AsJson()` gives you a **JsonDynamic**, which reads whichever way suits the script: + +```CSharp +var json = await Cmd("gh api repos/tomlm/CShell").AsJson(); + +Console.WriteLine(json.owner.login); // walk it with a dot +Console.WriteLine(json["stargazers_count"]); +foreach (var topic in json.topics) { } // arrays enumerate + +JsonObject o = await Cmd("gh api repos/tomlm/CShell").AsJson(); // or take the typed API +``` + +A property that is not there reads as null, so `if (json.optional != null)` is how you test for +one. Use `AsJson()` where the shape is known. + The result methods all take a log argument is passed set to true then the commands output will be written to standard out. ```CSharp @@ -169,7 +295,7 @@ To invoke the template > NOTE: If you want debug support from visual studio code simply run **dotnet script init** in the same folder. ```csharp -#r "nuget: CShell, 2.1.0" +#r "nuget: CShell, 3.0.0" global using static CShellNet.Globals; using CShellNet; @@ -206,7 +332,7 @@ On Linux/Mac you can make a .csx file executable by ```bash #!/usr/bin/env dotnet-script -#r "nuget: CShell, 1.5.0" +#r "nuget: CShell, 3.0.0" global using static CShellNet.Globals; using CShellNet; ``` @@ -235,9 +361,22 @@ chmod +x example.csx ``` ## CHANGELOG +### v3.0.0 +* Added **Cli**, a declarative command line parser with generated --help + * Argument()/OptionalArgument()/Rest() for positionals, Switch() for booleans, Option() for attached values + * anything undeclared is an error; Parse() exits on a bad command line, TryParse() reports instead +* Added the **Ask** methods: AskText, AskSecret, AskYesNo, AskNumber, AskChoice, AskMultiChoice + * AskChoice/AskMultiChoice are generic and return the option itself rather than its position + * each has an arrow-key mode and a typed-line mode, chosen from whether input is redirected +* Added **RichPrompts** and **ReadKey** to control how the Ask methods read input +* **BREAKING** now targets net8.0 rather than netstandard2.0. .NET Framework is no longer supported +* **BREAKING** replaced Newtonsoft.Json with System.Text.Json; MedallionShell is now the only dependency + * `AsJson()` returns a **JsonDynamic** as `dynamic`, so `json.owner.login` still works. It also indexes, enumerates, and converts to JsonNode/JsonObject/JsonArray. `AsJson()` is unchanged + * property names still match case insensitively; trailing commas, comments and a byte order mark are tolerated + ### v2.1.0 * Added Write/WriteLine/print/error methods for writing to standard out and standard error - + ### V1.5.2 * Added Exists() methods to global diff --git a/Tests/CShell.Tests/Ask.Tests.cs b/Tests/CShell.Tests/Ask.Tests.cs new file mode 100644 index 0000000..0a062dc --- /dev/null +++ b/Tests/CShell.Tests/Ask.Tests.cs @@ -0,0 +1,828 @@ +using CShellNet; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace CShellLibTests +{ + /// + /// The Ask family, in both of the modes it has. + /// + /// + /// Every test sets RichPrompts explicitly rather than letting it decide for itself. Left to + /// auto it reads Console.IsInputRedirected, which is a property of whoever is running the + /// tests -- these would then pass under one runner and pick the other mode under the next. + /// + [TestClass] + public class AskTests + { + private TextWriter originalOut; + private TextReader originalIn; + private StringWriter captured; + + [TestInitialize] + public void Capture() + { + this.originalOut = Console.Out; + this.originalIn = Console.In; + this.captured = new StringWriter(); + Console.SetOut(this.captured); + } + + [TestCleanup] + public void Restore() + { + Console.SetOut(this.originalOut); + Console.SetIn(this.originalIn); + } + + private string Screen => this.captured.ToString(); + + /// A shell reading typed lines. + private static CShell Typing(params string[] lines) + { + Console.SetIn(new StringReader(String.Join(Environment.NewLine, lines) + Environment.NewLine)); + return new CShell { RichPrompts = false }; + } + + /// A shell reading the given keystrokes, and nothing after them. + private static CShell Pressing(params ConsoleKeyInfo[] keys) + { + var queue = new Queue(keys); + return new CShell + { + RichPrompts = true, + ReadKey = () => queue.Count > 0 + ? queue.Dequeue() + : throw new InvalidOperationException("the prompt asked for more keys than the test scripted"), + }; + } + + private static ConsoleKeyInfo Key(ConsoleKey key) => new ConsoleKeyInfo('\0', key, false, false, false); + + private static ConsoleKeyInfo Ch(char c) => new ConsoleKeyInfo(c, ConsoleKey.NoName, false, false, false); + + private static readonly ConsoleKeyInfo Enter = Key(ConsoleKey.Enter); + private static readonly ConsoleKeyInfo Up = Key(ConsoleKey.UpArrow); + private static readonly ConsoleKeyInfo Down = Key(ConsoleKey.DownArrow); + private static readonly ConsoleKeyInfo Left = Key(ConsoleKey.LeftArrow); + private static readonly ConsoleKeyInfo Right = Key(ConsoleKey.RightArrow); + private static readonly ConsoleKeyInfo Space = new ConsoleKeyInfo(' ', ConsoleKey.Spacebar, false, false, false); + + private static readonly string[] YesNoMaybe = new[] { "yes", "no", "maybe" }; + + // ------------------------------------------------------------------ AskText + + [TestMethod] + public void AskText_ReturnsWhatWasTyped() + { + Assert.AreEqual("Tom", Typing("Tom").AskText("Name?")); + } + + [TestMethod] + public void AskText_TrimsAndAsksAsWritten() + { + Assert.AreEqual("Tom", Typing(" Tom ").AskText("Name?")); + StringAssert.Contains(this.Screen, "Name?"); + } + + [TestMethod] + public void AskText_EmptyAnswerIsAnAnswer() + { + Assert.AreEqual(String.Empty, Typing("").AskText("Name?")); + } + + [TestMethod] + public void AskText_ThrowsAtEndOfStream() + { + Console.SetIn(new StringReader(String.Empty)); + var shell = new CShell { RichPrompts = false }; + + var thrown = Assert.Throws(() => shell.AskText("Name?")); + StringAssert.Contains(thrown.Message, "Name?"); + StringAssert.Contains(thrown.Message, "end of stream"); + } + + // ------------------------------------------------------------------ AskSecret + + [TestMethod] + public void AskSecret_ReadsKeysWithoutEchoingThem() + { + var shell = Pressing(Ch('h'), Ch('u'), Ch('n'), Ch('t'), Ch('2'), Enter); + + Assert.AreEqual("hunt2", shell.AskSecret("Password?")); + StringAssert.Contains(this.Screen, "Password?"); + Assert.IsFalse(this.Screen.Contains("hunt2"), "the secret must never reach the screen"); + } + + [TestMethod] + public void AskSecret_BackspaceErases() + { + var shell = Pressing(Ch('a'), Ch('b'), Key(ConsoleKey.Backspace), Ch('c'), Enter); + + Assert.AreEqual("ac", shell.AskSecret("Password?")); + } + + [TestMethod] + public void AskSecret_IgnoresKeysThatCarryNoCharacter() + { + var shell = Pressing(Ch('a'), Key(ConsoleKey.F1), Up, Ch('b'), Enter); + + Assert.AreEqual("ab", shell.AskSecret("Password?")); + } + + [TestMethod] + public void AskSecret_FallsBackToALineWhenThereAreNoKeys() + { + // The case that matters: piped or CI input, where Console.ReadKey() would throw. + Assert.AreEqual("sk-ant-oat01", Typing(" sk-ant-oat01 ").AskSecret("Token?")); + } + + // ------------------------------------------------------------------ AskChoice, typed + + [TestMethod] + public void AskChoice_TypedNumberChoosesByPosition() + { + Assert.AreEqual("no", Typing("2").AskChoice("Continue?", YesNoMaybe)); + } + + [TestMethod] + public void AskChoice_TypedTextChoosesByName() + { + Assert.AreEqual("maybe", Typing("MAYBE").AskChoice("Continue?", YesNoMaybe)); + } + + [TestMethod] + public void AskChoice_RejectedAnswerAsksAgain() + { + Assert.AreEqual("yes", Typing("nope", "1").AskChoice("Continue?", YesNoMaybe)); + StringAssert.Contains(this.Screen, "'nope' is not one of the above."); + } + + [TestMethod] + public void AskChoice_EmptyAnswerAsksAgain() + { + Assert.AreEqual("yes", Typing("", "1").AskChoice("Continue?", YesNoMaybe)); + StringAssert.Contains(this.Screen, "Pick one of the above."); + } + + [TestMethod] + public void AskChoice_OptionTextBeatsPositionNumber() + { + // The list reads 1) 3 2) 1 3) 2. Typing 3 must pick the option LABELLED 3, which + // is the first, not the third. Position-first matching silently picked "2" here. + var numbered = new[] { "3", "1", "2" }; + + Assert.AreEqual("3", Typing("3").AskChoice("Pick:", numbered)); + } + + [TestMethod] + public void AskChoice_LettersAreAnsweredByLetter() + { + Assert.AreEqual("maybe", Typing("c").AskChoice("Continue?", ChoiceStyle.Letters, YesNoMaybe)); + StringAssert.Contains(this.Screen, "c) maybe"); + StringAssert.Contains(this.Screen, "[a-c]"); + } + + [TestMethod] + public void AskChoice_LettersDoNotAnswerToNumbers() + { + // Under Letters a bare 2 names nothing, so it is rejected rather than quietly + // taken as a position. + Assert.AreEqual("no", Typing("2", "b").AskChoice("Continue?", ChoiceStyle.Letters, YesNoMaybe)); + StringAssert.Contains(this.Screen, "'2' is not one of the above."); + } + + [TestMethod] + public void AskChoice_NoneIsAnsweredByTextAlone() + { + // Nothing is printed in front of the options, so a position number names nothing: + // it is refused, and the prompt asks with "> " rather than advertising "[1-3]" over + // a list with no numbers in it. + Assert.AreEqual("no", Typing("2", "no").AskChoice("Continue?", ChoiceStyle.None, YesNoMaybe)); + + StringAssert.Contains(this.Screen, " yes"); + StringAssert.Contains(this.Screen, "> "); + Assert.IsFalse(this.Screen.Contains("[1-3]"), "None must not ask for numbers it never showed"); + StringAssert.Contains(this.Screen, "'2' is not one of the above."); + } + + [TestMethod] + public void AskChoice_NoOptionsIsAMistake() + { + var shell = new CShell { RichPrompts = false }; + + Assert.Throws(() => shell.AskChoice("Pick:", new string[0])); + } + + [TestMethod] + public void AskChoice_TooManyToLetterIsAMistake() + { + var shell = new CShell { RichPrompts = false }; + var tooMany = new string[27]; + for (int i = 0; i < tooMany.Length; i++) + { + tooMany[i] = "option" + i; + } + + var thrown = Assert.Throws( + () => shell.AskChoice("Pick:", ChoiceStyle.Letters, tooMany)); + StringAssert.Contains(thrown.Message, "26 letters"); + } + + // ------------------------------------------------------------------ ChoiceStyle.Auto + + [TestMethod] + public void Auto_NumbersTheListWhenTheAnswerMustBeTyped() + { + // Without keys the label is the only thing saying what to type. A bare list under a + // "[1-3]" prompt would leave you counting rows. + Assert.AreEqual("no", Typing("2").AskChoice("Continue?", ChoiceStyle.Auto, YesNoMaybe)); + + StringAssert.Contains(this.Screen, "1) yes"); + StringAssert.Contains(this.Screen, "[1-3]"); + } + + [TestMethod] + public void Auto_LeavesTheListBareWhenThereAreArrowKeys() + { + // The selection is the affordance; a label on every row is noise. + Assert.AreEqual("no", Pressing(Down, Enter).AskChoice("Continue?", ChoiceStyle.Auto, YesNoMaybe)); + + Assert.IsFalse(this.Screen.Contains("1) yes"), "Auto should not number a list you can arrow through"); + StringAssert.Contains(this.Screen, "[no]"); + } + + [TestMethod] + public void Auto_IsWhatTheShortOverloadPasses() + { + Pressing(Enter).AskChoice("Continue?", YesNoMaybe); + Assert.IsFalse(this.Screen.Contains("1) yes"), "the short overload should be Auto, not Numbers"); + + Capture(); + Typing("1").AskChoice("Continue?", YesNoMaybe); + StringAssert.Contains(this.Screen, "1) yes"); + } + + [TestMethod] + public void Auto_DoesNotChangeWhatATypedAnswerMeans() + { + // Position numbers still answer, and option text still beats them. + Assert.AreEqual("maybe", Typing("3").AskChoice("Continue?", ChoiceStyle.Auto, YesNoMaybe)); + + Capture(); + Assert.AreEqual("3", Typing("3").AskChoice("Pick:", ChoiceStyle.Auto, new[] { "3", "1", "2" })); + } + + [TestMethod] + public void Auto_AppliesToMultiChoiceToo() + { + CollectionAssert.AreEqual( + new[] { "yes", "maybe" }, + Typing("1,3").AskMultiChoice("Pick some:", ChoiceStyle.Auto, YesNoMaybe)); + StringAssert.Contains(this.Screen, "1) yes"); + + Capture(); + var picked = Pressing(Space, Enter).AskMultiChoice("Pick some:", YesNoMaybe); + CollectionAssert.AreEqual(new[] { "yes" }, picked); + Assert.IsFalse(this.Screen.Contains("1) "), "the short overload should be Auto here as well"); + } + + [TestMethod] + public void None_StaysBareEvenWhenTheAnswerMustBeTyped() + { + // Auto is the one that adapts. None is the explicit "I really do want a bare list", + // and answering it means naming an option. + Assert.AreEqual("no", Typing("no").AskChoice("Continue?", ChoiceStyle.None, YesNoMaybe)); + + Assert.IsFalse(this.Screen.Contains("1) yes")); + StringAssert.Contains(this.Screen, " yes"); + } + + // ------------------------------------------------------------------ generic options + + private record Repo(string Name, int Stars); + + private static Repo[] Repos => new[] + { + new Repo("cshell", 42), + new Repo("scripts", 7), + new Repo("crazor", 99), + }; + + [TestMethod] + public void AskChoice_ReturnsTheOptionItselfNotItsPosition() + { + var chosen = Typing("scripts").AskChoice("Pick a repo:", Repos, r => r.Name); + + Assert.AreEqual("scripts", chosen.Name); + Assert.AreEqual(7, chosen.Stars); + } + + [TestMethod] + public void AskChoice_LabelIsWhatIsShownAndWhatIsTyped() + { + // Without the selector these would be shown as "Repo { Name = ... }" and would have + // to be answered that way too. The label governs both. + var chosen = Typing("crazor").AskChoice("Pick a repo:", Repos, r => r.Name); + + Assert.AreEqual("crazor", chosen.Name); + StringAssert.Contains(this.Screen, "1) cshell"); + Assert.IsFalse(this.Screen.Contains("Stars ="), "the selector should decide what is shown"); + } + + [TestMethod] + public void AskChoice_WithoutASelectorItUsesToString() + { + Assert.AreEqual(7, Typing("7").AskChoice("Pick a number:", new[] { 42, 7, 99 })); + } + + [TestMethod] + public void AskChoice_TakesAnyEnumerableNotJustAnArray() + { + // A List would have collapsed into a single option under a params signature. + var names = new List { "alpha", "beta" }; + + Assert.AreEqual("beta", Typing("beta").AskChoice("Pick:", names)); + + Capture(); + var lazy = Repos.Where(r => r.Stars > 10); + Assert.AreEqual("crazor", Typing("crazor").AskChoice("Pick:", lazy, r => r.Name).Name); + } + + [TestMethod] + public void AskMultiChoice_ReturnsTheOptionsThemselves() + { + var chosen = Typing("cshell, crazor").AskMultiChoice("Pick repos:", Repos, r => r.Name); + + CollectionAssert.AreEqual(new[] { "cshell", "crazor" }, chosen.Select(r => r.Name).ToArray()); + Assert.AreEqual(42, chosen[0].Stars); + } + + [TestMethod] + public void AskChoice_NullOptionsIsAMistake() + { + var shell = new CShell { RichPrompts = false }; + + Assert.Throws(() => shell.AskChoice("Pick:", (IEnumerable)null)); + } + + [TestMethod] + public void AskChoice_ANullOptionLabelsAsEmptyRatherThanThrowing() + { + // A hole in the list is something to see on screen, not a reason to take the prompt + // down mid-question. + Assert.AreEqual("b", Typing("2").AskChoice("Pick:", new[] { null, "b" })); + } + + [TestMethod] + public void AskChoice_NoneOffersNothingToJumpToWithAKey() + { + // The consequence of the rule, in the mode Auto picks when there are keys: with no + // numbers on screen, a digit names nothing and the selection stays put. The arrow + // keys are the way around a bare list. + Assert.AreEqual("yes", Pressing(Ch('3'), Enter).AskChoice("Continue?", ChoiceStyle.None, YesNoMaybe)); + } + + // ------------------------------------------------------------------ AskChoice, keys + + [TestMethod] + public void AskChoice_EnterTakesTheFirstOption() + { + Assert.AreEqual("yes", Pressing(Enter).AskChoice("Continue?", YesNoMaybe)); + } + + [TestMethod] + public void AskChoice_DownArrowMovesTheSelection() + { + Assert.AreEqual("maybe", Pressing(Down, Down, Enter).AskChoice("Continue?", YesNoMaybe)); + } + + [TestMethod] + public void AskChoice_SelectionWraps() + { + Assert.AreEqual("maybe", Pressing(Up, Enter).AskChoice("Continue?", YesNoMaybe)); + Assert.AreEqual("yes", Pressing(Down, Down, Down, Enter).AskChoice("Continue?", YesNoMaybe)); + } + + [TestMethod] + public void AskChoice_HomeAndEndJumpToTheEnds() + { + Assert.AreEqual("maybe", Pressing(Key(ConsoleKey.End), Enter).AskChoice("Continue?", YesNoMaybe)); + Assert.AreEqual("yes", Pressing(Down, Key(ConsoleKey.Home), Enter).AskChoice("Continue?", YesNoMaybe)); + } + + [TestMethod] + public void AskChoice_SelectionIsDrawnInBrackets() + { + Pressing(Down, Enter).AskChoice("Continue?", YesNoMaybe); + + StringAssert.Contains(this.Screen, "[no]"); + } + + [TestMethod] + public void AskChoice_TypingAMarkerJumpsButStillWaitsForEnter() + { + // '3' moves to the third option; without the enter this would not return at all. + // Explicitly Numbers: under a bare list there is no "3" on screen to jump to. + Assert.AreEqual("maybe", Pressing(Ch('3'), Enter).AskChoice("Continue?", ChoiceStyle.Numbers, YesNoMaybe)); + } + + // ------------------------------------------------------------------ AskMultiChoice, typed + + [TestMethod] + public void AskMultiChoice_TakesACommaSeparatedList() + { + CollectionAssert.AreEqual(new[] { "yes", "maybe" }, Typing("1,3").AskMultiChoice("Pick some:", YesNoMaybe)); + } + + [TestMethod] + public void AskMultiChoice_MixesTextAndNumbers() + { + CollectionAssert.AreEqual(new[] { "no", "maybe" }, Typing("no, 3").AskMultiChoice("Pick some:", YesNoMaybe)); + } + + [TestMethod] + public void AskMultiChoice_ResultIsInListOrderAndDistinct() + { + CollectionAssert.AreEqual(new[] { "yes", "no" }, Typing("2,1,2").AskMultiChoice("Pick some:", YesNoMaybe)); + } + + [TestMethod] + public void AskMultiChoice_BlankChoosesNothing() + { + Assert.AreEqual(0, Typing("").AskMultiChoice("Pick some:", YesNoMaybe).Length); + } + + [TestMethod] + public void AskMultiChoice_OneBadPartRejectsTheWholeAnswer() + { + // Not "select the ones I understood" -- a partial selection nobody asked for is + // worse than asking again. + CollectionAssert.AreEqual(new[] { "yes" }, Typing("1,nope", "1").AskMultiChoice("Pick some:", YesNoMaybe)); + StringAssert.Contains(this.Screen, "'nope' is not one of the above."); + } + + [TestMethod] + public void AskMultiChoice_SplitsOnCommasOnlySoNamesMayHaveSpaces() + { + var cities = new[] { "New York", "San Jose" }; + + CollectionAssert.AreEqual(new[] { "New York", "San Jose" }, Typing("New York, San Jose").AskMultiChoice("Where?", cities)); + } + + [TestMethod] + public void AskMultiChoice_OptionTextBeatsPositionNumber() + { + var numbered = new[] { "3", "1", "2" }; + + CollectionAssert.AreEqual(new[] { "3" }, Typing("3").AskMultiChoice("Pick some:", numbered)); + } + + [TestMethod] + public void AskMultiChoice_LettersAreAnsweredByLetter() + { + CollectionAssert.AreEqual( + new[] { "yes", "maybe" }, + Typing("a,c").AskMultiChoice("Pick some:", ChoiceStyle.Letters, YesNoMaybe)); + } + + [TestMethod] + public void AskMultiChoice_NoOptionsIsAMistake() + { + var shell = new CShell { RichPrompts = false }; + + Assert.Throws(() => shell.AskMultiChoice("Pick some:", new string[0])); + } + + // ------------------------------------------------------------------ AskMultiChoice, keys + + [TestMethod] + public void AskMultiChoice_SpaceChecksTheOptionUnderTheCursor() + { + var shell = Pressing(Space, Down, Down, Space, Enter); + + CollectionAssert.AreEqual(new[] { "yes", "maybe" }, shell.AskMultiChoice("Pick some:", YesNoMaybe)); + } + + [TestMethod] + public void AskMultiChoice_SpaceTogglesBackOff() + { + var shell = Pressing(Space, Space, Down, Space, Enter); + + CollectionAssert.AreEqual(new[] { "no" }, shell.AskMultiChoice("Pick some:", YesNoMaybe)); + } + + [TestMethod] + public void AskMultiChoice_EnterWithNothingCheckedChoosesNothing() + { + Assert.AreEqual(0, Pressing(Enter).AskMultiChoice("Pick some:", YesNoMaybe).Length); + } + + [TestMethod] + public void AskMultiChoice_CursorAndChecksAreDrawnSeparately() + { + Pressing(Space, Down, Enter).AskMultiChoice("Pick some:", YesNoMaybe); + + // The checked first option, and the cursor now resting on the unchecked second. + StringAssert.Contains(this.Screen, "[x] yes"); + StringAssert.Contains(this.Screen, "> "); + StringAssert.Contains(this.Screen, "[ ] no"); + } + + [TestMethod] + public void AskMultiChoice_CursorWraps() + { + var shell = Pressing(Up, Space, Enter); + + CollectionAssert.AreEqual(new[] { "maybe" }, shell.AskMultiChoice("Pick some:", YesNoMaybe)); + } + + [TestMethod] + public void AskMultiChoice_TypingAMarkerMovesTheCursorWithoutChecking() + { + // '3' jumps to the third option; only the space that follows checks it. + var shell = Pressing(Ch('3'), Space, Enter); + + CollectionAssert.AreEqual( + new[] { "maybe" }, + shell.AskMultiChoice("Pick some:", ChoiceStyle.Numbers, YesNoMaybe)); + } + + // ------------------------------------------------------------------ AskNumber, typed + + [TestMethod] + public void AskNumber_ReturnsTheNumberTyped() + { + Assert.AreEqual(42, Typing("42").AskNumber("How many?")); + } + + [TestMethod] + public void AskNumber_UnboundedStillShowsAPrompt() + { + Typing("-42").AskNumber("How many?"); + + // A bare cursor under a question reads as a hang rather than a prompt. + StringAssert.Contains(this.Screen, "> "); + } + + [TestMethod] + public void AskNumber_OutOfRangeAsksAgain() + { + Assert.AreEqual(3, Typing("9", "3").AskNumber("How many?", 1, 5)); + StringAssert.Contains(this.Screen, "9 is outside 1 to 5."); + } + + [TestMethod] + public void AskNumber_NotANumberAsksAgain() + { + Assert.AreEqual(3, Typing("three", "3").AskNumber("How many?", 1, 5)); + StringAssert.Contains(this.Screen, "'three' is not a number."); + } + + [TestMethod] + public void AskNumber_EmptyRangeIsAMistake() + { + var shell = new CShell { RichPrompts = false }; + + Assert.Throws(() => shell.AskNumber("How many?", 5, 1)); + } + + // ------------------------------------------------------------------ AskNumber, keys + + [TestMethod] + public void AskNumber_ArrowsStepTheValue() + { + Assert.AreEqual(3, Pressing(Up, Up, Up, Enter).AskNumber("How many?", 0, 10)); + Assert.AreEqual(1, Pressing(Up, Up, Down, Enter).AskNumber("How many?", 0, 10)); + } + + [TestMethod] + public void AskNumber_ArrowsAreHeldToTheRange() + { + // Starts clamped into range at 1, and cannot be stepped below it. + Assert.AreEqual(1, Pressing(Down, Down, Down, Enter).AskNumber("How many?", 1, 5)); + Assert.AreEqual(5, Pressing(Up, Up, Up, Up, Up, Up, Up, Enter).AskNumber("How many?", 1, 5)); + } + + [TestMethod] + public void AskNumber_DigitsAreTyped() + { + Assert.AreEqual(12, Pressing(Key(ConsoleKey.Backspace), Ch('1'), Ch('2'), Enter).AskNumber("How many?", 0, 99)); + } + + [TestMethod] + public void AskNumber_EnterIsRefusedWhileTheValueIsOutOfRange() + { + // 7 is outside 1-5, so the first enter does nothing; backspace then 4 makes it valid. + var shell = Pressing(Ch('7'), Enter, Key(ConsoleKey.Backspace), Key(ConsoleKey.Backspace), Ch('4'), Enter); + + Assert.AreEqual(4, shell.AskNumber("How many?", 1, 5)); + } + + // ------------------------------------------------------------------ AskYesNo, typed + + [TestMethod] + public void AskYesNo_AnswersYesAndNo() + { + Assert.IsTrue(Typing("y").AskYesNo("Sure?")); + Assert.IsTrue(Typing("YES").AskYesNo("Sure?")); + Assert.IsFalse(Typing("n").AskYesNo("Sure?")); + Assert.IsFalse(Typing("No").AskYesNo("Sure?")); + } + + [TestMethod] + public void AskYesNo_EnterTakesTheDefault() + { + Assert.IsFalse(Typing("").AskYesNo("Push to main?", false)); + Assert.IsTrue(Typing("").AskYesNo("Keep the backup?", true)); + } + + [TestMethod] + public void AskYesNo_ShowsTheDefaultCapitalised() + { + Typing("").AskYesNo("Push to main?", false); + StringAssert.Contains(this.Screen, "[y/N]"); + + Capture(); + Typing("").AskYesNo("Keep the backup?", true); + StringAssert.Contains(this.Screen, "[Y/n]"); + } + + [TestMethod] + public void AskYesNo_WithNoDefaultEnterAsksAgain() + { + Assert.IsTrue(Typing("", "y").AskYesNo("Sure?")); + StringAssert.Contains(this.Screen, "[y/n]"); + StringAssert.Contains(this.Screen, "Answer y or n."); + } + + // ------------------------------------------------------------------ AskYesNo, keys + + [TestMethod] + public void AskYesNo_KeysMoveTheSelectionAndEnterTakesIt() + { + Assert.IsTrue(Pressing(Ch('y'), Enter).AskYesNo("Sure?")); + Assert.IsFalse(Pressing(Ch('n'), Enter).AskYesNo("Sure?")); + } + + [TestMethod] + public void AskYesNo_AKeyOnItsOwnDoesNotAnswer() + { + // Pressing() throws once the scripted keys run out, which is only reachable if 'y' + // did not answer on its own. One keystroke is never enough to answer a question. + var thrown = Assert.Throws(() => Pressing(Ch('y')).AskYesNo("Sure?")); + + StringAssert.Contains(thrown.Message, "more keys than the test scripted"); + } + + [TestMethod] + public void AskYesNo_KeysCanBeChangedBeforeEnter() + { + // Reached for y, thought better of it. Nothing was committed on the way. + Assert.IsFalse(Pressing(Ch('y'), Ch('n'), Enter).AskYesNo("Delete everything?", false)); + } + + [TestMethod] + public void AskYesNo_ArrowsMoveBetweenThem() + { + Assert.IsFalse(Pressing(Right, Enter).AskYesNo("Push to main?", true)); + Assert.IsTrue(Pressing(Left, Enter).AskYesNo("Push to main?", false)); + } + + [TestMethod] + public void AskYesNo_EnterTakesTheSelectedSide() + { + Assert.IsTrue(Pressing(Enter).AskYesNo("Keep the backup?", true)); + Assert.IsFalse(Pressing(Enter).AskYesNo("Push to main?", false)); + } + + [TestMethod] + public void AskYesNo_SelectionIsDrawnInBrackets() + { + Pressing(Enter).AskYesNo("Push to main?", false); + + StringAssert.Contains(this.Screen, "[No]"); + } + + [TestMethod] + public void AskYesNo_IgnoresKeysThatMeanNothing() + { + Assert.IsTrue(Pressing(Key(ConsoleKey.F1), Ch('q'), Enter).AskYesNo("Sure?", true)); + } + + // ------------------------------------------------------------------ the rich-path gaps + + [TestMethod] + public void AskNumber_MinusSignTypesANegative() + { + // The value starts at 0, so the minus has to follow a backspace. askdemo tells people + // to try this, and nothing was checking it worked. + var shell = Pressing(Key(ConsoleKey.Backspace), Ch('-'), Ch('4'), Ch('2'), Enter); + + Assert.AreEqual(-42, shell.AskNumber("Any whole number?")); + } + + [TestMethod] + public void AskNumber_MinusSignOnlyLeads() + { + // '4', then '-', then '2'. The minus arrives with digits already typed and is + // dropped rather than landing in the middle of the number. + var shell = Pressing(Ch('4'), Ch('-'), Ch('2'), Enter); + + Assert.AreEqual(42, shell.AskNumber("Any whole number?")); + } + + [TestMethod] + public void AskNumber_UnboundedTakesKeysAndShowsNoRange() + { + Assert.AreEqual(2, Pressing(Up, Up, Enter).AskNumber("Any whole number?")); + + StringAssert.Contains(this.Screen, "Any whole number?"); + Assert.IsFalse(this.Screen.Contains("["), "an unbounded ask has no range to advertise"); + } + + [TestMethod] + public void AskNumber_UnboundedArrowsGoNegative() + { + Assert.AreEqual(-2, Pressing(Down, Down, Enter).AskNumber("Any whole number?")); + } + + [TestMethod] + public void AskMultiChoice_HomeAndEndJumpToTheEnds() + { + var shell = Pressing(Key(ConsoleKey.End), Space, Key(ConsoleKey.Home), Space, Enter); + + CollectionAssert.AreEqual(new[] { "yes", "maybe" }, shell.AskMultiChoice("Pick some:", YesNoMaybe)); + } + + [TestMethod] + public void AskYesNo_TabMovesBetweenThemToo() + { + Assert.IsFalse(Pressing(Key(ConsoleKey.Tab), Enter).AskYesNo("Push to main?", true)); + Assert.IsTrue(Pressing(Key(ConsoleKey.Tab), Enter).AskYesNo("Push to main?", false)); + } + + [TestMethod] + public void AskChoice_LettersJumpByLetterWhenThereAreKeys() + { + Assert.AreEqual("maybe", Pressing(Ch('c'), Enter).AskChoice("Continue?", ChoiceStyle.Letters, YesNoMaybe)); + + // An explicit style labels the list even in the mode Auto would have left bare. + StringAssert.Contains(this.Screen, "c) "); + } + + [TestMethod] + public void AskMultiChoice_TakesAStyleWhenThereAreKeys() + { + var shell = Pressing(Ch('b'), Space, Enter); + + CollectionAssert.AreEqual( + new[] { "no" }, + shell.AskMultiChoice("Pick some:", ChoiceStyle.Letters, YesNoMaybe)); + StringAssert.Contains(this.Screen, "b) "); + } + + [TestMethod] + public void AskSecret_TrimsWhatWasTypedToo() + { + // The line-reading fallback trims; so does the key path, so a pasted token with a + // stray space either side behaves the same whichever mode caught it. + var shell = Pressing(Ch(' '), Ch('a'), Ch('b'), Ch(' '), Enter); + + Assert.AreEqual("ab", shell.AskSecret("Token?")); + } + + [TestMethod] + public void AskChoice_ASelectorReturningNullLabelsAsEmpty() + { + // Blank rows on screen, but still answerable by position, and no exception out of + // the middle of a prompt. + Assert.AreEqual(2, Typing("2").AskChoice("Pick:", new[] { 1, 2 }, x => null)); + } + + [TestMethod] + public void AskMultiChoice_SaysItsOwnNameWhenTheOptionsAreImpossible() + { + // Shared validation, but the message names the method the caller actually called. + var shell = new CShell { RichPrompts = false }; + + var nothing = Assert.Throws( + () => shell.AskMultiChoice("Pick some:", (IEnumerable)null)); + StringAssert.Contains(nothing.Message, "AskMultiChoice"); + + var tooMany = new string[27]; + for (int i = 0; i < tooMany.Length; i++) + { + tooMany[i] = "option" + i; + } + + var lettered = Assert.Throws( + () => shell.AskMultiChoice("Pick some:", ChoiceStyle.Letters, tooMany)); + StringAssert.Contains(lettered.Message, "AskMultiChoice"); + StringAssert.Contains(lettered.Message, "26 letters"); + } + } +} diff --git a/Tests/CShell.Tests/CShell.Tests.cs b/Tests/CShell.Tests/CShell.Tests.cs index 121a869..f00071c 100644 --- a/Tests/CShell.Tests/CShell.Tests.cs +++ b/Tests/CShell.Tests/CShell.Tests.cs @@ -1,4 +1,4 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.IO; using System.Linq; @@ -17,7 +17,7 @@ public class CShellTests [ClassInitialize()] public static void ClassInit(TestContext context) { - testFolder = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), @"..\..\..\test")); + testFolder = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "..", "..", "..", "test")); subFolder = Path.Combine(testFolder, "subfolder"); subFolder2 = Path.Combine(subFolder, "subfolder2"); } @@ -97,12 +97,12 @@ public void Test_ChangeFolder() Environment.CurrentDirectory = testFolder; var shell = new CShell(); - shell.cd(@"subfolder\subfolder2"); + shell.cd(Path.Combine("subfolder", "subfolder2")); Assert.AreEqual(subFolder2, shell.CurrentFolder.FullName, "currentFolder relative path failed"); - shell.cd(@"..\subfolder2"); + shell.cd(Path.Combine("..", "subfolder2")); Assert.AreEqual(subFolder2, shell.CurrentFolder.FullName, "currentFolder relative path failed2"); Assert.AreEqual(2, shell.FolderHistory.Count, "relative non-navigation shouldn't have created history record"); - shell.cd(@"..\.."); + shell.cd(Path.Combine("..", "..")); Assert.AreEqual(testFolder, shell.CurrentFolder.FullName, "currentFolder relative path failed3"); Assert.AreEqual(3, shell.FolderHistory.Count, "history ignored on relative path"); shell.cd(subFolder2); @@ -224,7 +224,7 @@ public async Task Test_echo() Assert.AreEqual("test", result); var result2 = shell.echo(new string[] { "test1", "test2", "test3" }); var x = await result2.StandardOutput.ReadToEndAsync(); - Assert.AreEqual("test1\r\ntest2\r\ntest3\r\n", x); + Assert.AreEqual($"test1{Environment.NewLine}test2{Environment.NewLine}test3{Environment.NewLine}", x); } [TestMethod] diff --git a/Tests/CShell.Tests/CShellGlobal.Tests.cs b/Tests/CShell.Tests/CShellGlobal.Tests.cs index e4d37a2..c636e1a 100644 --- a/Tests/CShell.Tests/CShellGlobal.Tests.cs +++ b/Tests/CShell.Tests/CShellGlobal.Tests.cs @@ -1,4 +1,4 @@ -global using static CShellNet.Globals; +global using static CShellNet.Globals; global using CShellNet; using Microsoft.VisualStudio.TestTools.UnitTesting; using System; @@ -19,7 +19,7 @@ public class CShellGlobalsTests [ClassInitialize()] public static void ClassInit(TestContext context) { - testFolder = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), @"..\..\..\test")); + testFolder = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "..", "..", "..", "test")); subFolder = Path.Combine(testFolder, "subfolder"); subFolder2 = Path.Combine(subFolder, "subfolder2"); } @@ -77,12 +77,12 @@ public void Test_Global_ChangeFolder() { ResetShell(testFolder); - cd(@"subfolder\subfolder2"); + cd(Path.Combine("subfolder", "subfolder2")); Assert.AreEqual(subFolder2, CurrentFolder.FullName, "currentFolder relative path failed"); - cd(@"..\subfolder2"); + cd(Path.Combine("..", "subfolder2")); Assert.AreEqual(subFolder2, CurrentFolder.FullName, "currentFolder relative path failed2"); Assert.AreEqual(2, FolderHistory.Count, "relative non-navigation shouldn't have created history record"); - cd(@"..\.."); + cd(Path.Combine("..", "..")); Assert.AreEqual(testFolder, CurrentFolder.FullName, "currentFolder relative path failed3"); Assert.AreEqual(3, FolderHistory.Count, "history ignored on relative path"); cd(subFolder2); @@ -99,7 +99,7 @@ public async Task Test_Global_echo() Assert.AreEqual("test", result); var result2 = shell.echo(new string[] { "test1", "test2", "test3" }); var x = await result2.StandardOutput.ReadToEndAsync(); - Assert.AreEqual("test1\r\ntest2\r\ntest3\r\n", x); + Assert.AreEqual($"test1{Environment.NewLine}test2{Environment.NewLine}test3{Environment.NewLine}", x); } [TestMethod] diff --git a/Tests/CShell.Tests/Cli.Tests.cs b/Tests/CShell.Tests/Cli.Tests.cs new file mode 100644 index 0000000..7063232 --- /dev/null +++ b/Tests/CShell.Tests/Cli.Tests.cs @@ -0,0 +1,802 @@ +using CShellNet; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace CShellLibTests +{ + /// + /// Cli -- what a script declares it accepts, and how a command line is read against it. + /// + /// + /// Every test names the program explicitly. Left alone, Cli.For() takes the name from the + /// calling file via [CallerFilePath], which here would be this test file -- so assertions + /// about messages would be asserting on "Cli.Tests". + /// + [TestClass] + public class CliTests + { + private TextWriter originalOut; + private TextWriter originalError; + private StringWriter captured; + private StringWriter capturedErrors; + + [TestInitialize] + public void Capture() + { + this.originalOut = Console.Out; + this.originalError = Console.Error; + this.captured = new StringWriter(); + this.capturedErrors = new StringWriter(); + Console.SetOut(this.captured); + Console.SetError(this.capturedErrors); + } + + [TestCleanup] + public void Restore() + { + Console.SetOut(this.originalOut); + Console.SetError(this.originalError); + } + + private string Screen => this.captured.ToString(); + + private string Errors => this.capturedErrors.ToString(); + + private static Cli Given(params string[] args) => Cli.For(args).Program("demo"); + + // ------------------------------------------------------------------ switches + + [TestMethod] + public void Switch_IsTrueWhenGivenAndFalseWhenAbsent() + { + Assert.IsTrue(Given("-whatif").Switch("whatif", "touch nothing").TryParse().Switch("whatif")); + Assert.IsFalse(Given().Switch("whatif", "touch nothing").TryParse().Switch("whatif")); + } + + [TestMethod] + public void Switch_AcceptsEitherDashPrefix() + { + foreach (var spelling in new[] { "-whatif", "--whatif" }) + { + Assert.IsTrue(Given(spelling).Switch("whatif", "touch nothing").TryParse().Switch("whatif"), spelling); + } + } + + [TestMethod] + public void Switch_IgnoresCaseHyphensAndUnderscores() + { + foreach (var spelling in new[] { "--DRY-RUN", "--dryrun", "-Dry_Run", "--d-r-y-r-u-n" }) + { + Assert.IsTrue(Given(spelling).Switch("dry-run", "print only").TryParse().Switch("dry-run"), spelling); + } + } + + [TestMethod] + public void Switch_AliasesAfterThePipeSetTheSameSwitch() + { + foreach (var spelling in new[] { "-whatif", "--dry-run", "-n" }) + { + Assert.IsTrue(Given(spelling).Switch("whatif|dry-run|n", "touch nothing").TryParse().Switch("whatif"), spelling); + } + + // and it reads back under any of its names + var cmd = Given("-n").Switch("whatif|dry-run|n", "touch nothing").TryParse(); + Assert.IsTrue(cmd.Switch("dry-run")); + } + + [TestMethod] + public void Switch_RepeatedIsStillJustTrue() + { + Assert.IsTrue(Given("-whatif", "--whatif").Switch("whatif", "touch nothing").TryParse().Switch("whatif")); + } + + [TestMethod] + public void Switch_GivenAValueIsAnError() + { + var cmd = Given("-whatif:true").Switch("whatif", "touch nothing").TryParse(); + + Assert.IsTrue(cmd.ShouldExit); + Assert.AreEqual(1, cmd.ExitCode); + StringAssert.Contains(this.Errors, "takes no value"); + } + + [TestMethod] + public void Switch_ReadingAnUndeclaredNameThrowsAndSaysWhatWasDeclared() + { + var cmd = Given().Switch("whatif", "touch nothing").TryParse(); + + var thrown = Assert.Throws(() => cmd.Switch("nopush")); + StringAssert.Contains(thrown.Message, "nopush"); + StringAssert.Contains(thrown.Message, "whatif"); + } + + // ------------------------------------------------------------------ declaration mistakes + + [TestMethod] + public void Declaring_AHelpTextThatLooksLikeAnAliasThrows() + { + // Switch("whatif", "n") -- the classic. Silently making "n" the help text is exactly + // the quiet mistake this type exists to prevent, so it is refused. + var thrown = Assert.Throws(() => Given().Switch("whatif", "n")); + + StringAssert.Contains(thrown.Message, "help text"); + StringAssert.Contains(thrown.Message, "whatif|n"); + } + + [TestMethod] + public void Declaring_ANameWithItsPrefixThrows() + { + var thrown = Assert.Throws(() => Given().Switch("--whatif", "touch nothing")); + StringAssert.Contains(thrown.Message, "without a prefix"); + } + + [TestMethod] + public void Declaring_BlankHelpThrows() + { + Assert.Throws(() => Given().Switch("whatif", " ")); + } + + [TestMethod] + public void Declaring_TwoNamesThatNormaliseTheSameThrows() + { + // "nopush" and "no-push" are one switch once hyphens go. Better to refuse at + // declaration than to silently have them share a value. + var thrown = Assert.Throws( + () => Given().Switch("nopush", "leave the push").Switch("no-push", "something else")); + + StringAssert.Contains(thrown.Message, "collides"); + } + + [TestMethod] + public void Declaring_ASwitchAndAnArgumentWithOneNameThrows() + { + Assert.Throws( + () => Given().Argument("out", "where to write").Switch("out", "something else")); + } + + [TestMethod] + public void Declaring_ARequiredArgumentAfterAnOptionalOneThrows() + { + var thrown = Assert.Throws( + () => Given().OptionalArgument("repo", "the repo").Argument("branch", "the branch")); + + StringAssert.Contains(thrown.Message, "must be last"); + } + + [TestMethod] + public void Declaring_AnythingAfterARestThrows() + { + Assert.Throws( + () => Given().Rest("args", "passed through").Argument("file", "a file")); + } + + [TestMethod] + public void Declaring_AnAliasOnAnArgumentThrows() + { + var thrown = Assert.Throws(() => Given().Argument("file|f", "a file")); + StringAssert.Contains(thrown.Message, "matched by position"); + } + + // ------------------------------------------------------------------ options + + [TestMethod] + public void Option_TakesItsValueAfterAColonOrAnEquals() + { + Assert.AreEqual("test", Given("-folder:test").Option("folder", "the folder").TryParse().Option("folder")); + Assert.AreEqual("test", Given("-folder=test").Option("folder", "the folder").TryParse().Option("folder")); + Assert.AreEqual("test", Given("--folder:test").Option("folder", "the folder").TryParse().Option("folder")); + + } + + [TestMethod] + public void Option_IsNullWhenNotGiven() + { + Assert.IsNull(Given().Option("folder", "the folder").TryParse().Option("folder")); + } + + [TestMethod] + public void Option_NameIsNormalisedButTheValueIsNot() + { + // The whole point of splitting before normalising: --API-KEY finds the option, and + // the key it carries is untouched. + var cmd = Given("--API-KEY:sk-ant-AbC123").Option("api-key", "the key").TryParse(); + + Assert.AreEqual("sk-ant-AbC123", cmd.Option("api-key")); + } + + [TestMethod] + public void Option_ValueKeepsItsCaseAndHyphens() + { + var cmd = Given(@"-out:C:\temp\My-Folder").Option("out", "where to write").TryParse(); + + Assert.AreEqual(@"C:\temp\My-Folder", cmd.Option("out")); + } + + [TestMethod] + public void Option_SplitsOnTheFirstSeparatorOnlySoAValueMayContainMore() + { + Assert.AreEqual("https://api.nuget.org/v3/index.json", + Given("-source:https://api.nuget.org/v3/index.json").Option("source", "the feed").TryParse().Option("source")); + + Assert.AreEqual("a=b=c", Given("-q:a=b=c").Option("q", "a query").TryParse().Option("q")); + Assert.AreEqual(@"C:\temp", Given(@"-out=C:\temp").Option("out", "where to write").TryParse().Option("out")); + } + + [TestMethod] + public void Option_ApiKeyAndApikeyAreTheSameOption() + { + foreach (var spelling in new[] { "--api-key:x", "--apikey:x", "-API_KEY:x" }) + { + Assert.AreEqual("x", Given(spelling).Option("api-key", "the key").TryParse().Option("api-key"), spelling); + } + } + + [TestMethod] + public void Option_GivenBareIsAnErrorNamingTheAttachedForm() + { + // This is what catches someone typing the separated "--folder test" habit, instead of + // letting "test" slide through as a positional. + var cmd = Given("-folder").Option("folder", "the folder").TryParse(); + + Assert.IsTrue(cmd.ShouldExit); + Assert.AreEqual(1, cmd.ExitCode); + StringAssert.Contains(this.Errors, "needs a value"); + StringAssert.Contains(this.Errors, "--folder:value"); + } + + [TestMethod] + public void Option_GivenAnEmptyValueIsAnError() + { + Assert.IsTrue(Given("-folder:").Option("folder", "the folder").TryParse().ShouldExit); + StringAssert.Contains(this.Errors, "needs a value"); + } + + [TestMethod] + public void Option_GivenTwiceIsAnError() + { + var cmd = Given("-source:a", "-source:b").Option("source", "the feed").TryParse(); + + Assert.IsTrue(cmd.ShouldExit); + StringAssert.Contains(this.Errors, "more than once"); + } + + [TestMethod] + public void Option_ErrorMessagesNeverEchoTheValue() + { + // A secret must not reach stderr because the user typed it twice, or typed the + // separated form and left it dangling. + Given("--api-key:sk-ant-SECRET", "--api-key:sk-ant-OTHER").Option("api-key", "the key").TryParse(); + Assert.IsFalse(this.Errors.Contains("SECRET"), "an option's value must never be echoed"); + Assert.IsFalse(this.Errors.Contains("OTHER"), "an option's value must never be echoed"); + + Capture(); + Given("--api-key", "sk-ant-SECRET").Option("api-key", "the key").TryParse(); + Assert.IsFalse(this.Errors.Contains("SECRET"), + "the token after a bare option must not be echoed as an unexpected argument either"); + } + + // ------------------------------------------------------------------ unknown switches + + [TestMethod] + public void Unknown_SwitchIsAnErrorNamingTheRawToken() + { + var cmd = Given("--dryrun").Switch("nopush", "leave the push").TryParse(); + + Assert.IsTrue(cmd.ShouldExit); + Assert.AreEqual(1, cmd.ExitCode); + StringAssert.Contains(this.Errors, "unknown switch '--dryrun'"); + } + + [TestMethod] + public void Unknown_SwitchGoesToStandardErrorNotStandardOut() + { + Given("--nope").Switch("whatif", "touch nothing").TryParse(); + + StringAssert.Contains(this.Errors, "unknown switch"); + Assert.AreEqual(String.Empty, this.Screen, "an error is not output"); + } + + [TestMethod] + public void Unknown_SwitchPointsAtHelpRatherThanPrintingIt() + { + Given("--nope").Switch("whatif", "touch nothing").TryParse(); + + StringAssert.Contains(this.Errors, "Try 'demo --help'"); + Assert.IsFalse(this.Errors.Contains("Switches:"), "the full usage is noise here"); + } + + [TestMethod] + public void Unknown_SwitchesAreAllReportedAtOnce() + { + Given("--nope", "--alsonope").Switch("whatif", "touch nothing").TryParse(); + + StringAssert.Contains(this.Errors, "'--nope'"); + StringAssert.Contains(this.Errors, "'--alsonope'"); + } + + [TestMethod] + public void Unknown_ATypoThatNormalisesToADeclaredSwitchIsNotUnknown() + { + // "--dryrun" for "--dry-run" is the bug this closes: today it becomes a path. + Assert.IsTrue(Given("--dryrun").Switch("dry-run", "print only").TryParse().Switch("dry-run")); + } + + [TestMethod] + public void Unknown_SwitchErrorsSuppressPositionalErrors() + { + var cmd = Given("--nope", "extra1", "extra2").Switch("whatif", "touch nothing").TryParse(); + + Assert.IsTrue(cmd.ShouldExit); + StringAssert.Contains(this.Errors, "unknown switch"); + Assert.IsFalse(this.Errors.Contains("unexpected"), + "once the switches were misread the positional list means nothing"); + } + + // ------------------------------------------------------------------ positionals + + [TestMethod] + public void Argument_FillsInDeclarationOrder() + { + var cmd = Given("in.txt", "out").Argument("file", "the file").Argument("output", "the folder").TryParse(); + + Assert.AreEqual("in.txt", cmd.Argument("file")); + Assert.AreEqual("out", cmd.Argument("output")); + } + + [TestMethod] + public void Argument_MayBeInterspersedWithSwitches() + { + var cmd = Given("repo", "-whatif").OptionalArgument("repo", "the repo").Switch("whatif", "touch nothing").TryParse(); + + Assert.AreEqual("repo", cmd.Argument("repo")); + Assert.IsTrue(cmd.Switch("whatif")); + } + + [TestMethod] + public void Argument_MissingRequiredIsAnErrorNamingIt() + { + var cmd = Given().Argument("file", "the file").TryParse(); + + Assert.IsTrue(cmd.ShouldExit); + Assert.AreEqual(1, cmd.ExitCode); + StringAssert.Contains(this.Errors, "missing "); + } + + [TestMethod] + public void Argument_OptionalMayBeOmittedAndReadsNull() + { + Assert.IsNull(Given().OptionalArgument("path", "the path").TryParse().Argument("path")); + Assert.AreEqual("x", Given("x").OptionalArgument("path", "the path").TryParse().Argument("path")); + } + + [TestMethod] + public void Argument_TooManyIsAnErrorNamingTheUnexpectedOnes() + { + var one = Given("a", "b").OptionalArgument("path", "the path").TryParse(); + Assert.IsTrue(one.ShouldExit); + StringAssert.Contains(this.Errors, "unexpected argument 'b'"); + + Capture(); + Given("a", "b", "c").OptionalArgument("path", "the path").TryParse(); + StringAssert.Contains(this.Errors, "unexpected arguments: 'b' 'c'"); + } + + [TestMethod] + public void Argument_PathsAreNotMistakenForSwitches() + { + // The reason '/' is recognised rather than demanded: an absolute path on Linux starts + // with one. + Assert.AreEqual("/home/tom/file", + Given("/home/tom/file").OptionalArgument("path", "the path").TryParse().Argument("path")); + + Capture(); + Assert.AreEqual(@"C:\temp", + Given(@"C:\temp").OptionalArgument("path", "the path").TryParse().Argument("path")); + + Capture(); + Assert.AreEqual("/tmp/x:y", + Given("/tmp/x:y").OptionalArgument("path", "the path").TryParse().Argument("path")); + + Capture(); + Assert.AreEqual("/usr/local/bin", + Given("/usr/local/bin").OptionalArgument("path", "the path").TryParse().Argument("path")); + } + + [TestMethod] + public void Argument_ASlashTokenIsAlwaysAPositional() + { + // '/' is not a switch prefix. Dashes are the standard, and treating '/' as a prefix + // would make every absolute path on Linux something the parser had to recognise. + Assert.AreEqual("/nope", Given("/nope").OptionalArgument("path", "the path").TryParse().Argument("path")); + + Capture(); + var cmd = Given("/whatif").OptionalArgument("path", "the path").Switch("whatif", "touch nothing").TryParse(); + Assert.AreEqual("/whatif", cmd.Argument("path"), "a slash token is a value, not the switch it resembles"); + Assert.IsFalse(cmd.Switch("whatif")); + } + + [TestMethod] + public void Argument_ADashTokenIsAnUnknownSwitchNotAPositional() + { + Assert.IsTrue(Given("-nope").OptionalArgument("path", "the path").TryParse().ShouldExit); + StringAssert.Contains(this.Errors, "unknown switch"); + } + + [TestMethod] + public void Argument_NegativeNumbersAndABareDashArePositionals() + { + Assert.AreEqual("-9", Given("-9").OptionalArgument("n", "a number").TryParse().Argument("n")); + + Capture(); + Assert.AreEqual("-", Given("-").OptionalArgument("n", "stdin").TryParse().Argument("n")); + } + + [TestMethod] + public void Argument_AfterTheTerminatorMayLookLikeASwitch() + { + var cmd = Given("--", "-weird-name").OptionalArgument("path", "the path").TryParse(); + + Assert.AreEqual("-weird-name", cmd.Argument("path")); + } + + [TestMethod] + public void Argument_TheTerminatorIsNotItselfAPositional() + { + var cmd = Given("a", "--", "b").Argument("one", "first").OptionalArgument("two", "second").TryParse(); + + Assert.AreEqual("a", cmd.Argument("one")); + Assert.AreEqual("b", cmd.Argument("two")); + } + + [TestMethod] + public void Argument_ReadingAnUndeclaredNameThrows() + { + var cmd = Given("x").OptionalArgument("path", "the path").TryParse(); + + Assert.Throws(() => cmd.Argument("nope")); + } + + [TestMethod] + public void Argument_ArgumentsListsEveryPositionalInOrder() + { + var cmd = Given("a", "b").Argument("one", "first").Argument("two", "second").TryParse(); + + CollectionAssert.AreEqual(new[] { "a", "b" }, cmd.Arguments.ToArray()); + } + + // ------------------------------------------------------------------ rest + + [TestMethod] + public void Rest_CollectsWhatIsLeftVerbatim() + { + var cmd = Given("cmd.exe", "/k", "dir").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 Rest_StopsSwitchParsingAtTheFirstPositional() + { + // -whatif is ours because it comes first; --help belongs to the child. + var cmd = Given("-whatif", "cmd.exe", "--help") + .Switch("whatif", "touch nothing") + .Argument("program", "what to run") + .Rest("args", "passed through") + .TryParse(); + + Assert.IsFalse(cmd.ShouldExit, "--help after the program name is the child's, not ours"); + Assert.IsTrue(cmd.Switch("whatif")); + CollectionAssert.AreEqual(new[] { "--help" }, cmd.Rest.ToArray()); + } + + [TestMethod] + public void Rest_StillRejectsAMistypedSwitchBeforeTheFirstPositional() + { + // The reason the boundary is the first positional rather than the first unrecognised + // switch: otherwise a typo is silently handed to the child. + var cmd = Given("--whatf", "cmd.exe") + .Switch("whatif", "touch nothing") + .Argument("program", "what to run") + .Rest("args", "passed through") + .TryParse(); + + Assert.IsTrue(cmd.ShouldExit); + StringAssert.Contains(this.Errors, "unknown switch '--whatf'"); + } + + [TestMethod] + public void Rest_IsEmptyWhenNothingIsLeft() + { + var cmd = Given("cmd.exe").Argument("program", "what to run").Rest("args", "passed through").TryParse(); + + Assert.AreEqual(0, cmd.Rest.Count); + } + + [TestMethod] + public void Rest_ReadingItUndeclaredThrows() + { + var cmd = Given().Switch("whatif", "touch nothing").TryParse(); + + Assert.Throws(() => { var ignored = cmd.Rest; }); + } + + // ------------------------------------------------------------------ help + + [TestMethod] + public void Help_IsUnderstoodWithoutBeingDeclared() + { + foreach (var spelling in new[] { "--help", "-h", "-?" }) + { + Capture(); + var cmd = Given(spelling).Switch("whatif", "touch nothing").TryParse(); + + Assert.IsTrue(cmd.ShouldExit, spelling); + Assert.IsTrue(cmd.HelpRequested, spelling); + Assert.AreEqual(0, cmd.ExitCode, spelling); + StringAssert.Contains(this.Screen, "Usage:", spelling); + } + } + + [TestMethod] + public void Help_GoesToStandardOutNotStandardError() + { + Given("--help").Switch("whatif", "touch nothing").TryParse(); + + StringAssert.Contains(this.Screen, "Usage:"); + Assert.AreEqual(String.Empty, this.Errors); + } + + [TestMethod] + public void Help_WinsOverAnUnknownSwitchAndAMissingArgument() + { + var cmd = Given("--nope", "--help").Argument("file", "the file").TryParse(); + + Assert.IsTrue(cmd.HelpRequested); + Assert.AreEqual(0, cmd.ExitCode); + } + + [TestMethod] + public void Help_ListsEveryDeclaredArgumentSwitchAndOption() + { + // The anti-drift guarantee: the help cannot fall out of step with what is accepted, + // because it is rendered from the same declarations. + Given("--help") + .Description("Does a thing.") + .Argument("file", "File to operate on") + .OptionalArgument("output", "output folder") + .Switch("whatif", "What if without execute") + .Option("source", "the feed to use") + .TryParse(); + + StringAssert.Contains(this.Screen, "Does a thing."); + StringAssert.Contains(this.Screen, "file"); + StringAssert.Contains(this.Screen, "File to operate on"); + StringAssert.Contains(this.Screen, "output folder"); + StringAssert.Contains(this.Screen, "--whatif"); + StringAssert.Contains(this.Screen, "What if without execute"); + StringAssert.Contains(this.Screen, "--source:"); + StringAssert.Contains(this.Screen, "--help"); + } + + [TestMethod] + public void Help_ShowsRequiredAndOptionalArgumentsDifferently() + { + Given("--help").Argument("file", "the file").OptionalArgument("output", "the folder").TryParse(); + + StringAssert.Contains(this.Screen, ""); + StringAssert.Contains(this.Screen, "[output]"); + } + + [TestMethod] + public void Help_ShowsARestWithAnEllipsis() + { + Given("--help").Argument("program", "what to run").Rest("args", "passed through").TryParse(); + + StringAssert.Contains(this.Screen, "[args...]"); + } + + [TestMethod] + public void Help_ListsAliasesBesideTheirSwitch() + { + Given("--help").Switch("whatif|dry-run|n", "touch nothing").TryParse(); + + StringAssert.Contains(this.Screen, "--whatif, --dry-run, -n"); + } + + [TestMethod] + public void Help_NamesTheProgram() + { + Given("--help").Switch("whatif", "touch nothing").TryParse(); + + StringAssert.Contains(this.Screen, "demo"); + } + + [TestMethod] + public void Help_NamesTheProgramWithoutBeingToldWhoItIs() + { + // A .csx or .csrun is named after its own file -- verified by hand under dotnet-script, + // and untestable from here because this caller is a compiled .cs. What IS testable is + // that the fallback never leaves the usage line blank, and that Program() wins. + var inferred = Cli.For(new string[0]).Switch("whatif", "touch nothing").TryParse(); + Assert.IsFalse(String.IsNullOrWhiteSpace(inferred.ProgramName)); + + var told = Cli.For(new string[0]).Program("gho").Switch("whatif", "touch nothing").TryParse(); + Assert.AreEqual("gho", told.ProgramName); + } + + [TestMethod] + public void Help_DedentsTheDescription() + { + Given("--help").Description(@" + First line. + Indented under it.").TryParse(); + + StringAssert.Contains(this.Screen, "First line."); + StringAssert.Contains(this.Screen, " Indented under it."); + Assert.IsFalse(this.Screen.Contains(" First line.")); + } + + [TestMethod] + public void Help_IncludesExamples() + { + Given("--help").Switch("whatif", "touch nothing") + .Example("demo -whatif", "show what would happen") + .TryParse(); + + StringAssert.Contains(this.Screen, "Examples:"); + StringAssert.Contains(this.Screen, "demo -whatif"); + StringAssert.Contains(this.Screen, "show what would happen"); + } + + [TestMethod] + public void Help_IsReadableAsAStringWithoutTouchingTheConsole() + { + var cmd = Given().Switch("whatif", "touch nothing").TryParse(); + + StringAssert.Contains(cmd.UsageText, "Usage:"); + Assert.AreEqual(String.Empty, this.Screen); + } + + [TestMethod] + public void Help_CanBeReplacedByTheScriptsOwn() + { + Given("--help").Switch("help|h", "show the help my way").TryParse(); + + StringAssert.Contains(this.Screen, "show the help my way"); + Assert.IsFalse(this.Screen.Contains("show this help")); + } + + // ------------------------------------------------------------------ usage when empty + + [TestMethod] + public void UsageWhenEmpty_PrintsUsageAndExitsZeroForNoArguments() + { + var cmd = Given().UsageWhenEmpty().Argument("file", "the file").TryParse(); + + Assert.IsTrue(cmd.ShouldExit); + Assert.AreEqual(0, cmd.ExitCode, "being shown the usage is not a failure"); + StringAssert.Contains(this.Screen, "Usage:"); + } + + [TestMethod] + public void UsageWhenEmpty_IsOffUnlessAskedFor() + { + var cmd = Given().Argument("file", "the file").TryParse(); + + Assert.AreEqual(1, cmd.ExitCode, "without it, a missing required argument is still an error"); + StringAssert.Contains(this.Errors, "missing "); + } + + [TestMethod] + public void UsageWhenEmpty_IsNotTriggeredWhenAnythingIsGiven() + { + var cmd = Given("x").UsageWhenEmpty().Argument("file", "the file").TryParse(); + + Assert.IsFalse(cmd.ShouldExit); + Assert.AreEqual("x", cmd.Argument("file")); + } + + // ------------------------------------------------------------------ whatif + + [TestMethod] + public void WhatIf_AcceptsAllThreeSpellings() + { + foreach (var spelling in new[] { "-whatif", "--dry-run", "--dryrun", "-n" }) + { + Assert.IsTrue(Given(spelling).WhatIf().TryParse().WhatIf, spelling); + } + } + + [TestMethod] + public void WhatIf_IsFalseWhenNotGiven() + { + Assert.IsFalse(Given().WhatIf().TryParse().WhatIf); + } + + [TestMethod] + public void WhatIf_ReadingItUndeclaredThrowsRatherThanAnsweringFalse() + { + // Answering false would mean a script that forgot .WhatIf() silently never rehearses. + var cmd = Given().Switch("nopush", "leave the push").TryParse(); + + var thrown = Assert.Throws(() => { var ignored = cmd.WhatIf; }); + StringAssert.Contains(thrown.Message, "never declared"); + } + + [TestMethod] + public void WhatIf_ShowsWhatIfAsItsPrimarySpelling() + { + Given("--help").WhatIf().TryParse(); + + StringAssert.Contains(this.Screen, "--whatif"); + } + + // ------------------------------------------------------------------ the result contract + + [TestMethod] + public void Parse_IsQuietAndReadableForACleanCommandLine() + { + var cmd = Given("-whatif").Switch("whatif", "touch nothing").TryParse(); + + Assert.IsFalse(cmd.ShouldExit); + Assert.AreEqual(0, cmd.ExitCode); + Assert.IsNull(cmd.Error); + Assert.IsFalse(cmd.HelpRequested); + Assert.AreEqual(String.Empty, this.Screen); + Assert.AreEqual(String.Empty, this.Errors); + } + + [TestMethod] + public void Parse_ReadingAnythingAfterAnErrorThrows() + { + // The guard under the ShouldExit contract: a script that forgets the check fails + // loudly instead of running on with defaults it never earned. + var cmd = Given("--nope").Switch("whatif", "touch nothing").Argument("file", "the file").TryParse(); + + Assert.IsTrue(cmd.ShouldExit); + Assert.Throws(() => cmd.Switch("whatif")); + Assert.Throws(() => cmd.Argument("file")); + } + + [TestMethod] + public void Parse_TheDiagnosticsStayReadableAfterAnError() + { + var cmd = Given("--nope").Switch("whatif", "touch nothing").TryParse(); + + Assert.IsTrue(cmd.ShouldExit); + Assert.AreEqual(1, cmd.ExitCode); + StringAssert.Contains(cmd.Error, "unknown switch"); + StringAssert.Contains(cmd.UsageText, "Usage:"); + Assert.AreEqual("demo", cmd.ProgramName); + } + + [TestMethod] + public void Parse_TakesAnArrayOrAList() + { + Assert.IsTrue(Cli.For(new List { "-whatif" }).Program("demo") + .Switch("whatif", "touch nothing").TryParse().Switch("whatif")); + + Assert.IsTrue(Cli.For(new[] { "-whatif" }).Program("demo") + .Switch("whatif", "touch nothing").TryParse().Switch("whatif")); + } + + [TestMethod] + public void Parse_NullArgumentsThrows() + { + Assert.Throws(() => Cli.For(null)); + } + + [TestMethod] + public void Parse_AnEmptyCommandLineIsFineWhenNothingIsRequired() + { + var cmd = Given().Switch("whatif", "touch nothing").TryParse(); + + Assert.IsFalse(cmd.ShouldExit); + Assert.IsFalse(cmd.Switch("whatif")); + } + } +} diff --git a/Tests/CShell.Tests/Command.Tests.cs b/Tests/CShell.Tests/Command.Tests.cs index 67ccda6..c937e11 100644 --- a/Tests/CShell.Tests/Command.Tests.cs +++ b/Tests/CShell.Tests/Command.Tests.cs @@ -1,10 +1,12 @@ -using CShellNet; +using CShellNet; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using System; using System.IO; using System.Reflection; +using System.Runtime.InteropServices; using System.Threading.Tasks; namespace CShellLibTests @@ -16,10 +18,18 @@ public TestRecord() } - [JsonProperty("name")] + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("age")] + public int Age { get; set; } + } + + /// The same shape with no attributes, so nothing but case-insensitive matching can fill it. + public class UnmappedRecord + { public string Name { get; set; } - [JsonProperty("age")] public int Age { get; set; } } @@ -33,7 +43,7 @@ public class CommandTests [ClassInitialize()] public static void ClassInit(TestContext context) { - testFolder = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), @"..\..\..\test")); + testFolder = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "..", "..", "..", "test")); subFolder = Path.Combine(testFolder, "subfolder"); subFolder2 = Path.Combine(subFolder, "subfolder2"); } @@ -44,11 +54,11 @@ public async Task Test_AsString() CShell shell = new CShell(); shell.cd(testFolder); - var result = await shell.Run("cmd", "/c", "echo this is a yo yo").AsString(); + var result = await shell.Run(ShellExe, ShellFlag, "echo this is a yo yo").AsString(); Assert.AreEqual("this is a yo yo", result.Trim(), "AsString"); var record = await shell.ReadFile("TestA.txt").AsString(); - var text = File.ReadAllText(Path.Combine(testFolder, "TestA.Txt")); + var text = File.ReadAllText(Path.Combine(testFolder, "TestA.txt")); Assert.AreEqual(record, text, "AsString"); } @@ -62,13 +72,20 @@ public async Task Test_AsJson() Assert.AreEqual("Joe Smith", record.Name, "name is wrong"); Assert.AreEqual(42, record.Age, "age is wrong"); - JObject record2 = (JObject)await shell.ReadFile("TestA.txt").AsJson(); - Assert.AreEqual("Joe Smith", (string)record2["name"], "JOBject name is wrong"); - Assert.AreEqual(42, (int)record2["age"], "JOBject age is wrong"); + JsonNode record2 = await shell.ReadFile("TestA.txt").AsJson(); + Assert.AreEqual("Joe Smith", (string)record2["name"], "JsonNode name is wrong"); + Assert.AreEqual(42, (int)record2["age"], "JsonNode age is wrong"); - dynamic record3 = await shell.ReadFile("TestA.txt").AsJson(); - Assert.AreEqual("Joe Smith", (string)record3.name, "dynamic name is wrong"); - Assert.AreEqual(42, (int)record3.age, "dynamic age is wrong"); + // Nested indexing is how a JsonNode is navigated. Member access -- record.name -- + // came from the Newtonsoft JObject and is gone with it. + Assert.IsNull(record2["nope"], "a missing property reads as null"); + + // System.Text.Json matches property names case sensitively by default, which would + // leave both of these at their defaults rather than failing. CShell turns that off, + // because CLI tools emit camelCase and the C# modelling them is PascalCase. + var unmapped = await shell.ReadFile("TestA.txt").AsJson(); + Assert.AreEqual("Joe Smith", unmapped.Name, "lowercase json must still fill a PascalCase property"); + Assert.AreEqual(42, unmapped.Age, "lowercase json must still fill a PascalCase property"); } @@ -91,14 +108,15 @@ public async Task Test_AsResult() shell.cd(testFolder); var result = await shell.ReadFile("TestA.txt").AsResult(); - var text = File.ReadAllText(Path.Combine(testFolder, "TestA.Txt")); + var text = File.ReadAllText(Path.Combine(testFolder, "TestA.txt")); Assert.AreEqual(text, result.StandardOutput, "result stdout"); Assert.AreEqual("", result.StandardError, "result stderr"); var badResult = await shell.ReadFile("sdfsdffd.txt").AsResult(); Assert.AreEqual("", badResult.StandardOutput, "result stdout"); - Assert.AreEqual("The system cannot find the file specified.", badResult.StandardError.Trim(), "result stderr"); + Assert.IsFalse(badResult.Success, "reading a file that is not there should fail"); + Assert.AreNotEqual(String.Empty, badResult.StandardError.Trim(), "and should say so on stderr"); } [TestMethod] @@ -135,7 +153,7 @@ public async Task Test_Throw_AsJson() } catch (Exception err) { - Assert.IsTrue(err.Message.Contains("The system cannot find the file specified.")); + Assert.IsTrue(err.Message.Contains("xyz")); } } @@ -152,7 +170,7 @@ public async Task Test_Throw_AsXml() } catch (Exception err) { - Assert.IsTrue(err.Message.Contains("The system cannot find the file specified.")); + Assert.IsTrue(err.Message.Contains("xyz")); } } @@ -169,16 +187,27 @@ public async Task Test_Throw_AsString() } catch (Exception err) { - Assert.IsTrue(err.Message.Contains("The system cannot find the file specified.")); + Assert.IsTrue(err.Message.Contains("xyz")); } } + + private static bool IsWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + /// The shell to hand a command line to, and the flag that says "run this". + private static string ShellExe => IsWindows ? "cmd" : "bash"; + + private static string ShellFlag => IsWindows ? "/c" : "-c"; + + /// List one file, in whichever shell Cmd() runs: cmd.exe on Windows, bash elsewhere. + private static string ListTestA => IsWindows ? "dir /b TestA.txt" : "ls TestA.txt"; + [TestMethod] public async Task Test_Cmd() { CShell shell = new CShell(); shell.cd(testFolder); - var result = await shell.Cmd("dir /b TestA.txt").AsString(); + var result = await shell.Cmd(ListTestA).AsString(); Assert.AreEqual("TestA.txt", result.Trim(), "AsString"); } @@ -211,7 +240,7 @@ public async Task Test_StartExecute() Assert.IsTrue(result.Success); try { - result = await shell.Start("xxxxxtest.cmd").Execute(); + result = await shell.Start("xxxxx-no-such-program").Execute(); Assert.Fail("Should have thrown execption)"); } catch @@ -237,7 +266,7 @@ public async Task Test_Log() { CShell shell = new CShell(); shell.cd(testFolder); - var commandResult = await shell.Cmd("dir /b TestA.txt").Execute(true); + var commandResult = await shell.Cmd(ListTestA).Execute(true); } } diff --git a/Tests/CShell.Tests/CommandGlobal.Tests.cs b/Tests/CShell.Tests/CommandGlobal.Tests.cs index 425e959..c47145e 100644 --- a/Tests/CShell.Tests/CommandGlobal.Tests.cs +++ b/Tests/CShell.Tests/CommandGlobal.Tests.cs @@ -1,11 +1,12 @@ -global using static CShellNet.Globals; +global using static CShellNet.Globals; global using CShellNet; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; using System; using System.IO; using System.Reflection; +using System.Runtime.InteropServices; using System.Threading.Tasks; namespace CShellLibTests @@ -20,7 +21,7 @@ public class CommandGlobalTests [ClassInitialize()] public static void ClassInit(TestContext context) { - testFolder = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), @"..\..\..\test")); + testFolder = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "..", "..", "..", "test")); subFolder = Path.Combine(testFolder, "subfolder"); subFolder2 = Path.Combine(subFolder, "subfolder2"); } @@ -30,11 +31,11 @@ public async Task Test_Global_AsString() { ResetShell(testFolder); - var result = await Run("cmd", "/c", "echo this is a yo yo").AsString(); + var result = await Run(ShellExe, ShellFlag, "echo this is a yo yo").AsString(); Assert.AreEqual("this is a yo yo", result.Trim(), "AsString"); var record = await ReadFile("TestA.txt").AsString(); - var text = File.ReadAllText(Path.Combine(testFolder, "TestA.Txt")); + var text = File.ReadAllText(Path.Combine(testFolder, "TestA.txt")); Assert.AreEqual(record, text, "AsString"); } @@ -48,13 +49,13 @@ public async Task Test_Global_AsJson() Assert.AreEqual("Joe Smith", record.Name, "name is wrong"); Assert.AreEqual(42, record.Age, "age is wrong"); - JObject record2 = (JObject)await ReadFile("TestA.txt").AsJson(); - Assert.AreEqual("Joe Smith", (string)record2["name"], "JOBject name is wrong"); - Assert.AreEqual(42, (int)record2["age"], "JOBject age is wrong"); + JsonNode record2 = await ReadFile("TestA.txt").AsJson(); + Assert.AreEqual("Joe Smith", (string)record2["name"], "JsonNode name is wrong"); + Assert.AreEqual(42, (int)record2["age"], "JsonNode age is wrong"); - dynamic record3 = await ReadFile("TestA.txt").AsJson(); - Assert.AreEqual("Joe Smith", (string)record3.name, "dynamic name is wrong"); - Assert.AreEqual(42, (int)record3.age, "dynamic age is wrong"); + // Nested indexing is how a JsonNode is navigated. Member access -- record.name -- + // came from the Newtonsoft JObject and is gone with it. + Assert.IsNull(record2["nope"], "a missing property reads as null"); } @@ -76,14 +77,15 @@ public async Task Test_Global_AsResult() ResetShell(testFolder); var result = await ReadFile("TestA.txt").AsResult(); - var text = File.ReadAllText(Path.Combine(testFolder, "TestA.Txt")); + var text = File.ReadAllText(Path.Combine(testFolder, "TestA.txt")); Assert.AreEqual(text, result.StandardOutput, "result stdout"); Assert.AreEqual("", result.StandardError, "result stderr"); var badResult = await ReadFile("sdfsdffd.txt").AsResult(); Assert.AreEqual("", badResult.StandardOutput, "result stdout"); - Assert.AreEqual("The system cannot find the file specified.", badResult.StandardError.Trim(), "result stderr"); + Assert.IsFalse(badResult.Success, "reading a file that is not there should fail"); + Assert.AreNotEqual(String.Empty, badResult.StandardError.Trim(), "and should say so on stderr"); } [TestMethod] @@ -120,7 +122,7 @@ public async Task Test_Global_Throw_AsJson() } catch (Exception err) { - Assert.IsTrue(err.Message.Contains("The system cannot find the file specified.")); + Assert.IsTrue(err.Message.Contains("xyz")); } } @@ -137,7 +139,7 @@ public async Task Test_Global_Throw_AsXml() } catch (Exception err) { - Assert.IsTrue(err.Message.Contains("The system cannot find the file specified.")); + Assert.IsTrue(err.Message.Contains("xyz")); } } @@ -154,16 +156,27 @@ public async Task Test_Global_Throw_AsString() } catch (Exception err) { - Assert.IsTrue(err.Message.Contains("The system cannot find the file specified.")); + Assert.IsTrue(err.Message.Contains("xyz")); } } + + private static bool IsWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + /// The shell to hand a command line to, and the flag that says "run this". + private static string ShellExe => IsWindows ? "cmd" : "bash"; + + private static string ShellFlag => IsWindows ? "/c" : "-c"; + + /// List one file, in whichever shell Cmd() runs: cmd.exe on Windows, bash elsewhere. + private static string ListTestA => IsWindows ? "dir /b TestA.txt" : "ls TestA.txt"; + [TestMethod] public async Task Test_Global_Cmd() { ResetShell(testFolder); - var result = await Cmd("dir /b TestA.txt").AsString(); + var result = await Cmd(ListTestA).AsString(); Assert.AreEqual("TestA.txt", result.Trim(), "AsString"); } @@ -195,7 +208,7 @@ public async Task Test_Global_StartExecute() Assert.IsTrue(result.Success); try { - result = await Start("xxxxxtest.cmd").Execute(); + result = await Start("xxxxx-no-such-program").Execute(); Assert.Fail("Should have thrown execption)"); } catch @@ -223,7 +236,7 @@ public async Task Test_Global_Log() { ResetShell(testFolder); - var commandResult = await Cmd("dir /b TestA.txt").Execute(true); + var commandResult = await Cmd(ListTestA).Execute(true); } } diff --git a/Tests/CShell.Tests/JsonDynamic.Tests.cs b/Tests/CShell.Tests/JsonDynamic.Tests.cs new file mode 100644 index 0000000..191fbc4 --- /dev/null +++ b/Tests/CShell.Tests/JsonDynamic.Tests.cs @@ -0,0 +1,170 @@ +using CShellNet; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.Text.Json.Nodes; + +namespace CShellLibTests +{ + /// + /// What AsJson() hands back: JSON walked with a dot, and still a JsonNode underneath. + /// + [TestClass] + public class JsonDynamicTests + { + private const string Sample = @"{ + ""name"": ""Joe Smith"", + ""age"": 42, + ""active"": true, + ""missingIsNull"": null, + ""owner"": { ""login"": ""tomlm"" }, + ""tags"": [ ""a"", ""b"" ], + ""repos"": [ { ""name"": ""CShell"" }, { ""name"": ""scripts"" } ] + }"; + + private static dynamic Json() => new JsonDynamic(JsonNode.Parse(Sample)); + + [TestMethod] + public void Member_ReadsAProperty() + { + Assert.AreEqual("Joe Smith", (string)Json().name); + } + + [TestMethod] + public void Member_NestsAsDeepAsYouLike() + { + Assert.AreEqual("tomlm", (string)Json().owner.login); + Assert.AreEqual("scripts", (string)Json().repos[1].name); + } + + [TestMethod] + public void Member_ThatIsNotThereReadsAsNull() + { + // What makes `if (json.optional != null)` the way to test for a field. The cost is + // that a typo reads as null too. + Assert.IsNull(Json().nope); + Assert.IsNull(Json().missingIsNull); + } + + [TestMethod] + public void Indexer_WorksOnObjectsAndArrays() + { + Assert.AreEqual("tomlm", (string)Json()["owner"]["login"]); + Assert.AreEqual("b", (string)Json().tags[1]); + } + + [TestMethod] + public void Indexer_OutOfRangeReadsAsNull() + { + Assert.IsNull(Json().tags[99]); + } + + [TestMethod] + public void Convert_AssignsToTypedVariables() + { + string name = Json().name; + int age = Json().age; + bool active = Json().active; + + Assert.AreEqual("Joe Smith", name); + Assert.AreEqual(42, age); + Assert.IsTrue(active); + } + + [TestMethod] + public void Convert_GivesBackTheJsonNodeItself() + { + // The escape hatch to the typed API, and the reason a wrapper is enough even though + // JsonObject is sealed and cannot be derived from. + JsonNode node = Json().owner; + Assert.AreEqual("tomlm", (string)node["login"]); + + JsonObject o = Json(); + Assert.AreEqual("Joe Smith", (string)o["name"]); + + JsonArray a = Json().tags; + Assert.AreEqual(2, a.Count); + } + + [TestMethod] + public void Convert_DeserializesAWholeShape() + { + var owner = (Owner)Json().owner; + Assert.AreEqual("tomlm", owner.Login); + } + + [TestMethod] + public void Operators_CompareAndArithmetic() + { + // DynamicObject binds members and conversions but not operators; without + // TryBinaryOperation every one of these throws RuntimeBinderException. + Assert.IsTrue(Json().age == 42); + Assert.IsTrue(Json().age != 7); + Assert.IsTrue(Json().age > 41); + Assert.IsTrue(Json().age <= 42); + Assert.IsTrue(Json().name == "Joe Smith"); + Assert.AreEqual(43, Json().age + 1); + } + + [TestMethod] + public void Enumeration_WalksAnArray() + { + var seen = new List(); + foreach (var tag in Json().tags) + { + seen.Add((string)tag); + } + + CollectionAssert.AreEqual(new[] { "a", "b" }, seen); + + var names = new List(); + foreach (var repo in Json().repos) + { + names.Add((string)repo.name); + } + + CollectionAssert.AreEqual(new[] { "CShell", "scripts" }, names); + } + + [TestMethod] + public void Enumeration_OfSomethingThatIsNotAnArrayIsEmpty() + { + var count = 0; + foreach (var nothing in Json().owner) + { + count++; + } + + Assert.AreEqual(0, count); + } + + [TestMethod] + public void MemberNames_AreDiscoverable() + { + var names = (IEnumerable)((JsonDynamic)Json()).GetDynamicMemberNames(); + + CollectionAssert.Contains(new List(names), "owner"); + } + + [TestMethod] + public void ToString_GivesTheValueWithoutItsQuotes() + { + Assert.AreEqual("Joe Smith", Json().name.ToString()); + } + + [TestMethod] + public void WrappingNullIsSafe() + { + var empty = new JsonDynamic(null); + + Assert.IsNull(empty.Node); + Assert.AreEqual(String.Empty, empty.ToString()); + Assert.IsNull((JsonNode)empty); + } + + public class Owner + { + public string Login { get; set; } + } + } +} diff --git a/askdemo.csx b/askdemo.csx new file mode 100644 index 0000000..66fe7c9 --- /dev/null +++ b/askdemo.csx @@ -0,0 +1,311 @@ +#!/usr/bin/env dotnet-script +#r "nuget: MedallionShell, 1.6.2" +#r "src/bin/Debug/netstandard2.0/CShell.dll" + +using CShellNet; +using static CShellNet.Globals; + +// askdemo -- a quick tour of every AskXXX() method. Each one shows you the code, then +// runs exactly that code so you can answer it. +// +// dotnet script askdemo.csx arrow keys, if you're at a terminal +// dotnet script askdemo.csx -- -plain typed answers instead +// echo ... | dotnet script askdemo.csx typed, because it has no choice +// +// Build the library first: dotnet build src + +if (Args.Any(a => a is "-h" or "-?" or "--help")) +{ + Console.WriteLine("askdemo [-plain|-rich]"); + Console.WriteLine(" A tour of AskText, AskSecret, AskYesNo, AskNumber,"); + Console.WriteLine(" AskChoice and AskMultiChoice."); + Console.WriteLine(); + Console.WriteLine(" -plain typed answers"); + Console.WriteLine(" -rich arrow keys"); + return; +} + +if (Args.Any(a => a is "-plain" or "--plain")) RichPrompts = false; +if (Args.Any(a => a is "-rich" or "--rich")) RichPrompts = true; + +var rich = RichPrompts ?? !Console.IsInputRedirected; + +// Strip the leading indentation the verbatim snippets below carry, keeping the relative +// indent inside a snippet so a wrapped argument still lines up. +string[] Dedent(string text) +{ + var lines = text.Replace("\r\n", "\n").Split('\n') + .SkipWhile(l => l.Trim().Length == 0).ToList(); + while (lines.Count > 0 && lines[lines.Count - 1].Trim().Length == 0) + { + lines.RemoveAt(lines.Count - 1); + } + + var indent = lines.Where(l => l.Trim().Length > 0) + .Select(l => l.Length - l.TrimStart().Length) + .DefaultIfEmpty(0).Min(); + + return lines.Select(l => l.Length >= indent ? l.Substring(indent) : l.Trim()).ToArray(); +} + +// The code you're about to run, in a box. Every section below keeps the box and the real +// call next to each other, so the two can't quietly drift apart. +void Box(string code) +{ + var lines = Dedent(code); + var width = Math.Max(68, lines.Max(l => l.Length)); + + Console.WriteLine(" ┌─" + new string('─', width) + "─┐"); + foreach (var line in lines) + { + Console.WriteLine(" │ " + line.PadRight(width) + " │"); + } + + Console.WriteLine(" └─" + new string('─', width) + "─┘"); +} + +void Lesson(string title, string description, string code, string richHint, string plainHint) +{ + Console.WriteLine(); + Console.WriteLine("==== " + title + " " + new string('=', Math.Max(4, 78 - title.Length - 6))); + foreach (var line in description.Replace("\r\n", "\n").Split('\n')) + { + Console.WriteLine(" " + line.Trim()); + } + + Console.WriteLine(); + Box(code); + Console.WriteLine(); + Console.WriteLine(" TRY: " + (rich ? richHint : plainHint)); + Console.WriteLine(); +} + +Console.WriteLine("══ The Ask Methods ═══════════════════════════════════════════════════════════"); +Console.WriteLine(); +Console.WriteLine(" The Ask() methods are prompts for asking whoever's running your script a question."); +Console.WriteLine(); +Console.WriteLine(); + +// Guarded on IsInputRedirected rather than on `rich`, because that is the thing ReadKey() +// actually needs. Piping answers in would otherwise eat one of them here. +if (!Console.IsInputRedirected) +{ + Console.Write(" Hit any key to start."); + Console.ReadKey(intercept: true); + Console.WriteLine(); + Console.WriteLine(); +} + +// If the input runs out we stop here, naming the question nobody answered. +try +{ + // ---------------------------------------------------------------- AskText + + Lesson("AskText(string question) -> string", + @"Grab a line of text. Whatever they type, trimmed. + Blank counts as an answer, so check for it if you care.", + @"var name = AskText(""What should I call you?"");", + "type a name and hit enter.", + "type a name and hit enter."); + + var name = AskText("What should I call you?"); + Console.WriteLine($" -> \"{name}\"{(name.Length == 0 ? " (nothing is a valid answer)" : "")}"); + + // ---------------------------------------------------------------- AskSecret + + Lesson("AskSecret(string question) -> string", + @"Same, but nothing appears as they type -- for tokens and passwords you'd + rather not leave sitting on the screen. Backspace still works. + If input is piped it just reads a line; there's no screen to leak onto.", + @"var secret = AskSecret(""Paste a token (nothing will appear):"");", + "type something. You won't see it. Enter when you're done.", + "type or paste a value and hit enter."); + + var secret = AskSecret("Paste a token (nothing will appear):"); + Console.WriteLine(secret.Length > 0 + ? $" -> {secret.Length} characters, starting {secret.Substring(0, Math.Min(4, secret.Length))}... (never printed in full)" + : " -> nothing entered"); + + // ---------------------------------------------------------------- AskYesNo + + Lesson("AskYesNo(string question) -> bool", + @"A yes/no question. + Enter is what answers. y and n just move the highlight, so a stray + keypress can't commit you to anything.", + @"var sure = AskYesNo(""Ready to see the rest?"");", + "left/right, tab, or y/n to move. Enter to answer.", + "type y, yes, n or no. Enter on its own just asks again."); + + var sure = AskYesNo("Ready to see the rest?"); + Console.WriteLine($" -> {sure}"); + + Lesson("AskYesNo(string question, bool defaultAnswer) -> bool", + @"Pass a default and enter takes it. + The capital in [y/N] tells you which one that is. Make it the safe + answer -- enter is what people press without reading.", + @" + var push = AskYesNo(""Push straight to main?"", false); + var backup = AskYesNo(""Keep a backup first?"", true);", + "hit enter for the default, or move off it first.", + "hit enter for the default, or type y/n to override."); + + var push = AskYesNo("Push straight to main?", false); + Console.WriteLine($" -> {push} (enter would have meant No)"); + + var backup = AskYesNo("Keep a backup first?", true); + Console.WriteLine($" -> {backup} (enter would have meant Yes)"); + + // ---------------------------------------------------------------- AskNumber + + Lesson("AskNumber(string question, int min, int max) -> int", + @"A whole number, kept inside the range you give it. + Arrows nudge it up and down, digits type it. It won't let the value + wander outside min..max, so you can't be handed one you'd refuse.", + @"var retries = AskNumber(""How many retries?"", 1, 5);", + "up/down to step, or type digits. Backspace edits. Enter accepts.", + "type a number from 1 to 5. Try 9 first and watch it say no."); + + var retries = AskNumber("How many retries?", 1, 5); + Console.WriteLine($" -> {retries}"); + + Lesson("AskNumber(string question) -> int", + @"Same thing without a range. Any whole number, negatives included.", + @"var anything = AskNumber(""Any whole number at all?"");", + "arrows and digits, same as before. Try a minus sign.", + "type any whole number."); + + var anything = AskNumber("Any whole number at all?"); + Console.WriteLine($" -> {anything}"); + + // ---------------------------------------------------------------- AskChoice + + Lesson("AskChoice(string question, IEnumerable options) -> T", + @"Pick one from a list. You get the option itself back, not its position. + Anything enumerable will do -- an array, a List, a LINQ query.", + @" + string[] fruits = [""apple"", ""banana"", ""cherry""]; + + var fruit = AskChoice(""Pick a fruit:"", fruits);", + "up/down to move (it wraps), home/end to jump, enter to pick.", + "type the number, or the option itself -- 'banana' works as well as 2."); + + string[] fruits = ["apple", "banana", "cherry"]; + + var fruit = AskChoice("Pick a fruit:", fruits); + Console.WriteLine($" -> {fruit}"); + + Lesson("AskChoice(..., ChoiceStyle style, ...) -> T", + @"ChoiceStyle sets the labels: Auto, Numbers, Letters or None. + Auto is the default -- no labels when there are arrow keys, numbers when + the answer has to be typed. Letters also changes what they can type: + 'b' picks the second one, and a bare '2' means nothing.", + @" + var lettered = AskChoice(""Pick again, by letter:"", + ChoiceStyle.Letters, fruits);", + "arrows as before. Typing 'c' jumps there, but enter still picks.", + "type a, b or c. Try 2 first and watch it bounce."); + + var lettered = AskChoice("Pick again, by letter:", ChoiceStyle.Letters, fruits); + Console.WriteLine($" -> {lettered}"); + + Lesson("ChoiceStyle.None", + @"None puts nothing in front of the options. With nothing on screen to + reference, you answer with the option's own text -- a number would be + naming something the list never showed.", + @" + var colour = AskChoice(""Pick a colour:"", ChoiceStyle.None, + [""red"", ""green"", ""blue""]);", + "arrows and enter, as ever.", + "type the colour itself -- 'green'. A number gets bounced."); + + var colour = AskChoice("Pick a colour:", ChoiceStyle.None, ["red", "green", "blue"]); + Console.WriteLine($" -> {colour}"); + + Lesson("AskChoice(..., Func label) -> T", + @"Options don't have to be strings. Hand it your own objects and a selector + saying what to show for each, and you get the object back -- no lookup. + The label is also what they type, so they answer with what they can see.", + @" + var repos = new[] + { + (Name: ""cshell"", Stars: 42), + (Name: ""scripts"", Stars: 7), + (Name: ""crazor"", Stars: 99), + }; + + var repo = AskChoice(""Pick a repo:"", repos, r => r.Name);", + "arrows and enter, same as always.", + "type a repo name, or its number."); + + var repos = new[] + { + (Name: "cshell", Stars: 42), + (Name: "scripts", Stars: 7), + (Name: "crazor", Stars: 99), + }; + + var repo = AskChoice("Pick a repo:", repos, r => r.Name); + Console.WriteLine($" -> {repo.Name}, which has {repo.Stars} stars (a whole tuple back, not an index)"); + + // ---------------------------------------------------------------- AskMultiChoice + + Lesson("AskMultiChoice(string question, IEnumerable options) -> T[]", + @"Pick as many as you like. You get the options themselves back. + The > shows where you are, [x] shows what's checked -- two marks, because + they're two different things. + Picking nothing is a real answer: you get an empty array rather than being + asked again. If you need at least one, say so yourself, like the loop below.", + @" + string[] toppings = [""cheese"", ""tomato"", ""basil"", ""olives""]; + + string[] chosen; + do + { + chosen = AskMultiChoice(""Choose your toppings:"", toppings); + } + while (chosen.Length == 0);", + "up/down to move, SPACE to check, enter when done. Try enter with nothing checked.", + "a comma separated list: '1,3' or 'cheese, basil'. Only commas split, so names can have spaces."); + + string[] toppings = ["cheese", "tomato", "basil", "olives"]; + + string[] chosen; + do + { + chosen = AskMultiChoice("Choose your toppings:", toppings); + if (chosen.Length == 0) + { + Console.WriteLine(" -> nothing chosen, which is allowed -- this demo is the one"); + Console.WriteLine(" asking for at least one. Go again."); + } + } + while (chosen.Length == 0); + + Console.WriteLine($" -> {string.Join(", ", chosen)}"); + + // ---------------------------------------------------------------- summary + + Console.WriteLine(); + Console.WriteLine("══ What you said ════════════════════════════════════════════════════════════"); + Console.WriteLine(); + Console.WriteLine($" AskText {name}"); + Console.WriteLine($" AskSecret {secret.Length} characters (never printed)"); + Console.WriteLine($" AskYesNo {sure}"); + Console.WriteLine($" AskYesNo(false) {push}"); + Console.WriteLine($" AskYesNo(true) {backup}"); + Console.WriteLine($" AskNumber(1,5) {retries}"); + Console.WriteLine($" AskNumber {anything}"); + Console.WriteLine($" AskChoice {fruit}"); + Console.WriteLine($" ..Letters {lettered}"); + Console.WriteLine($" ..None {colour}"); + Console.WriteLine($" ..selector {repo.Name}"); + Console.WriteLine($" AskMultiChoice {string.Join(", ", chosen)}"); + Console.WriteLine(); +} +catch (InvalidOperationException e) +{ + // The input ran out -- a pipe with too few lines in it, most likely. + Console.WriteLine(); + Console.WriteLine(e.Message); + Environment.Exit(1); +} diff --git a/src/CShell.cs b/src/CShell.cs index 0576c95..95a57a5 100644 --- a/src/CShell.cs +++ b/src/CShell.cs @@ -7,6 +7,30 @@ namespace CShellNet { + /// + /// How AskChoice() labels the options it offers. + /// + public enum ChoiceStyle + { + /// + /// Whatever the mode can afford: nothing at all when there are arrow keys to pick with, + /// numbers when the answer has to be typed. The default, and usually the right one. + /// + Auto, + + /// 1) 2) 3) -- and a typed answer may be the number. + Numbers, + + /// a) b) c) -- and a typed answer may be the letter. + Letters, + + /// + /// nothing before each. With no label on screen to reference, a typed answer is the + /// option's own text -- a position number names nothing and is refused. + /// + None, + } + /// /// CShell is class which provides the environmental equivelent of a CMD or BASH environment /// * current directory @@ -44,10 +68,63 @@ public CShell(string startingFolder = null) public bool Echo { get; set; } = true; + /// + /// Where the Ask methods get their keystrokes when they are reading keys rather than + /// lines. Null reads the console; set it to drive the rich prompts from somewhere else. + /// + public Func ReadKey { get; set; } + + /// + /// Whether the Ask methods draw their rich prompts -- a selection moved with the arrow + /// keys -- or fall back to reading a typed line. + /// + /// + /// Null, the default, decides by asking whether standard input is redirected, because + /// Console.ReadKey() throws outright when it is: piped, scheduled and CI runs have no + /// keys to read. Worth setting explicitly anywhere the answer matters, since the two + /// modes accept different input and print different things -- a script that works by + /// hand and fails under CI has usually just changed mode without being told. + /// + public bool? RichPrompts { get; set; } + + bool UseKeys + { + get { return this.RichPrompts.HasValue ? this.RichPrompts.Value : !Console.IsInputRedirected; } + } + + ConsoleKeyInfo NextKey() + { + var reader = this.ReadKey; + return reader != null ? reader() : Console.ReadKey(true); + } + /// /// Run a process /// + /// + /// All three streams are redirected, which is what makes StandardOutput readable, and + /// also what makes this the wrong method for a process that stops to ask the user + /// something -- `claude setup-token`, `gh auth login`, ssh, anything with a terminal UI. + /// Such a process ends up waiting on a stdin pipe that nothing will ever write to and + /// nothing will ever close. It never exits, nothing is printed while it waits, and there + /// is no way to answer the question it is stuck on. + /// + /// To run one of those, leave stdin and stderr on the console this shell is itself + /// attached to and capture stdout alone. That is the `program | cat` shape: the question + /// reaches the user and the answer reaches the process. + /// + /// var result = await Run(opt => opt.StartInfo(psi => + /// { + /// psi.RedirectStandardInput = false; + /// psi.RedirectStandardError = false; + /// }), "claude", "setup-token").AsResult(); + /// + /// Captured still means unseen: a terminal UI draws itself on stdout, so it shows nothing + /// at all while it waits, which looks exactly like the hang above. Print what to expect + /// before calling it. Nothing is feeding stdin either, so RedirectFrom() and piping in do + /// not apply to a call shaped like this. + /// /// /// /// @@ -687,6 +764,840 @@ public Command echo(TextReader textReader) /// public void Write(string format, object arg0, object arg1, object arg2) => Console.Write(format, arg0, arg1, arg2); + /// + /// Ask the user a question and return what they typed. + /// + /// + /// The Ask family is the script asking the user. For the other direction -- a process + /// that asks the user something itself -- see the remarks on Run(). + /// None of them will answer themselves: see ReadAnswer. + /// + /// the question, asked as written + /// what the user typed, trimmed; empty if they just pressed enter + /// standard input is at end of stream + public string AskText(string question) + { + Console.Write($"{question.TrimEnd()} "); + return ReadAnswer(question); + } + + /// + /// Ask the user for something that should not be looked at, and read it without echoing. + /// + /// + /// For tokens, passwords and keys. AskText() would put the answer on the screen, into the + /// scrollback, and into whatever is recording the terminal -- a long-lived credential is + /// worth one method to keep out of all three. Nothing is echoed at all, not even stars, + /// which is what a console password prompt conventionally does; backspace still works. + /// + /// With no keys to read this falls back to reading a line. That is not a downgrade: piped + /// input was never being echoed to a terminal, which is the only thing being avoided. + /// + /// A string, not a SecureString: SecureString does not protect its contents outside + /// Windows and .NET now advises against it, so this would be security theatre. Treat the + /// return like any other secret -- do not log it, and hand it on through stdin rather + /// than as an argument, where it would show up in the process list. + /// + /// the question, asked as written + /// what the user typed, trimmed + /// standard input is at end of stream + public string AskSecret(string question) + { + Console.Write($"{question.TrimEnd()} "); + + if (!this.UseKeys) + { + return ReadAnswer(question); + } + + var secret = new System.Text.StringBuilder(); + while (true) + { + var key = NextKey(); + + if (key.Key == ConsoleKey.Enter) + { + Console.WriteLine(); + return secret.ToString().Trim(); + } + + if (key.Key == ConsoleKey.Backspace) + { + if (secret.Length > 0) + { + secret.Length--; + } + + continue; + } + + // Arrows, function keys and the like arrive with no character to append. + if (key.KeyChar != '\0') + { + secret.Append(key.KeyChar); + } + } + } + + /// + /// Ask the user to pick one of a list, and return the one they picked. + /// + /// + /// Labelled ChoiceStyle.Auto -- nothing in front of the options when there are arrow keys + /// to pick with, numbers when the answer has to be typed. + /// + /// what is being chosen among + /// the question, asked as written + /// the things to choose between, at least one + /// what to show for each; ToString() when not given + /// the option chosen + public T AskChoice(string question, IEnumerable options, Func label = null) + { + return AskChoice(question, ChoiceStyle.Auto, options, label); + } + + /// + /// Ask the user to pick one of a list, and return the one they picked. + /// + /// + /// With keys to read, the list is drawn with the current option in brackets and moved + /// with the arrow keys, enter choosing it. Typing an option's own marker jumps to it but + /// still waits for enter, so a mistyped key costs nothing. + /// + /// Without them the list is printed once and the answer is typed: the option's LABEL -- + /// what it is shown as, not what ToString() says -- or whatever is printed in front of + /// it. The label is matched FIRST, so a list whose options are themselves numbers -- + /// "3", "1", "2" -- answers the way it reads, and typing 3 picks the option labelled 3 + /// rather than the third one. + /// + /// The prompt asks for what is on screen and takes nothing else: numbers over a numbered + /// list, letters over a lettered one, and under ChoiceStyle.None -- which prints no + /// labels at all -- the option's text and only that. + /// + /// What comes back is the option itself, not where it sat. Two options that label the + /// same are therefore indistinguishable in the answer, though a reference type still + /// hands back the instance that was chosen. + /// + /// what is being chosen among + /// the question, asked as written + /// how the options are labelled + /// the things to choose between, at least one + /// what to show for each; ToString() when not given + /// the option chosen + /// options is null + /// no options were given, or too many to letter + /// standard input is at end of stream + public T AskChoice(string question, ChoiceStyle style, IEnumerable options, Func label = null) + { + var items = Materialise("AskChoice", options, style); + var labels = Labels(items, label); + var resolved = Resolve(style); + + var picked = this.UseKeys + ? ChooseByKey(question, resolved, labels) + : ChooseByLine(question, resolved, labels); + + return items[picked - 1]; + } + + // The options as an array, with the two ways of asking for an impossible list refused up + // front. Everything below the public methods works in labels and 1-based positions; only + // AskChoice and AskMultiChoice know there is a T at all. + static T[] Materialise(string caller, IEnumerable options, ChoiceStyle style) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options), $"{caller}() was given no options at all."); + } + + var items = options.ToArray(); + + if (items.Length == 0) + { + throw new ArgumentException($"{caller}() needs at least one option to choose between.", nameof(options)); + } + + if (style == ChoiceStyle.Letters && items.Length > 26) + { + throw new ArgumentException($"{caller}() cannot letter {items.Length} options; there are 26 letters.", nameof(options)); + } + + return items; + } + + // What each option is shown as, and -- in the typed mode -- what it answers to. A null + // option labels as empty rather than throwing: a hole in a list is the caller's problem + // to see on screen, not a reason to take the prompt down. + static string[] Labels(T[] items, Func label) + { + var labels = new string[items.Length]; + for (int i = 0; i < items.Length; i++) + { + labels[i] = label != null + ? (label(items[i]) ?? "") + : (items[i] == null ? "" : items[i].ToString()); + } + + return labels; + } + + // Auto asks what the mode can afford. With arrow keys the selection IS the affordance and + // a label in front of every row is noise; without them the label is the only thing saying + // what to type, and a bare list under a "[1-3]" prompt makes you count rows yourself. + ChoiceStyle Resolve(ChoiceStyle style) + { + if (style != ChoiceStyle.Auto) + { + return style; + } + + return this.UseKeys ? ChoiceStyle.None : ChoiceStyle.Numbers; + } + + static string Marker(ChoiceStyle style, int index) + { + if (style == ChoiceStyle.Numbers) { return (index + 1) + ") "; } + if (style == ChoiceStyle.Letters) { return (char)('a' + index) + ") "; } + + return ""; + } + + // Which option a typed answer names, or 0 for none. Option TEXT is matched before any + // marker, which is what keeps a list of numbers honest. + static int FromAnswer(ChoiceStyle style, string[] options, string answer) + { + for (int i = 0; i < options.Length; i++) + { + if (String.Equals(options[i], answer, StringComparison.OrdinalIgnoreCase)) + { + return i + 1; + } + } + + if (style == ChoiceStyle.Letters) + { + if (answer.Length == 1) + { + var index = Char.ToLowerInvariant(answer[0]) - 'a'; + if (index >= 0 && index < options.Length) + { + return index + 1; + } + } + + return 0; + } + + // Under None the label is all there is. Taking a position number here would mean + // answering with something the list never showed -- "[1-3]" over an unnumbered list + // leaves you counting rows -- so the option's own text is the only answer. + if (style == ChoiceStyle.None) + { + return 0; + } + + int number; + if (int.TryParse(answer, out number) && number >= 1 && number <= options.Length) + { + return number; + } + + return 0; + } + + static void RenderChoices(ChoiceStyle style, string[] options, int selected) + { + for (int i = 0; i < options.Length; i++) + { + var item = i == selected ? "[" + options[i] + "]" : " " + options[i] + " "; + Console.WriteLine(Fill(" " + Marker(style, i) + item)); + } + } + + int ChooseByKey(string question, ChoiceStyle style, string[] options) + { + var selected = 0; + + Console.WriteLine(question); + RenderChoices(style, options, selected); + + while (true) + { + var key = NextKey(); + + if (key.Key == ConsoleKey.Enter) + { + return selected + 1; + } + + if (key.Key == ConsoleKey.UpArrow || key.Key == ConsoleKey.LeftArrow) + { + selected = (selected - 1 + options.Length) % options.Length; + } + else if (key.Key == ConsoleKey.DownArrow || key.Key == ConsoleKey.RightArrow) + { + selected = (selected + 1) % options.Length; + } + else if (key.Key == ConsoleKey.Home) + { + selected = 0; + } + else if (key.Key == ConsoleKey.End) + { + selected = options.Length - 1; + } + else if (key.KeyChar != '\0') + { + var named = FromAnswer(style, options, key.KeyChar.ToString()); + if (named > 0) + { + selected = named - 1; + } + } + + Rewind(options.Length); + RenderChoices(style, options, selected); + } + } + + int ChooseByLine(string question, ChoiceStyle style, string[] options) + { + // Written once, outside the loop. A rejected answer reprints the input line only -- + // repeating the whole question every time buries the list it refers to. + Console.WriteLine(question); + for (int i = 0; i < options.Length; i++) + { + Console.WriteLine(" " + Marker(style, i) + options[i]); + } + + // Whatever is in front of the options is what the prompt asks for: numbers over a + // numbered list, letters over a lettered one, and nothing to reference at all over + // a bare one, which just takes the text. + string hint; + if (style == ChoiceStyle.Letters) + { + hint = options.Length == 1 ? "[a] " : $"[a-{(char)('a' + options.Length - 1)}] "; + } + else if (style == ChoiceStyle.None) + { + hint = "> "; + } + else + { + hint = options.Length == 1 ? "[1] " : $"[1-{options.Length}] "; + } + + while (true) + { + Console.Write(hint); + var answer = ReadAnswer(question); + + var chosen = FromAnswer(style, options, answer); + if (chosen > 0) + { + return chosen; + } + + Console.WriteLine(answer.Length == 0 + ? "Pick one of the above." + : $"'{answer}' is not one of the above."); + } + } + + /// + /// Ask the user to pick any number of a list, and return the ones they picked. + /// + /// + /// Labelled ChoiceStyle.Auto -- nothing in front of the options when there are arrow keys + /// to pick with, numbers when the answer has to be typed. + /// + /// what is being chosen among + /// the question, asked as written + /// the things to choose among, at least one + /// what to show for each; ToString() when not given + /// the options chosen, in list order; empty if none were + public T[] AskMultiChoice(string question, IEnumerable options, Func label = null) + { + return AskMultiChoice(question, ChoiceStyle.Auto, options, label); + } + + /// + /// Ask the user to pick any number of a list, and return the ones they picked. + /// + /// + /// AskChoice() with a checkbox. With keys to read, up and down move a `>` down the list + /// and space checks the option under it, enter finishing. The cursor and the checkmarks + /// are two different things, so they get two different marks: reusing the brackets for + /// both -- as AskChoice() can afford to, having only one -- leaves a line whose state + /// nobody can read. + /// + /// Without keys the answer is typed as a comma separated list, each part being an + /// option's label, number, or letter under ChoiceStyle.Letters. Commas alone separate + /// them, so options labelled with spaces in them still answer to their labels. One part + /// that names nothing rejects the whole answer rather than silently selecting the rest. + /// + /// Choosing nothing is an answer: enter on an unchecked list, or a blank line, returns + /// an empty array rather than asking again. A caller that needs at least one has to say + /// so itself -- there is no way for this to tell an empty answer from a deliberate one. + /// + /// what is being chosen among + /// the question, asked as written + /// how the options are labelled + /// the things to choose among, at least one + /// what to show for each; ToString() when not given + /// the options chosen, in list order; empty if none were + /// options is null + /// no options were given, or too many to letter + /// standard input is at end of stream + public T[] AskMultiChoice(string question, ChoiceStyle style, IEnumerable options, Func label = null) + { + var items = Materialise("AskMultiChoice", options, style); + var labels = Labels(items, label); + var resolved = Resolve(style); + + var picked = this.UseKeys + ? ChooseManyByKey(question, resolved, labels) + : ChooseManyByLine(question, resolved, labels); + + var chosen = new T[picked.Length]; + for (int i = 0; i < picked.Length; i++) + { + chosen[i] = items[picked[i] - 1]; + } + + return chosen; + } + + static int[] Checked(bool[] chosen) + { + var picked = new List(); + for (int i = 0; i < chosen.Length; i++) + { + if (chosen[i]) + { + picked.Add(i + 1); + } + } + + return picked.ToArray(); + } + + static void RenderChecks(ChoiceStyle style, string[] options, bool[] chosen, int cursor) + { + for (int i = 0; i < options.Length; i++) + { + var pointer = i == cursor ? "> " : " "; + var box = chosen[i] ? "[x] " : "[ ] "; + Console.WriteLine(Fill(pointer + Marker(style, i) + box + options[i])); + } + } + + int[] ChooseManyByKey(string question, ChoiceStyle style, string[] options) + { + var chosen = new bool[options.Length]; + var cursor = 0; + + Console.WriteLine(question); + RenderChecks(style, options, chosen, cursor); + + while (true) + { + var key = NextKey(); + + if (key.Key == ConsoleKey.Enter) + { + return Checked(chosen); + } + + // Tested before the marker jump below, so space is never read as a label. + if (key.Key == ConsoleKey.Spacebar || key.KeyChar == ' ') + { + chosen[cursor] = !chosen[cursor]; + } + else if (key.Key == ConsoleKey.UpArrow || key.Key == ConsoleKey.LeftArrow) + { + cursor = (cursor - 1 + options.Length) % options.Length; + } + else if (key.Key == ConsoleKey.DownArrow || key.Key == ConsoleKey.RightArrow) + { + cursor = (cursor + 1) % options.Length; + } + else if (key.Key == ConsoleKey.Home) + { + cursor = 0; + } + else if (key.Key == ConsoleKey.End) + { + cursor = options.Length - 1; + } + else if (key.KeyChar != '\0') + { + var named = FromAnswer(style, options, key.KeyChar.ToString()); + if (named > 0) + { + cursor = named - 1; + } + } + + Rewind(options.Length); + RenderChecks(style, options, chosen, cursor); + } + } + + int[] ChooseManyByLine(string question, ChoiceStyle style, string[] options) + { + Console.WriteLine(question); + for (int i = 0; i < options.Length; i++) + { + Console.WriteLine(" " + Marker(style, i) + options[i]); + } + + while (true) + { + Console.Write("[comma separated, blank for none] "); + var answer = ReadAnswer(question); + + if (answer.Length == 0) + { + return new int[0]; + } + + var chosen = new bool[options.Length]; + string unknown = null; + + foreach (var part in answer.Split(',')) + { + var token = part.Trim(); + if (token.Length == 0) + { + continue; + } + + var named = FromAnswer(style, options, token); + if (named == 0) + { + unknown = token; + break; + } + + chosen[named - 1] = true; + } + + if (unknown == null) + { + return Checked(chosen); + } + + Console.WriteLine($"'{unknown}' is not one of the above."); + } + } + + /// + /// Ask the user for a whole number, asking again until they give one. + /// + /// the question, asked as written + /// the number they typed + public int AskNumber(string question) + { + return AskNumber(question, int.MinValue, int.MaxValue); + } + + /// + /// Ask the user for a whole number within a range, asking again until they give one. + /// + /// + /// With keys to read, up and down step the number and digits type it, both held to the + /// range so it can never show a value it would then refuse. Without them the number is + /// typed as a line, and one outside the range is rejected the same way an unparseable + /// one is -- a number the caller cannot use is not an answer. + /// + /// the question, asked as written + /// smallest acceptable answer, inclusive + /// largest acceptable answer, inclusive + /// the number they typed, between min and max + /// min is greater than max + /// standard input is at end of stream + public int AskNumber(string question, int min, int max) + { + if (min > max) + { + throw new ArgumentException($"AskNumber() was given an empty range: {min} to {max}.", nameof(min)); + } + + return this.UseKeys ? NumberByKey(question, min, max) : NumberByLine(question, min, max); + } + + static int Clamp(int value, int min, int max) + { + return value < min ? min : (value > max ? max : value); + } + + static string Range(int min, int max) + { + return min == int.MinValue && max == int.MaxValue ? "" : $"[{min}-{max}] "; + } + + int NumberByKey(string question, int min, int max) + { + var prefix = question.TrimEnd() + " " + Range(min, max); + var typed = Clamp(0, min, max).ToString(); + + Console.Write("\r" + Fill(prefix + typed)); + + while (true) + { + var key = NextKey(); + int current; + + if (key.Key == ConsoleKey.Enter) + { + if (int.TryParse(typed, out current) && current >= min && current <= max) + { + Console.WriteLine(); + return current; + } + } + else if (key.Key == ConsoleKey.UpArrow) + { + int.TryParse(typed, out current); + typed = Clamp(current + 1, min, max).ToString(); + } + else if (key.Key == ConsoleKey.DownArrow) + { + int.TryParse(typed, out current); + typed = Clamp(current - 1, min, max).ToString(); + } + else if (key.Key == ConsoleKey.Backspace) + { + if (typed.Length > 0) + { + typed = typed.Substring(0, typed.Length - 1); + } + } + else if (Char.IsDigit(key.KeyChar) || (key.KeyChar == '-' && typed.Length == 0)) + { + typed = typed + key.KeyChar; + } + + Console.Write("\r" + Fill(prefix + typed)); + } + } + + int NumberByLine(string question, int min, int max) + { + var range = Range(min, max); + + // An unbounded ask has no range to show, and a bare cursor under a question reads + // as a hang rather than a prompt. + var hint = range.Length > 0 ? range : "> "; + + Console.WriteLine(question); + while (true) + { + Console.Write(hint); + var answer = ReadAnswer(question); + + int number; + if (int.TryParse(answer, out number)) + { + if (number >= min && number <= max) + { + return number; + } + + Console.WriteLine($"{number} is outside {min} to {max}."); + continue; + } + + Console.WriteLine(answer.Length == 0 + ? "Type a number." + : $"'{answer}' is not a number."); + } + } + + /// + /// Ask the user a yes or no question, asking again until they answer one or the other. + /// + /// the question, asked as written + /// true for yes, false for no + public bool AskYesNo(string question) + { + return AskYesNo(question, (bool?)null); + } + + /// + /// Ask the user a yes or no question, with an answer that pressing enter accepts. + /// + /// + /// With keys to read, Yes and No sit side by side with the current one in brackets. The + /// arrow keys move between them and so do y and n -- but only enter answers, the same + /// way typing an option's marker in AskChoice() moves to it without choosing it. Without + /// keys the answer is typed as y, yes, n or no, case insensitively. + /// + /// The default is shown capitalised the way a shell script does it -- `[Y/n]` for yes, + /// `[y/N]` for no -- which makes the capital a promise. Pass the SAFE answer as the + /// default, because enter is what gets pressed by someone who is not reading. + /// + /// the question, asked as written + /// what pressing enter answers + /// true for yes, false for no + /// standard input is at end of stream + public bool AskYesNo(string question, bool defaultAnswer) + { + return AskYesNo(question, (bool?)defaultAnswer); + } + + bool AskYesNo(string question, bool? defaultAnswer) + { + return this.UseKeys ? YesNoByKey(question, defaultAnswer) : YesNoByLine(question, defaultAnswer); + } + + static string YesNoBar(bool yes) + { + return yes ? "[Yes] No " : " Yes [No]"; + } + + bool YesNoByKey(string question, bool? defaultAnswer) + { + // With no default there is still a side the selection has to start on. Starting on + // Yes and requiring enter is not the same promise as a default: nothing is accepted + // until a key says so. + var yes = defaultAnswer.HasValue ? defaultAnswer.Value : true; + var prefix = question.TrimEnd() + " "; + + Console.Write("\r" + Fill(prefix + YesNoBar(yes))); + + while (true) + { + var key = NextKey(); + + // Enter is the only thing that answers. y and n MOVE the selection rather than + // committing it, which is the same rule AskChoice() plays by when you type an + // option's marker: one key is never enough to answer a question, so a mistyped + // one costs nothing. The alternative -- y answering outright -- makes the two + // halves of the family disagree, and surprises anyone who reached for y meaning + // to look before they leapt. + if (key.Key == ConsoleKey.Enter) + { + Console.Write("\r" + Fill(prefix + YesNoBar(yes))); + Console.WriteLine(); + return yes; + } + + if (key.KeyChar == 'y' || key.KeyChar == 'Y') + { + yes = true; + } + else if (key.KeyChar == 'n' || key.KeyChar == 'N') + { + yes = false; + } + else if (key.Key == ConsoleKey.LeftArrow || key.Key == ConsoleKey.RightArrow || key.Key == ConsoleKey.Tab) + { + yes = !yes; + } + else + { + continue; + } + + Console.Write("\r" + Fill(prefix + YesNoBar(yes))); + } + } + + bool YesNoByLine(string question, bool? defaultAnswer) + { + var choices = !defaultAnswer.HasValue ? "[y/n]" + : defaultAnswer.Value ? "[Y/n]" + : "[y/N]"; + + while (true) + { + Console.Write($"{question.TrimEnd()} {choices} "); + var answer = ReadAnswer(question); + + if (answer.Length == 0 && defaultAnswer.HasValue) + { + return defaultAnswer.Value; + } + + if (String.Equals(answer, "y", StringComparison.OrdinalIgnoreCase) || + String.Equals(answer, "yes", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (String.Equals(answer, "n", StringComparison.OrdinalIgnoreCase) || + String.Equals(answer, "no", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + Console.WriteLine("Answer y or n."); + } + } + + // Move back over lines already drawn so the next render replaces them. Without a console + // there is nothing to move around in, and the renders stack up instead -- ugly on screen, + // but exactly what a captured transcript wants. + static void Rewind(int lines) + { + if (lines <= 0 || Console.IsOutputRedirected) + { + return; + } + + try + { + var top = Console.CursorTop - lines; + Console.SetCursorPosition(0, top < 0 ? 0 : top); + } + catch (IOException) + { + } + } + + // Pad a redrawn line out to the width so whatever the last render left there is erased. + static string Fill(string text) + { + if (Console.IsOutputRedirected) + { + return text; + } + + try + { + var width = Console.WindowWidth - 1; + return text.Length < width ? text.PadRight(width) : text; + } + catch (IOException) + { + return text; + } + } + + /// + /// Read one answer, refusing to treat "there is nobody there" as an answer. + /// + /// + /// ReadLine() returns null at end of stream rather than blocking, which a script run + /// non-interactively -- piped, scheduled, under CI -- hits immediately. Left unchecked + /// that is an empty answer the caller acts on, or, in the loops above, a spin that reasks + /// a question nobody can hear forever. Throwing says which question went unanswered. + /// + string ReadAnswer(string question) + { + var answer = Console.ReadLine(); + if (answer == null) + { + throw new InvalidOperationException( + $"\"{question}\" could not be answered: standard input is at end of stream, " + + "so there is no one to ask."); + } + + return answer.Trim(); + } + /// /// Write value as line to standard out /// diff --git a/src/CShell.csproj b/src/CShell.csproj index 4f51048..a0b09a0 100644 --- a/src/CShell.csproj +++ b/src/CShell.csproj @@ -1,7 +1,7 @@  - netstandard2.0 + net8.0 true Tom Laird-McConnell @@ -16,9 +16,9 @@ git scripting dotnet csharp CShell - 2.1.0.0 - 2.1.0.0 - 2.1.0 + 3.0.0.0 + 3.0.0.0 + 3.0.0 true snupkg @@ -30,7 +30,6 @@ - diff --git a/src/Cli.cs b/src/Cli.cs new file mode 100644 index 0000000..685e6e0 --- /dev/null +++ b/src/Cli.cs @@ -0,0 +1,858 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text; + +namespace CShellNet +{ + /// + /// Declares what a script accepts on its command line, and parses it. + /// + /// + /// Three words, because there are three kinds of thing: an Argument is a positional, a + /// Switch is on or off, an Option carries a value. + /// + /// var cmd = Cli.For(Args) + /// .Argument("file", "File to operate on") + /// .Switch("whatif", "What if without execute") + /// .Option("out", "where to write the result") + /// .Parse(); + /// + /// if (cmd.ShouldExit) return cmd.ExitCode; + /// + /// string file = cmd.Argument("file"); + /// bool whatIf = cmd.Switch("whatif"); + /// + /// Anything undeclared is an ERROR rather than something to skip past. Silently ignoring a + /// switch is how a mistyped dry-run does the real thing and a mistyped credential runs with + /// the wrong one -- both of which were live bugs in scripts this replaces. + /// + /// 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, no + /// typed binding, 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> examples = new List>(); + + private string program; + private string description; + private bool usageWhenEmpty; + private bool whatIfDeclared; + + private Cli(List tokens, string program) + { + this.tokens = tokens; + this.program = program; + + // 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. + this.switches.Add(new SwitchSpec(new[] { "help", "h", "?" }, new[] { "help", "h", "?" }, + "show this help", false, true)); + } + + /// + /// Begin declaring what this script accepts. + /// + /// + /// The program name shown in the usage line is worked out from the calling script's file + /// name, which is why scriptPath is filled in by the compiler and should not be passed. + /// Under dotnet-script the entry assembly is `dotnet-script` rather than the script, so + /// inferring it any other way would put the wrong name in every usage line. Program() + /// overrides it. + /// + /// the command line, `Args` in a .csx or `args` in a .cs + /// filled in by the compiler; do not pass it + /// the builder, to go on declaring + /// args is null + public static Cli For(IEnumerable args, [CallerFilePath] string scriptPath = null) + { + if (args == null) + { + throw new ArgumentNullException(nameof(args), "Cli.For() needs the command line, not null."); + } + + return new Cli(args.ToList(), ProgramFrom(scriptPath)); + } + + static string ProgramFrom(string scriptPath) + { + if (!String.IsNullOrEmpty(scriptPath)) + { + var name = Path.GetFileNameWithoutExtension(scriptPath); + + // A SCRIPT is invoked by its own file name -- a .csx once .csx is on PATHEXT, a + // .csrun through `dotnet run --file`. The entry assembly is no help for either: + // under dotnet-script it is "dotnet-script", and under a test runner it is + // whatever is hosting. A compiled app is the other way round, so it falls through. + if (!String.IsNullOrEmpty(name) && + (scriptPath.EndsWith(".csx", StringComparison.OrdinalIgnoreCase) || + scriptPath.EndsWith(".csrun", StringComparison.OrdinalIgnoreCase))) + { + return name; + } + + var entry = Assembly.GetEntryAssembly(); + if (entry != null && !String.IsNullOrEmpty(entry.GetName().Name)) + { + return entry.GetName().Name; + } + + if (!String.IsNullOrEmpty(name)) + { + return name; + } + } + + var fallback = Assembly.GetEntryAssembly(); + return fallback != null && !String.IsNullOrEmpty(fallback.GetName().Name) + ? fallback.GetName().Name + : "script"; + } + + /// + /// Name the program in the generated usage, overriding the script's file name. + /// + /// what the user types to run this + /// the builder, to go on declaring + public Cli Program(string name) + { + if (String.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Program() needs a name.", nameof(name)); + } + + this.program = name.Trim(); + return this; + } + + /// + /// The paragraph shown above the usage line, saying what the script is for. + /// + /// + /// Rendered as written apart from having its common leading whitespace removed, so a + /// verbatim string indented inside a script still comes out flush left. The line breaks + /// are the author's and are not re-wrapped. + /// + /// one or more lines of prose + /// the builder, to go on declaring + public Cli Description(string text) + { + this.description = text; + return this; + } + + /// + /// Declare a required positional argument. + /// + /// + /// Positionals fill in declaration order. A bare word on the command line is a positional + /// and never a candidate for the unknown-switch error -- which is what lets a script take + /// a path without every path being rejected as a switch it does not know. + /// + /// what it is called in the usage + /// the one line shown beside it + /// the builder, to go on declaring + /// the name or help is unusable + /// it cannot follow what is already declared + public Cli Argument(string name, string help) + { + return AddArgument(name, help, true, false); + } + + /// + /// Declare a positional argument that may be left out. + /// + /// + /// Reads back null when omitted, so `cmd.Argument("path") ?? Directory.GetCurrentDirectory()` + /// is the idiom. Its own method rather than a `required: false` argument, because a bare + /// `false` in the third position reads as nothing at the call site, and because the + /// declaration chain should read down the page the way the usage line reads across it. + /// + /// what it is called in the usage + /// the one line shown beside it + /// the builder, to go on declaring + /// the name or help is unusable + /// it cannot follow what is already declared + public Cli OptionalArgument(string name, string help) + { + return AddArgument(name, help, false, false); + } + + /// + /// Declare a tail that collects every positional left over. + /// + /// + /// Declaring a Rest STOPS switch parsing at the first positional: everything from there on + /// is collected verbatim, switches and all, so a wrapper can pass `/k dir` to the program + /// it launches. Switches before that first positional are still the script's own. + /// + /// The boundary is the first positional rather than the first unrecognised switch, so that + /// a mistyped switch before it is still rejected instead of being quietly handed to a + /// child process. + /// + /// what it is called in the usage + /// the one line shown beside it + /// the builder, to go on declaring + /// the name or help is unusable + /// it cannot follow what is already declared + public Cli Rest(string name, string help) + { + return AddArgument(name, help, false, true); + } + + Cli AddArgument(string name, string help, bool required, bool isRest) + { + CheckName(name, help, "Argument"); + + if (name.IndexOf('|') >= 0) + { + throw new ArgumentException( + $"Argument(\"{name}\") cannot have aliases -- positionals are matched by position, not by name.", + nameof(name)); + } + + if (this.arguments.Any(a => a.IsRest)) + { + throw new InvalidOperationException( + $"\"{name}\" cannot be declared after a Rest -- a rest collects everything left, so nothing can follow it."); + } + + if (required && this.arguments.Any(a => !a.Required)) + { + var optional = this.arguments.First(a => !a.Required).Name; + throw new InvalidOperationException( + $"Argument(\"{name}\") cannot follow OptionalArgument(\"{optional}\") -- an optional argument must be last."); + } + + if (this.arguments.Any(a => String.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException($"\"{name}\" is already declared as an argument."); + } + + if (Find(Normalize(name)) != null) + { + throw new InvalidOperationException( + $"\"{name}\" is already declared as a switch -- one name cannot mean both."); + } + + this.arguments.Add(new ArgSpec(name, help, required, isRest)); + return this; + } + + /// + /// Declare a switch that is either on or off. + /// + /// + /// Aliases go in the name after a pipe -- `Switch("whatif|n", "...")` -- so that the second + /// argument is ALWAYS the help text. An overload taking aliases after the help would let + /// `Switch("whatif", "n")` compile and silently make "n" the help, which is the class of + /// quiet mistake this whole type exists to prevent. + /// + /// `-whatif`, `--whatif` and `--what-if` are all the same switch: the leading dashes come + /// off, inner hyphens and underscores go, and case is ignored. + /// + /// Dashes only. `/whatif` is a positional, not a switch: `--` is the near-universal + /// standard now, and treating `/` as a prefix would make every absolute path on Linux + /// look like a switch it had to recognise. + /// + /// the name, optionally followed by |aliases + /// the one line shown beside it + /// the builder, to go on declaring + /// the name or help is unusable + /// it collides with something already declared + public Cli Switch(string name, string help) + { + return AddSwitch(name, help, false); + } + + /// + /// Declare a switch that carries a value, written attached: `-out:file` or `-out=file`. + /// + /// + /// The value ATTACHES. `-out file` is not accepted, and that is a safety property rather + /// than a shortcut: the separated form is what lets a trailing `-out` silently become a + /// positional, and `-out -whatif` silently eat the next switch as its value. Both were + /// live bugs in the scripts this replaces. An attached value is one token, so neither is + /// possible, and someone typing the separated form is told so instead of being misread. + /// + /// Only the NAME is normalized. The value is kept exactly as typed, which is what keeps + /// `-source:https://api.nuget.org/v3/index.json` and `-out:C:\temp\My-Folder` intact. + /// + /// Reads back null when not supplied, so the script writes `cmd.Option("source") ?? "..."`. + /// There is no default parameter here because real defaults are usually computed -- an + /// environment variable, the current directory -- and a parameter serving only constants + /// would be two ways to say one thing. + /// + /// the name, optionally followed by |aliases + /// the one line shown beside it + /// the builder, to go on declaring + /// the name or help is unusable + /// it collides with something already declared + public Cli Option(string name, string help) + { + return AddSwitch(name, help, true); + } + + Cli AddSwitch(string name, string help, bool takesValue) + { + CheckName(name, help, takesValue ? "Option" : "Switch"); + + 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)); + } + + foreach (var key in keys) + { + var clash = Find(key); + if (clash != null && !clash.BuiltIn) + { + throw new InvalidOperationException( + $"\"{parts[0]}\" collides with \"{clash.Primary}\" -- they are the same once case, hyphens and underscores are ignored."); + } + } + + if (this.arguments.Any(a => Normalize(a.Name) == keys[0])) + { + throw new InvalidOperationException( + $"\"{parts[0]}\" is already declared as an argument -- one name cannot mean both."); + } + + // 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) + { + var builtIn = Find(key); + if (builtIn != null) + { + this.switches.Remove(builtIn); + } + } + + this.switches.Add(new SwitchSpec(parts, keys, help, takesValue, false)); + return this; + } + + static void CheckName(string name, string help, string what) + { + if (String.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException($"{what}() needs a name.", nameof(name)); + } + + if (name[0] == '-' || name[0] == '/') + { + throw new ArgumentException( + $"{what}(\"{name}\") should be declared without a prefix -- write \"{name.TrimStart('-', '/')}\". " + + "Switches are written with dashes; '/' is not a prefix.", + nameof(name)); + } + + if (String.IsNullOrWhiteSpace(help)) + { + throw new ArgumentException( + $"{what}(\"{name}\") needs the one-line help text shown in --help.", nameof(help)); + } + + // The second argument is ALWAYS the help text; aliases live in the name after a pipe. + // Something short and word-like in that position is almost certainly an alias written + // in the wrong place, and saying so is better than silently printing it as the help. + if (help[0] == '-' || help[0] == '/' || (help.Trim().Length <= 4 && !help.Any(Char.IsWhiteSpace))) + { + throw new ArgumentException( + $"{what}(\"{name}\", \"{help}\") -- the second argument is the help text shown in --help, not an alias. " + + $"Aliases go in the name: \"{name}|{help.Trim().TrimStart('-', '/')}\".", + nameof(help)); + } + } + + /// + /// Declare the conventional dry-run switch: -whatif, also spelled --dry-run or -n. + /// + /// + /// Opt-in on purpose. A dry-run that is accepted and then ignored is worse than none at + /// all -- it is the failure where someone asks for a rehearsal and gets the real thing. + /// So the library declares the switch and nothing more; what a dry run MEANS is the + /// script's to implement, and reading CliResult.WhatIf without having declared it throws + /// rather than quietly answering false. + /// + /// the builder, to go on declaring + public Cli WhatIf() + { + this.whatIfDeclared = true; + return Switch("whatif|dry-run|n", "show what would happen, without doing it"); + } + + /// + /// Print the usage and stop when the script is run with no arguments at all. + /// + /// + /// Opt-in, because a script whose no-argument case is the real work must not print help + /// instead of doing it. Exits 0 -- being asked for help is not a failure. + /// + /// the builder, to go on declaring + public Cli UsageWhenEmpty() + { + this.usageWhenEmpty = true; + return this; + } + + /// + /// Add a worked example to the bottom of the generated help. + /// + /// the command as it would be typed + /// what it does + /// the builder, to go on declaring + public Cli Example(string commandLine, string help) + { + if (String.IsNullOrWhiteSpace(commandLine)) + { + throw new ArgumentException("Example() needs the command line to show.", nameof(commandLine)); + } + + this.examples.Add(new KeyValuePair(commandLine.Trim(), (help ?? "").Trim())); + return this; + } + + SwitchSpec Find(string key) + { + return this.switches.FirstOrDefault(s => s.Keys.Contains(key)); + } + + // Lower-cased with inner hyphens and underscores removed, so --dry-run, --dryrun and + // -Dry_Run are one switch and --api-key and --apikey are one option. + internal static string Normalize(string name) + { + var text = new StringBuilder(name.Length); + foreach (var c in name) + { + if (c != '-' && c != '_') + { + text.Append(Char.ToLowerInvariant(c)); + } + } + + return text.ToString(); + } + + /// + /// Read the command line, and stop the script if it was not valid or help was asked for. + /// + /// + /// What comes back is always usable, so a script goes straight on to reading it: + /// + /// var cmd = Cli.For(Args).Switch("whatif", "touch nothing").Parse(); + /// bool whatIf = cmd.Switch("whatif"); + /// + /// There is nothing to check, because a command line that was not understood never gets + /// this far. The message has already gone to standard error, or the help to standard + /// output, and the process has exited 1 or 0 accordingly. + /// + /// It never throws for a BAD COMMAND LINE -- a stack trace is the wrong way to say "you + /// typed --dryrun". It still throws for a mistake in the script itself, at the declaration + /// that caused it. + /// + /// Use TryParse() where exiting is not acceptable: a test, or a Cli parsed inside a + /// larger program that means to handle the failure itself. + /// + /// the parsed command line, always readable + public CliResult Parse() + { + var cmd = TryParse(); + + if (cmd.ShouldExit) + { + Environment.Exit(cmd.ExitCode); + } + + return cmd; + } + + /// + /// Read the command line without ever exiting the process. + /// + /// + /// The same work as Parse(), reported rather than acted on: check ShouldExit and use + /// ExitCode. Everything else on the result throws until you do, so a skipped check fails + /// loudly instead of running on with defaults it never earned. + /// + /// This is what Parse() is built on, and what the tests use. A script wants Parse(). + /// + /// the parsed command line, which may be one that should not be used + public CliResult TryParse() + { + var values = new Dictionary(StringComparer.Ordinal); + var flags = new HashSet(StringComparer.Ordinal); + var positionals = new List(); + + var unknown = new List(); + var badValues = new List(); + var terminated = false; + var stopSwitches = false; + var restDeclared = this.arguments.Any(a => a.IsRest); + + foreach (var raw in this.tokens) + { + if (terminated || stopSwitches) + { + positionals.Add(raw); + continue; + } + + if (raw == "--") + { + terminated = true; + continue; + } + + if (raw.Length == 0 || raw == "-" || raw[0] != '-') + { + positionals.Add(raw); + + // A declared Rest hands everything from the first positional onward to whatever + // the script is wrapping, switches included. + if (restDeclared) + { + stopSwitches = true; + } + + continue; + } + + var prefix = raw.StartsWith("--", StringComparison.Ordinal) ? 2 : 1; + var body = raw.Substring(prefix); + + // Split BEFORE normalizing, on the first separator only: the name half is + // normalized and the value half is not. The other order corrupts every value that + // contains a hyphen, a capital, or a second colon. + var sep = body.IndexOfAny(new[] { ':', '=' }); + var namePart = sep >= 0 ? body.Substring(0, sep) : body; + var valuePart = sep >= 0 ? body.Substring(sep + 1) : null; + + var spec = Find(Normalize(namePart)); + + if (spec == null) + { + // A negative number is a value, not a mistake. Anything else starting with a + // dash was meant as a switch, so say that it is not one. + if (namePart.Length > 0 && Char.IsDigit(namePart[0])) + { + positionals.Add(raw); + if (restDeclared) { stopSwitches = true; } + } + else + { + unknown.Add(raw); + } + + continue; + } + + if (spec.TakesValue) + { + if (valuePart == null || valuePart.Length == 0) + { + // Never echo what followed: someone typing the separated form may well have + // put a secret in the next token. + badValues.Add($"{Dash(spec.Primary)} needs a value, attached to the switch: '{Dash(spec.Primary)}:value'."); + } + else if (values.ContainsKey(spec.Keys[0])) + { + badValues.Add($"{Dash(spec.Primary)} was given more than once."); + } + else + { + values[spec.Keys[0]] = valuePart; + } + } + else + { + if (valuePart != null) + { + badValues.Add($"{Dash(spec.Primary)} is a switch and takes no value -- write it as '{Dash(spec.Primary)}'."); + } + else + { + flags.Add(spec.Keys[0]); + } + } + } + + var usage = RenderUsage(); + var helpKey = this.switches.First(s => s.Keys.Contains("help")).Keys[0]; + var helpAsked = flags.Contains(helpKey); + + // Being asked for help wins over anything wrong with the rest of the line: someone + // fumbling the syntax and reaching for --help should get --help. + if (this.usageWhenEmpty && this.tokens.Count == 0) + { + Console.Out.WriteLine(usage); + return CliResult.Exiting(this.program, 0, null, true, usage); + } + + if (helpAsked) + { + Console.Out.WriteLine(usage); + return CliResult.Exiting(this.program, 0, null, true, usage); + } + + // 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. + if (unknown.Count > 0 || badValues.Count > 0) + { + var lines = new List(); + if (unknown.Count == 1) + { + lines.Add($"{this.program}: unknown switch '{unknown[0]}'"); + } + else if (unknown.Count > 1) + { + lines.Add($"{this.program}: unknown switches: {String.Join(" ", unknown.Select(u => "'" + u + "'"))}"); + } + + foreach (var bad in badValues) + { + lines.Add($"{this.program}: {bad}"); + } + + return Failed(String.Join(Environment.NewLine, lines), usage); + } + + // Fill the declared positionals in order, then the rest. + var taken = new Dictionary(StringComparer.OrdinalIgnoreCase); + var tail = new List(); + var next = 0; + + foreach (var arg in this.arguments) + { + if (arg.IsRest) + { + while (next < positionals.Count) + { + tail.Add(positionals[next++]); + } + + break; + } + + if (next < positionals.Count) + { + taken[arg.Name] = positionals[next++]; + } + } + + var missing = this.arguments.FirstOrDefault(a => a.Required && !taken.ContainsKey(a.Name)); + if (missing != null) + { + return Failed($"{this.program}: missing <{missing.Name}>.", usage); + } + + var extra = positionals.Skip(next).ToList(); + if (extra.Count == 1) + { + return Failed($"{this.program}: unexpected argument '{extra[0]}'.", usage); + } + + if (extra.Count > 1) + { + return Failed( + $"{this.program}: unexpected arguments: {String.Join(" ", extra.Select(e => "'" + e + "'"))}", + usage); + } + + return CliResult.Parsed(this.program, usage, flags, values, taken, tail, + this.switches, this.arguments, this.whatIfDeclared); + } + + CliResult Failed(string error, string usage) + { + 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); + } + + static string Dash(string name) + { + return name.Length == 1 ? "-" + name : "--" + name; + } + + internal string RenderUsage() + { + var text = new StringBuilder(); + + if (!String.IsNullOrWhiteSpace(this.description)) + { + foreach (var prose in Dedent(this.description)) + { + text.AppendLine(prose); + } + + text.AppendLine(); + } + + 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 withSwitches = new StringBuilder(line.ToString()); + foreach (var s in this.switches) + { + withSwitches.Append(" [" + Spelling(s) + "]"); + } + + text.AppendLine("Usage:"); + text.AppendLine(withSwitches.Length <= 78 ? withSwitches.ToString() : line + " [switches]"); + + // One column across both sections, so the two lists line up as one block. + var widest = 0; + foreach (var a in this.arguments) { widest = Math.Max(widest, a.Name.Length); } + foreach (var s in spelled) { widest = Math.Max(widest, s.Length); } + var column = Math.Min(2 + widest + 2, 30); + + if (this.arguments.Count > 0) + { + text.AppendLine(); + text.AppendLine("Arguments:"); + foreach (var a in this.arguments) + { + Row(text, a.Name, a.Help, column); + } + } + + text.AppendLine(); + text.AppendLine("Switches:"); + for (int i = 0; i < this.switches.Count; i++) + { + Row(text, spelled[i], this.switches[i].Help, column); + } + + if (this.examples.Count > 0) + { + text.AppendLine(); + text.AppendLine("Examples:"); + foreach (var e in this.examples) + { + text.AppendLine(" " + e.Key); + if (e.Value.Length > 0) + { + text.AppendLine(" " + e.Value); + } + } + } + + return text.ToString().TrimEnd(); + } + + static string Spelling(SwitchSpec spec) + { + // Every spelling the user may type, primary first, so the help teaches the aliases + // instead of hiding them. + var text = String.Join(", ", spec.Spellings.Select(Dash)); + return spec.TakesValue ? text + ":" : text; + } + + static void Row(StringBuilder text, string left, string help, int column) + { + var padded = " " + left; + if (padded.Length + 2 <= column) + { + text.AppendLine(padded.PadRight(column) + help); + } + else + { + // Too wide to share a line; the help goes underneath, still in the column. + text.AppendLine(padded); + text.AppendLine(new string(' ', column) + help); + } + } + + // Strip the indentation a verbatim string literal carries, so an indented declaration in a + // script still renders flush left. The author's line breaks are left alone. + internal static IEnumerable Dedent(string text) + { + var lines = text.Replace("\r\n", "\n").Split('\n').Select(l => l.TrimEnd()).ToList(); + while (lines.Count > 0 && lines[0].Length == 0) { lines.RemoveAt(0); } + while (lines.Count > 0 && lines[lines.Count - 1].Length == 0) { lines.RemoveAt(lines.Count - 1); } + + var indent = lines.Where(l => l.Length > 0) + .Select(l => l.Length - l.TrimStart().Length) + .DefaultIfEmpty(0) + .Min(); + + return lines.Select(l => l.Length >= indent ? l.Substring(indent) : l.TrimStart()); + } + } + + internal class SwitchSpec + { + public SwitchSpec(string[] spellings, string[] keys, string help, bool takesValue, bool builtIn) + { + this.Spellings = spellings; + this.Primary = spellings[0]; + this.Keys = keys; + this.Help = help; + this.TakesValue = takesValue; + this.BuiltIn = builtIn; + } + + public string Primary { get; private set; } + + // The spellings as the author wrote them. Keys are normalized for matching; these are + // what help shows, so a switch declared "dry-run" is not advertised as "--dryrun". + public string[] Spellings { get; private set; } + + public string[] Keys { get; private set; } + + public string Help { get; private set; } + + public bool TakesValue { get; private set; } + + public bool BuiltIn { get; private set; } + } + + internal class ArgSpec + { + public ArgSpec(string name, string help, bool required, bool isRest) + { + this.Name = name; + this.Help = help; + this.Required = required; + this.IsRest = isRest; + } + + public string Name { get; private set; } + + public string Help { get; private set; } + + public bool Required { get; private set; } + + public bool IsRest { get; private set; } + } +} diff --git a/src/CliResult.cs b/src/CliResult.cs new file mode 100644 index 0000000..59043c7 --- /dev/null +++ b/src/CliResult.cs @@ -0,0 +1,244 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace CShellNet +{ + /// + /// A command line that has been read against what a script declared. + /// + /// + /// The same words that declared each thing read it back -- Argument, Switch and Option mean + /// "declare" on Cli and "read" here -- so the block that reads the command line can be checked + /// line for line against the block that declared it. + /// + /// From Cli.Parse() this is always usable, because a command line that was not understood + /// exited the process instead of arriving here. Read it and get on with the script. + /// + /// From Cli.TryParse() it may be one that should not be used, so check ShouldExit first. + /// Forgetting is caught rather than ignored: every value below THROWS once the command line + /// turned out to be bad, because the alternative -- handing back defaults for a line that was + /// never understood -- is the silent-wrong-behaviour this type exists to prevent. The error + /// itself has already been written to standard error by then, so what the user sees is the + /// real message first and a loud failure second. + /// + public class CliResult + { + private readonly HashSet flags; + private readonly Dictionary values; + private readonly Dictionary args; + private readonly List rest; + private readonly List switches; + private readonly List arguments; + private readonly bool whatIfDeclared; + + private CliResult(string program, int exitCode, string error, bool helpRequested, string usage) + { + this.ProgramName = program; + this.ExitCode = exitCode; + this.Error = error; + this.HelpRequested = helpRequested; + this.UsageText = usage; + this.ShouldExit = true; + } + + private CliResult(string program, string usage, HashSet flags, Dictionary values, + Dictionary args, List rest, List switches, + List arguments, bool whatIfDeclared) + { + this.ProgramName = program; + this.UsageText = usage; + this.flags = flags; + this.values = values; + this.args = args; + this.rest = rest; + this.switches = switches; + this.arguments = arguments; + this.whatIfDeclared = whatIfDeclared; + } + + internal static CliResult Exiting(string program, int exitCode, string error, bool helpRequested, string usage) + { + return new CliResult(program, exitCode, error, helpRequested, usage); + } + + internal static CliResult Parsed(string program, string usage, HashSet flags, + Dictionary values, Dictionary args, + List rest, List switches, List arguments, + bool whatIfDeclared) + { + return new CliResult(program, usage, flags, values, args, rest, switches, arguments, whatIfDeclared); + } + + /// The name shown in the usage line. + public string ProgramName { get; private set; } + + /// + /// True when the script should stop -- help was shown, or the command line was not valid. + /// + /// + /// Always false from Parse(), which will have exited instead. This is for TryParse(). + /// Whatever it reports has already been printed: help to standard output, an error to + /// standard error. + /// + public bool ShouldExit { get; private set; } + + /// What to return: 0 for help, 1 for a command line that was not valid. + public int ExitCode { get; private set; } + + /// What was wrong with the command line, or null when nothing was. + public string Error { get; private set; } + + /// True when the user asked for help rather than getting something wrong. + public bool HelpRequested { get; private set; } + + /// The generated help, whether or not it was shown. + public string UsageText { get; private set; } + + /// + /// What was given for a declared positional, or null when an optional one was left out. + /// + /// the name it was declared with + /// the value, or null + /// the command line was not valid + /// nothing was declared by that name + public string Argument(string name) + { + Readable(); + + if (!this.arguments.Any(a => String.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase))) + { + throw Undeclared("argument", name, this.arguments.Select(a => a.Name)); + } + + string value; + return this.args.TryGetValue(name, out value) ? value : null; + } + + /// + /// Whether a declared switch was given. + /// + /// the name it was declared with, or any of its aliases + /// true when it was given + /// the command line was not valid + /// nothing was declared by that name + public bool Switch(string name) + { + Readable(); + var spec = Spec(name, false); + return this.flags.Contains(spec.Keys[0]); + } + + /// + /// The value given for a declared option, or null when it was not supplied. + /// + /// + /// Null rather than a default, so the script says what its default is: + /// `cmd.Option("source") ?? "https://api.nuget.org/v3/index.json"`. + /// + /// the name it was declared with, or any of its aliases + /// the value as typed, or null + /// the command line was not valid + /// nothing was declared by that 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; + } + + /// + /// Whether the dry-run switch was given. + /// + /// + /// Throws when the script never called Cli.WhatIf(). Answering false would mean a script + /// that forgot to declare it silently never rehearses -- someone asks for a dry run and + /// gets the real thing, which is the worst failure this whole type is guarding against. + /// + /// the command line was not valid, or WhatIf() was never declared + public bool WhatIf + { + get + { + Readable(); + + if (!this.whatIfDeclared) + { + throw new InvalidOperationException( + "WhatIf was never declared -- add .WhatIf() to the Cli chain, or this script has no dry run to report."); + } + + return this.flags.Contains("whatif"); + } + } + + /// Everything the declared Rest collected, or empty when it collected nothing. + /// the command line was not valid, or no Rest was declared + public IReadOnlyList Rest + { + get + { + Readable(); + + if (!this.arguments.Any(a => a.IsRest)) + { + throw new InvalidOperationException("No Rest was declared -- add .Rest(name, help) to the Cli chain."); + } + + return this.rest; + } + } + + /// Every positional given, in the order it was given. + /// the command line was not valid + public IReadOnlyList Arguments + { + get + { + Readable(); + + var all = this.arguments.Where(a => !a.IsRest) + .Select(a => this.args.ContainsKey(a.Name) ? this.args[a.Name] : null) + .Where(v => v != null) + .ToList(); + all.AddRange(this.rest); + return all; + } + } + + void Readable() + { + if (this.ShouldExit) + { + throw new InvalidOperationException( + "The command line was not valid, so there is nothing to read from it -- check ShouldExit before reading anything."); + } + } + + SwitchSpec Spec(string name, bool wantValue) + { + var key = Cli.Normalize(name ?? ""); + var spec = this.switches.FirstOrDefault(s => s.Keys.Contains(key) && s.TakesValue == wantValue); + + if (spec == null) + { + throw Undeclared(wantValue ? "option" : "switch", name, + this.switches.Where(s => s.TakesValue == wantValue).Select(s => s.Primary)); + } + + return spec; + } + + static ArgumentException Undeclared(string what, string name, IEnumerable declared) + { + var known = declared.ToList(); + var list = known.Count > 0 ? String.Join(", ", known.Select(k => "\"" + k + "\"")) : "nothing"; + + // Name what WAS declared: a typo in the script is as easy to make as one on the + // command line, and as quiet. + return new ArgumentException($"No {what} \"{name}\" was declared. Declared: {list}.", nameof(name)); + } + } +} diff --git a/src/CommandExtensions.cs b/src/CommandExtensions.cs index 241b9ea..7c8dd30 100644 --- a/src/CommandExtensions.cs +++ b/src/CommandExtensions.cs @@ -1,5 +1,6 @@ using Medallion.Shell; -using Newtonsoft.Json; +using System.Text.Json; +using System.Text.Json.Nodes; using System; using System.Diagnostics; using System.IO; @@ -34,11 +35,16 @@ public async static Task AsString(this Command cmd, bool log = false) } /// - /// Convert StandardOutput of command to dynamic object using Json deserialization (JObject) + /// Parse the StandardOutput of a command as JSON. /// + /// + /// Walk it with a dot -- `json.owner.login` -- or with an indexer, or assign it to a + /// JsonNode, JsonObject or JsonArray for the typed System.Text.Json API. The object behind + /// it is a JsonDynamic either way. Use AsJson<T>() where the shape is known. + /// /// /// if true the output to standardout/error - /// JObject + /// the parsed JSON, as a JsonDynamic public async static Task AsJson(this Command cmd, bool log = false) { var cmdResult = await cmd.Task.ConfigureAwait(false); @@ -52,7 +58,7 @@ public async static Task AsJson(this Command cmd, bool log = false) throw new CommandResultException(cmdResult); } - return JsonConvert.DeserializeObject(cmdResult.StandardOutput); + return new JsonDynamic(JsonNode.Parse(Json.Clean(cmdResult.StandardOutput), null, Json.DocumentOptions)); } /// @@ -75,7 +81,7 @@ public async static Task AsJson(this Command cmd, bool log = false) throw new CommandResultException(cmdResult); } - return JsonConvert.DeserializeObject(cmdResult.StandardOutput); + return JsonSerializer.Deserialize(Json.Clean(cmdResult.StandardOutput), Json.Options); } /// diff --git a/src/CommandResultExtensions.cs b/src/CommandResultExtensions.cs index 6436e57..1dba163 100644 --- a/src/CommandResultExtensions.cs +++ b/src/CommandResultExtensions.cs @@ -1,5 +1,6 @@ using Medallion.Shell; -using Newtonsoft.Json; +using System.Text.Json; +using System.Text.Json.Nodes; using System.IO; using System.Xml.Serialization; @@ -23,10 +24,15 @@ public static string AsString(this CommandResult cmdResult) } /// - /// Convert StandardOutput of command to dynamic object using Json deserialization (JObject) + /// Parse the StandardOutput of a command as JSON. /// + /// + /// Walk it with a dot -- `json.owner.login` -- or with an indexer, or assign it to a + /// JsonNode, JsonObject or JsonArray for the typed System.Text.Json API. The object behind + /// it is a JsonDynamic either way. Use AsJson<T>() where the shape is known. + /// /// - /// + /// the parsed JSON, as a JsonDynamic public static dynamic AsJson(this CommandResult cmdResult) { if (!cmdResult.Success) @@ -34,7 +40,7 @@ public static dynamic AsJson(this CommandResult cmdResult) throw new CommandResultException(cmdResult); } - return JsonConvert.DeserializeObject(cmdResult.StandardOutput); + return new JsonDynamic(JsonNode.Parse(Json.Clean(cmdResult.StandardOutput), null, Json.DocumentOptions)); } /// @@ -50,7 +56,7 @@ public static T AsJson(this CommandResult cmdResult) throw new CommandResultException(cmdResult); } - return JsonConvert.DeserializeObject(cmdResult.StandardOutput); + return JsonSerializer.Deserialize(Json.Clean(cmdResult.StandardOutput), Json.Options); } /// diff --git a/src/Globals.cs b/src/Globals.cs index 0af6306..9adfbc8 100644 --- a/src/Globals.cs +++ b/src/Globals.cs @@ -18,6 +18,19 @@ public static class Globals public static bool Echo { get => _shell.Echo; set => _shell.Echo = value; } + /// + /// Where the Ask methods get their keystrokes when reading keys rather than lines. + /// Null reads the console. See CShell.ReadKey. + /// + public static Func ReadKey { get => _shell.ReadKey; set => _shell.ReadKey = value; } + + /// + /// Whether the Ask methods draw their rich prompts or fall back to reading a typed line. + /// Null, the default, decides by asking whether standard input is redirected, because + /// Console.ReadKey() throws when it is. See CShell.RichPrompts. + /// + public static bool? RichPrompts { get => _shell.RichPrompts; set => _shell.RichPrompts = value; } + /// /// Reset global shell state. /// @@ -30,6 +43,29 @@ public static void ResetShell(string startFolder=null) /// /// Run a process /// + /// + /// All three streams are redirected, which is what makes StandardOutput readable, and + /// also what makes this the wrong method for a process that stops to ask the user + /// something -- `claude setup-token`, `gh auth login`, ssh, anything with a terminal UI. + /// Such a process ends up waiting on a stdin pipe that nothing will ever write to and + /// nothing will ever close. It never exits, nothing is printed while it waits, and there + /// is no way to answer the question it is stuck on. + /// + /// To run one of those, leave stdin and stderr on the console this shell is itself + /// attached to and capture stdout alone. That is the `program | cat` shape: the question + /// reaches the user and the answer reaches the process. + /// + /// var result = await Run(opt => opt.StartInfo(psi => + /// { + /// psi.RedirectStandardInput = false; + /// psi.RedirectStandardError = false; + /// }), "claude", "setup-token").AsResult(); + /// + /// Captured still means unseen: a terminal UI draws itself on stdout, so it shows nothing + /// at all while it waits, which looks exactly like the hang above. Print what to expect + /// before calling it. Nothing is feeding stdin either, so RedirectFrom() and piping in do + /// not apply to a call shaped like this. + /// /// /// /// @@ -64,6 +100,137 @@ public static Command Start(string executable, params Object[] arguments) public static Command Start(Action options, string executable, params Object[] arguments) => _shell.Start(options, executable, arguments); + /// + /// Ask the user a question and return what they typed. + /// + /// + /// The Ask family is the script asking the user. For the other direction -- a process + /// that asks the user something itself -- see the remarks on Run(). All of them throw if + /// standard input is at end of stream, rather than answering for someone who is not + /// there. See CShell.AskText(). + /// + /// the question, asked as written + /// what the user typed, trimmed; empty if they just pressed enter + public static string AskText(string question) + => _shell.AskText(question); + + /// + /// Ask the user for something that should not be looked at, and read it without echoing. + /// + /// + /// For tokens, passwords and keys -- AskText() would leave the answer on the screen and + /// in the scrollback. Falls back to reading a line when standard input is redirected, + /// where there is no terminal echoing it anyway. See CShell.AskSecret(). + /// + /// the question, asked as written + /// what the user typed, trimmed + public static string AskSecret(string question) + => _shell.AskSecret(question); + + /// + /// Ask the user to pick one of a list, and return the one they picked. + /// + /// + /// Labelled ChoiceStyle.Auto: nothing in front of the options when there are arrow keys + /// to pick with, numbers when the answer has to be typed. See CShell.AskChoice(). + /// + /// what is being chosen among + /// the question, asked as written + /// the things to choose between, at least one + /// what to show for each; ToString() when not given + /// the option chosen + public static T AskChoice(string question, IEnumerable options, Func label = null) + => _shell.AskChoice(question, options, label); + + /// + /// Ask the user to pick one of a list, and return the one they picked. + /// + /// + /// With keys to read the list is moved with the arrow keys, the current option shown in + /// brackets. Without them the answer is typed: the option's label, its number, or its + /// letter under ChoiceStyle.Letters -- label matched first. See CShell.AskChoice(). + /// + /// what is being chosen among + /// the question, asked as written + /// how the options are labelled + /// the things to choose between, at least one + /// what to show for each; ToString() when not given + /// the option chosen + public static T AskChoice(string question, ChoiceStyle style, IEnumerable options, Func label = null) + => _shell.AskChoice(question, style, options, label); + + /// + /// Ask the user to pick any number of a list, and return the ones they picked. + /// + /// + /// Labelled ChoiceStyle.Auto: nothing in front of the options when there are arrow keys + /// to pick with, numbers when the answer has to be typed. See CShell.AskMultiChoice(). + /// + /// what is being chosen among + /// the question, asked as written + /// the things to choose among, at least one + /// what to show for each; ToString() when not given + /// the options chosen, in list order; empty if none were + public static T[] AskMultiChoice(string question, IEnumerable options, Func label = null) + => _shell.AskMultiChoice(question, options, label); + + /// + /// Ask the user to pick any number of a list, and return the ones they picked. + /// + /// + /// With keys to read, up and down move a `>` down the list and space checks the option + /// under it. Without them the answer is a comma separated list of labels, numbers or + /// letters. Choosing nothing is an answer and returns an empty array. + /// See CShell.AskMultiChoice(). + /// + /// what is being chosen among + /// the question, asked as written + /// how the options are labelled + /// the things to choose among, at least one + /// what to show for each; ToString() when not given + /// the options chosen, in list order; empty if none were + public static T[] AskMultiChoice(string question, ChoiceStyle style, IEnumerable options, Func label = null) + => _shell.AskMultiChoice(question, style, options, label); + + /// + /// Ask the user for a whole number, asking again until they give one. + /// + /// the question, asked as written + /// the number they typed + public static int AskNumber(string question) + => _shell.AskNumber(question); + + /// + /// Ask the user for a whole number within a range, asking again until they give one. + /// + /// the question, asked as written + /// smallest acceptable answer, inclusive + /// largest acceptable answer, inclusive + /// the number they typed, between min and max + public static int AskNumber(string question, int min, int max) + => _shell.AskNumber(question, min, max); + + /// + /// Ask the user a yes or no question, asking again until they answer one or the other. + /// + /// the question, asked as written + /// true for yes, false for no + public static bool AskYesNo(string question) + => _shell.AskYesNo(question); + + /// + /// Ask the user a yes or no question, with an answer that pressing enter accepts. + /// + /// + /// Shown `[Y/n]` or `[y/N]`, so the capital is a promise -- pass the SAFE answer as the + /// default, because enter is what gets pressed by someone who is not reading. + /// + /// the question, asked as written + /// what pressing enter answers + /// true for yes, false for no + public static bool AskYesNo(string question, bool defaultAnswer) + => _shell.AskYesNo(question, defaultAnswer); + /// /// Run a cmd/bash command /// diff --git a/src/Json.cs b/src/Json.cs new file mode 100644 index 0000000..bb2c490 --- /dev/null +++ b/src/Json.cs @@ -0,0 +1,47 @@ +using System; +using System.Text.Json; + +namespace CShellNet +{ + /// + /// How CShell reads the JSON that command line tools produce. + /// + /// + /// System.Text.Json is stricter out of the box than the JSON real tools emit, and stricter + /// than the Newtonsoft reader this replaced. Three of its defaults are relaxed here, each + /// because a script would otherwise fail on output it had no hand in producing: + /// + /// * property names are matched case insensitively, so `{"name":"..."}` still fills a + /// `Name` property. Nearly every CLI emits camelCase or snake_case JSON while the C# + /// record modelling it is PascalCase, and the strict default would not error -- it would + /// hand back an object with every field left at its default, which is the worst way to + /// fail. + /// * trailing commas are allowed. + /// * comments are skipped rather than rejected. + /// + /// A byte order mark is trimmed for the same reason: a UTF-8 BOM is not valid JSON, tools + /// and files on Windows produce it constantly, and the resulting error names a character + /// nobody can see. + /// + internal static class Json + { + internal static readonly JsonSerializerOptions Options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip, + }; + + internal static readonly JsonDocumentOptions DocumentOptions = new JsonDocumentOptions + { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip, + }; + + /// Whatever a tool wrote, made safe to hand to the parser. + internal static string Clean(string json) + { + return json == null ? null : json.TrimStart('', '​').Trim(); + } + } +} diff --git a/src/JsonDynamic.cs b/src/JsonDynamic.cs new file mode 100644 index 0000000..d80eb56 --- /dev/null +++ b/src/JsonDynamic.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Dynamic; +using System.Linq; +using System.Linq.Expressions; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CShellNet +{ + /// + /// JSON you can walk with a dot: `json.owner.login`. + /// + /// + /// This is what AsJson() hands back, as `dynamic`, so a script can read the output of a tool + /// without declaring a type for it: + /// + /// var json = await Cmd("gh api repos/tomlm/CShell").AsJson(); + /// Console.WriteLine(json.owner.login); + /// Console.WriteLine(json["stargazers_count"]); + /// + /// System.Text.Json has no equivalent of its own, mostly because dynamic dispatch cannot be + /// trimmed or compiled ahead of time -- a real constraint for a shipped application and no + /// constraint at all for a utility script, which is the only thing running this. + /// + /// It is a wrapper rather than a subclass because JsonObject is sealed and JsonNode's only + /// constructor is internal, so neither can be derived from. Conversions cover the difference: + /// assign it to a JsonNode, JsonObject or JsonArray and you get the node underneath, which + /// keeps every typed System.Text.Json API available. + /// + /// A member that is not there reads as null rather than throwing, which is what makes + /// `if (json.optional != null)` the way to test for a field. The cost is that a typo reads as + /// null too, and only announces itself one hop later. + /// + public class JsonDynamic : DynamicObject, IEnumerable + { + private readonly JsonNode node; + + /// Wrap a parsed JSON node. + /// the node to wrap, which may be null + public JsonDynamic(JsonNode node) + { + this.node = node; + } + + /// The node underneath, for anything that wants the typed API. + public JsonNode Node + { + get { return this.node; } + } + + internal static object Wrap(JsonNode node) + { + return node == null ? null : new JsonDynamic(node); + } + + /// json.owner -- a property that is not there reads as null. + public override bool TryGetMember(GetMemberBinder binder, out object result) + { + result = Property(binder.Name); + return true; + } + + /// json["owner"] for an object, json[0] for an array. + public override bool TryGetIndex(GetIndexBinder binder, object[] indexes, out object result) + { + var index = indexes != null && indexes.Length > 0 ? indexes[0] : null; + + if (index is string name) + { + result = Property(name); + return true; + } + + if (index is int position && this.node is JsonArray array) + { + result = position >= 0 && position < array.Count ? Wrap(array[position]) : null; + return true; + } + + result = null; + return true; + } + + /// Assigning to a typed variable: string, int, JsonNode, a record, anything. + public override bool TryConvert(ConvertBinder binder, out object result) + { + // Asking for a JsonNode, JsonObject or JsonArray gets the node itself rather than a + // copy, so the typed API keeps working on the same instance. + if (this.node != null && binder.Type.IsInstanceOfType(this.node)) + { + result = this.node; + return true; + } + + if (binder.Type == typeof(string)) + { + result = this.node == null ? null : this.node.ToString(); + return true; + } + + result = this.node == null ? null : JsonSerializer.Deserialize(this.node, binder.Type, Json.Options); + return true; + } + + /// + /// json.age == 42, json.name == "Joe", json.age + 1. + /// + /// + /// Without this every comparison throws RuntimeBinderException, because DynamicObject + /// binds members and conversions but not operators. The value is read as whatever the + /// other side is, then the operator is applied to that. + /// + public override bool TryBinaryOperation(BinaryOperationBinder binder, object arg, out object result) + { + dynamic left = arg == null || this.node == null + ? (object)null + : JsonSerializer.Deserialize(this.node, arg.GetType(), Json.Options); + dynamic right = arg; + + switch (binder.Operation) + { + case ExpressionType.Equal: result = left == right; return true; + case ExpressionType.NotEqual: result = left != right; return true; + case ExpressionType.LessThan: result = left < right; return true; + case ExpressionType.LessThanOrEqual: result = left <= right; return true; + case ExpressionType.GreaterThan: result = left > right; return true; + case ExpressionType.GreaterThanOrEqual: result = left >= right; return true; + case ExpressionType.Add: result = left + right; return true; + case ExpressionType.Subtract: result = left - right; return true; + case ExpressionType.Multiply: result = left * right; return true; + case ExpressionType.Divide: result = left / right; return true; + default: result = null; return false; + } + } + + /// The property names, so a debugger and `foreach` over an object can see them. + public override IEnumerable GetDynamicMemberNames() + { + return this.node is JsonObject o ? o.Select(p => p.Key) : Enumerable.Empty(); + } + + /// foreach over an array. Declared as object because IEnumerable<dynamic> is not a legal interface to implement. + public IEnumerator GetEnumerator() + { + var array = this.node as JsonArray; + return array == null + ? Enumerable.Empty().GetEnumerator() + : array.Select(Wrap).GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// The value as text: a string without its quotes, anything else as JSON. + public override string ToString() + { + return this.node == null ? String.Empty : this.node.ToString(); + } + + /// The node underneath, for typed System.Text.Json code. + public static implicit operator JsonNode(JsonDynamic json) + { + return json == null ? null : json.node; + } + + /// The node underneath as an object, or null if it is not one. + public static implicit operator JsonObject(JsonDynamic json) + { + return json == null ? null : json.node as JsonObject; + } + + /// The node underneath as an array, or null if it is not one. + public static implicit operator JsonArray(JsonDynamic json) + { + return json == null ? null : json.node as JsonArray; + } + + object Property(string name) + { + JsonNode value; + return this.node is JsonObject o && o.TryGetPropertyValue(name, out value) ? Wrap(value) : null; + } + } +} diff --git a/template/CShell.Template/content/CShellTemplate.csx b/template/CShell.Template/content/CShellTemplate.csx index 45aa6f8..ee23ab6 100644 --- a/template/CShell.Template/content/CShellTemplate.csx +++ b/template/CShell.Template/content/CShellTemplate.csx @@ -1,5 +1,5 @@ #!/usr/bin/env dotnet-script -#r "nuget: CShell, 2.1.0" +#r "nuget: CShell, 3.0.0" global using static CShellNet.Globals; using CShellNet; @@ -12,11 +12,37 @@ using CShellNet; // MAC/LINUX you need to mark the script file as executable // chmod +x filename.csx // -// To debug this I HIGHLY recommend LinqPad9 https://linqpad.net +// To debug this I HIGHLY recommend LinqPad9 https://linqpad.net -foreach (var arg in Args) +var cmd = Cli.For(Args) + .Description("..description.") + .Argument("file", "the file to work on") + .OptionalArgument("output", "where to write the result; defaults to the input name") + .Switch("force", "overwrite the output if it is already there") + .Option("format", "the output format; defaults to json") + .WhatIf() + .Example("CShellTemplate report.txt", "write report.json beside it") + .Parse(); + +var file = cmd.Argument("file"); +var format = cmd.Option("format") ?? "json"; +var output = cmd.Argument("output") ?? Path.ChangeExtension(file, format); + +if (cmd.WhatIf) +{ + print($"would write {file} to {output} as {format}"); + return 0; +} + +// Ask the user anything the command line did not settle. AskYesNo, AskText, AskSecret, +// AskNumber, AskChoice and AskMultiChoice all read arrow keys at a terminal and a typed +// line when input is piped. +if (!cmd.Switch("force") && exists(output) && !AskYesNo($"{output} already exists. Overwrite?", false)) { - WriteLine(arg); - print(arg); + return 1; } +// your script goes here +print($"writing {file} to {output} as {format}"); + +return 0; diff --git a/turtle.png b/turtle.png index 77e6682..82e7083 100644 Binary files a/turtle.png and b/turtle.png differ