From 5acb6dfb05e4ccf5c325e854b22cba66d9f2a729 Mon Sep 17 00:00:00 2001 From: CuiSharp Contributor Date: Thu, 12 Nov 2026 08:38:49 +0800 Subject: [PATCH 1/3] Carry the documentation in dotnet cui so an agent needs one install An assistant had to be pointed at the repository's skills folder to learn how to write a CuiSharp UI, which meant every project either copied the framework's rules into its own instruction file or left them out. The tool now carries those pages. dotnet cui docs lists the fourteen task topics and the bundled documents; docs read takes a topic, a page or a page#section; docs search ranks sections and prints the read that opens each one. All three answer from resources inside CuiSharp.Tools and return before any project is resolved, so they work offline in an empty directory with no app, no build and no network. A topic is a named list of doc#section pointers resolved at load, not a second copy of the prose: every sentence keeps one home. A test walks every topic and reads it, so a heading renamed in the guide fails the build instead of leaving a topic that answers and misleads. Pages are embedded rather than copied, and another test compares the bytes with the checked-in files. Search weighs words by how rare they are in the bundle, so "decimate chart" finds the decimation rule rather than every page that says "chart", and a section whose own heading names the question ranks above a page that merely lists it. README and the app template's AGENTS.md now carry the install line and the one paragraph a project needs, instead of restating the guide. --- README.md | 46 +- docs/ARCHITECTURE.md | 9 +- docs/PLAN.md | 3 + skills/cui-dotnet/SKILL.md | 6 + src/CuiSharp.Templates/CuiSharp.App/AGENTS.md | 23 +- src/CuiSharp.Tests/DocsCommandTests.cs | 377 +++++++ src/CuiSharp.Tools/ApiCatalog.cs | 7 + src/CuiSharp.Tools/Cli.cs | 68 +- src/CuiSharp.Tools/CuiSharp.Tools.csproj | 24 +- src/CuiSharp.Tools/DocLibrary.cs | 941 ++++++++++++++++++ tools/verify-template.ps1 | 31 +- 11 files changed, 1522 insertions(+), 13 deletions(-) create mode 100644 src/CuiSharp.Tests/DocsCommandTests.cs create mode 100644 src/CuiSharp.Tools/DocLibrary.cs diff --git a/README.md b/README.md index 2e4fc07..cb124db 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,46 @@ internal static class MyControls ui.Spark(Monthly.Revenue); ``` +## Give an agent the whole thing + +An assistant needs two commands, and the project's instruction file needs one paragraph — never a +copy of this README: + +```sh +dotnet tool install -g CuiSharp.Tools # exposes `dotnet cui` +dotnet cui docs # the topics, the documents, and what to run next +``` + +Then this is the whole of what a project's `AGENTS.md` has to say about CuiSharp: + +> This project uses CuiSharp. Run `dotnet cui docs` before writing UI code. Use +> `dotnet cui docs search ""` and `dotnet cui docs read ` for the rules and the +> pitfalls, `dotnet cui api ` for an exact signature, and `dotnet cui tree` / `find` / `click` / +> `png` to see what a build actually draws. Anything that must keep holding belongs in the test +> project, not in a shell history. + +`dotnet cui docs` answers from the guide, the reference and the behaviour pages **inside the tool**: +no repository, no NuGet cache, no network, nothing built and nothing started. Fourteen topics cover +the tasks an assistant arrives with — `getting-started`, `writing-ui`, `custom-controls`, `hosting`, +`testing`, `debugging`, `layout`, `live-debugging`, `performance`, `theming`, `translation`, +`publishing`, `signatures`, `widgets` — and each one is a named set of sections of the shipped pages, +so the tool cannot say anything the documentation does not. + +```sh +dotnet cui docs # topics, documents, and the commands that come next +dotnet cui docs read testing # one topic, whole +dotnet cui docs read SKILL # the guide +dotnet cui docs read SKILL#pitfalls # one section of it +dotnet cui docs search "decimate chart" # ranked sections, each with the read that opens it +dotnet cui api ChartOpts # exact declarations +dotnet cui icons chart # the glyph index +``` + +Every answer names the command that goes deeper, an unknown topic names the nearest one, and +`--json` turns any of them into machine-readable output. That is why a beginner's project file can be +short: the framework's rules are versioned with the tool that ships them, instead of being re-explained +in every repository that uses it. + ## Test it, and let an agent drive it `CuiSharp.Testing` runs the real component with no window: find a control by what it says, click it, @@ -256,7 +296,7 @@ src/CuiSharp/ the library: Interop (generated), Ui, Runtime, Semantic src/CuiSharp.Analyzers/ CUI001-CUI008, packed into CuiSharp src/CuiSharp.CodeFixes/ IDE quick fixes, packed beside the analyzers src/CuiSharp.Testing/ TestApp: drive a component without a window -src/CuiSharp.Tools/ dotnet cui +src/CuiSharp.Tools/ dotnet cui: the guide and the API offline, plus build, run headless, relay one command src/CuiSharp.Templates/ dotnet new cui-app / cui-mini samples/CuiSharp.Gallery/ twenty-eight pages, one file per page samples/CuiSharp.Gallery.Browser/ the same gallery on a software canvas in WebAssembly @@ -287,7 +327,9 @@ below works in either place. | RIDs carried, glibc floor, provenance | [skills/cui-dotnet/native-abi.md](skills/cui-dotnet/native-abi.md) | | Rules and decisions, before changing the framework | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | -An assistant needs the first two: point it at the folder and say "read `SKILL.md`". `widgets.md` and +An assistant needs the first two: point it at the folder and say "read `SKILL.md`", or — with +nothing but the tool installed — tell it to run `dotnet cui docs`, which carries that same folder. +`widgets.md` and `native-abi.md` are copies of CUI's documentation and of the ABI promise, regenerated with `pwsh tools/regen.ps1`; the rest of the folder is hand-written or generated from the headers. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b83c3d4..2a545f7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -21,6 +21,7 @@ Settled. Reopening one is a design change, not a refactor. | D8 | **A packaged native library declares the ABI it was built against.** Windows x64/ARM64 is `/MT` MSVC, Linux x64/ARM64 is glibc 2.31 built inside `debian:11`, `osx-arm64` targets macOS 12. `native/build-native.sh` fails rather than emit a `libcui.so` that needs more, and the Linux build runs inside that image so it cannot inherit a newer floor; the package records the revision, compiler and hashes it shipped (`runtimes/native-provenance.txt`), and `build/CuiSharp.targets` matches the RID exactly and errors when the archive for it is missing. See [native-abi.md](../skills/cui-dotnet/native-abi.md). | | D9 | **Window behaviour the framework needs is a hook over CUI's own window procedure, not a native patch.** A drag on Windows makes Win32 send `WM_SIZE` per mouse sample and then a `WM_PAINT` that invalidates the whole client area, and CUI draws in both, inside the modal loop, with vsync on: one mouse step cost two frames and two display waits. `Hosting/LiveResize` subclasses the window procedure (the one place Windows lets a framework intervene), lets the coalesced paint draw once, and stops waiting for the display for the length of the drag — driving CUI's own live-frame path rather than replacing it. The same hook is where the window handle is recovered for `CuiWindow`, because the app loop keeps its `cui_app*` private and the window's user data holds it. A fix that needed `native/CUI/` would be upstream's, per D2. | | D10 | **A published binary is a product, and the package decides what goes in it.** `build/CuiSharp.Size.targets` applies the switches that decide size (`CuiSharpSizeProfile`, `CuiSharpHeadless`, `CuiSharpVerboseErrors`) to a self-contained AOT publish and to nothing else, because that is the only build whose size matters: `dotnet run` keeps the headless channel and the framework's exception messages. `PublishAot` alone is not the signal — the file-based app shape sets it for `dotnet run` too. Measured in [SIZE.md](SIZE.md), which is what a size decision has to cite. | +| D11 | **The tool carries the documentation, and a topic names sections instead of repeating them.** `dotnet cui docs` answers from `skills/cui-dotnet/**` embedded in `CuiSharp.Tools`, so an assistant that has installed one tool can read the guide in a directory with no repository, no network and no app — the reason a project's instruction file can say `dotnet cui docs` instead of restating the framework's rules. A *topic* (`testing`, `debugging`, `publishing`) is a named list of `doc#section` pointers resolved at load, so every sentence has exactly one home and a renamed heading fails a test rather than leaving a topic that reads but misleads. The bundle is embedded, not copied, and a test compares the bytes with the checked-in files. | ## Layout @@ -37,7 +38,7 @@ src/CuiSharp/ the library (package: CuiSharp) src/CuiSharp.Analyzers/ CUI001-CUI008, packed into CuiSharp at analyzers/dotnet/cs src/CuiSharp.CodeFixes/ IDE-only fixes, packed beside the analyzers (no runtime dependency) src/CuiSharp.Testing/ TestApp: drive a component without a window -src/CuiSharp.Tools/ dotnet cui: build, run headless, relay one command +src/CuiSharp.Tools/ dotnet cui: the docs and API offline, then build, run headless, relay one command src/CuiSharp.Templates/ dotnet new cui-app / cui-mini src/CuiSharp.Tests/ ABI, enums, marshalling, frame behaviour, hosting, tools, docs drift src/CuiSharp.Benchmarks/ BenchmarkDotNet suites + --fps and --scale harnesses @@ -264,6 +265,10 @@ an explicitly enabled Windows live window over a user-only local named pipe inst HTTP/MCP server or separate daemon. `CuiSharp.Testing` remains the fast, repeatable way to drive an app from an agent, and `dotnet watch` is how a human edits in place. +The three commands that are not a relay — `docs`, `api`, `icons` — return before any project is +resolved, so they answer in a directory that holds no app and start no process (D11). That is what +lets one installed tool serve both halves of an assistant's job: read the rules, then drive a build. + Live work runs on the owning window thread, between completed frames in the pinned Win32 backend's message pump. Callback nesting guards are thread/context-owned: an unrelated surface cannot change that window's guard. A connection has a bounded asynchronous reader that detects EOF even during @@ -293,5 +298,5 @@ timeout says which wait it gave up on — the banner or the answer. |---|---|---| | `CuiSharp` | the library, each RID's `runtimes//native/` files, `runtimes/native-provenance.txt`, analyzers and IDE fixes, build targets | AOT links the RID's static archive through `build/CuiSharp.targets`; the RID is matched exactly and a missing archive is an error, not a warning | | `CuiSharp.Testing` | `TestApp` (a `HeadlessApp` under the name a test wants) | test-only dependency | -| `CuiSharp.Tools` | `dotnet cui` | `PackAsTool`, `ToolCommandName=dotnet-cui` | +| `CuiSharp.Tools` | `dotnet cui`, with `skills/cui-dotnet/**` and the API catalog embedded | `PackAsTool`, `ToolCommandName=dotnet-cui`. The documentation and the catalog are resources, not files beside it: `docs`, `api` and `icons` answer offline, before any project is resolved | | `CuiSharp.Templates` | `dotnet new cui-app`, `dotnet new cui-mini` | a solution (app + tests + `AGENTS.md`) or a file-based app named with `-n` in the current directory (optional `-o`), with a package directive | diff --git a/docs/PLAN.md b/docs/PLAN.md index 7c349bb..ca4650d 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -28,6 +28,9 @@ in [ARCHITECTURE.md](ARCHITECTURE.md). Every item here passes the same gates as - **Accessibility.** CUI publishes no accessibility tree, so there is no UIA, AT-SPI or NSAccessibility. The semantic tree is the foundation to build on, not a substitute. +- **Installing a skill into a particular agent harness.** `dotnet cui docs` is the portable form of + the guide: it works in any shell, for any tool. Per-harness registration (a `skills/` folder, a + plugin manifest, a system prompt) stays a local convention, and none of it is generated. - **One window per app.** CUI creates one window; multi-window is an upstream question. - **IME coverage** beyond what upstream implements. - **Chart time axes and gaps.** The managed `NormalizeMode` is a y-value transform; CUI still spaces diff --git a/skills/cui-dotnet/SKILL.md b/skills/cui-dotnet/SKILL.md index 5eeca90..cc78fdc 100644 --- a/skills/cui-dotnet/SKILL.md +++ b/skills/cui-dotnet/SKILL.md @@ -515,6 +515,11 @@ evidence behind. The rule underneath all four: **one reproduction per task, in a file.** If a check mattered once, it belongs in the test suite, not in a shell history. +This guide and the pages beside it are also inside the tool, so a reader with no repository can ask +for them: `dotnet cui docs` lists the topics by task (`testing`, `debugging`, `layout`, +`publishing`, …), `dotnet cui docs search ` ranks sections, and `dotnet cui docs read ` +prints one — `docs read debugging` is the two sections above that this tool drives the app with. + ## Testing without a window `CuiSharp.Testing` drives the real component with no window. Frames happen when the test asks, so a @@ -684,6 +689,7 @@ Everything below is in this folder, beside this guide; nothing here points outsi | records used as array items | `api/Records.md` | | the whole API paged, one file per section | `api/` | | every declaration as data, for offline lookup | `api/catalog.json` (`dotnet cui api --json`) | +| the whole folder, offline, with nothing installed but the tool | `dotnet tool install -g CuiSharp.Tools` then `dotnet cui docs` | | what each widget does, and its keyboard | `widgets.md`, `widgets-more.md` | | hosting: components, mutations, DI | `hosting.md` | | which RIDs the package carries, and the glibc floor | `native-abi.md` | diff --git a/src/CuiSharp.Templates/CuiSharp.App/AGENTS.md b/src/CuiSharp.Templates/CuiSharp.App/AGENTS.md index 08cc70a..726a3f6 100644 --- a/src/CuiSharp.Templates/CuiSharp.App/AGENTS.md +++ b/src/CuiSharp.Templates/CuiSharp.App/AGENTS.md @@ -3,6 +3,20 @@ Rules for working in this app, for whoever — or whatever — edits it next. They are short on purpose: they are the ones that change the code. +**The framework's own rules are not in this file.** They ship with the tool, so a project does not +have to restate them — and cannot fall out of date with them: + +```sh +dotnet tool install -g CuiSharp.Tools +dotnet cui docs # topics: writing-ui, testing, debugging, layout, publishing… +dotnet cui docs search "" # the sections that answer it, ranked +dotnet cui docs read # the rules, the pitfalls, the workflow +dotnet cui api # one exact signature +``` + +Offline, from inside the tool: no repository, no build, no network. When this file and the tool +disagree about CuiSharp itself, the tool is right. + ## The shape ``` @@ -46,15 +60,20 @@ dotnet build -warnaserror ``` A render test draws a real frame into a pixel buffer and asserts `surface.Faults` is empty, so a -page that throws cannot pass as a page that works. `dotnet cui tree` prints what a frame drew: +page that throws cannot pass as a page that works. + +When the question is what the app actually draws, ask the tool rather than guessing which page to +open — `dotnet cui docs read debugging` is the whole workflow, and these are the commands it uses: ```sh -dotnet tool install -g CuiSharp.Tools # exposes `dotnet cui` dotnet cui tree --project . # the controls of the last frame, nested dotnet cui click Dark # a control, by the label or id the tree printed dotnet cui png shot.png # the frame as a PNG, for you or for a person ``` +A `tree`, a `click` or a `png` is evidence, not a fix: finish by making the test fail without the +change. + ## The performance claims are tests `PageRenderTests` asserts **0 B per frame** for every page; `PageInteractionTests` clicks through diff --git a/src/CuiSharp.Tests/DocsCommandTests.cs b/src/CuiSharp.Tests/DocsCommandTests.cs new file mode 100644 index 0000000..551eaaa --- /dev/null +++ b/src/CuiSharp.Tests/DocsCommandTests.cs @@ -0,0 +1,377 @@ +using System.Text.Json; +using CuiSharp.Tools; +using DocSource = CuiSharp.Tools.Source; + +namespace CuiSharp.Tests; + +/// +/// dotnet cui docs: the guide, the reference and the behaviour pages answered from bytes +/// inside the tool. Like the catalog commands, every call here passes --project pointing at +/// a directory that does not exist — an ordinary command would fail there with "No app found", so +/// an answer with exit 0 is itself the proof that reading the documentation discovered no project, +/// built nothing, started no process and opened no socket. +/// +public class DocsCommandTests +{ + private static readonly string NoProjectHere = Path.Combine(Path.GetTempPath(), "cui-docs-offline-" + Guid.NewGuid()); + + private static (int Exit, string Out, string Err) Run(params string[] args) + { + var output = new StringWriter(); + var error = new StringWriter(); + int exit = Cli.Run([.. args, "--project", NoProjectHere], output, error); + return (exit, output.ToString(), error.ToString()); + } + + [Fact] + public void TheIndexNamesTheTopicsAndTheDocumentsAndStartsNoAppToDoIt() + { + (int exit, string output, _) = Run("docs"); + + Assert.Equal(0, exit); + Assert.Contains("dotnet cui docs read", output, StringComparison.Ordinal); + // The tasks an assistant arrives with, each a name it can pass back to `docs read`. + foreach (string topic in new[] { "getting-started", "writing-ui", "testing", "debugging", "layout", "publishing" }) + Assert.Contains(topic, output, StringComparison.Ordinal); + + // The pages themselves, by the name a reader types. + foreach (string document in new[] { "SKILL.md", "llms.txt", "widgets.md", "hosting.md", "native-abi.md" }) + Assert.Contains(document, output, StringComparison.Ordinal); + + // A page it carries must be reachable, or the index is advertising something absent. + Assert.Contains("api/Layout", output, StringComparison.Ordinal); + } + + /// + /// The topics are pointers into the shipped pages, so a heading renamed in the guide leaves a + /// topic that resolves to nothing. That is the drift this test exists to catch — and it is the + /// only reason a topic can be broken without anybody noticing, because the guide itself still + /// reads correctly. + /// + [Fact] + public void EveryTopicResolvesToSectionsOfABundledPage() + { + DocLibrary docs = DocLibrary.LoadEmbedded(); + + Assert.NotEmpty(docs.Topics); + foreach (Topic topic in docs.Topics) + { + Assert.True(topic.Sections.Count > 0, $"topic \"{topic.Name}\" resolves to no section at all"); + foreach (DocSource source in topic.Sections) + { + Assert.Contains(source.Doc, docs.Docs); + if (source.Section is { } section) + { + Assert.Contains(section, source.Doc.Sections); + Assert.True(source.Text.Length > 0, $"{topic.Name} -> {source.Title} is empty"); + } + } + + // And it reads: the answer a caller gets is the topic, not a miss with a suggestion. + (int exit, string output, string error) = Run("docs", "read", topic.Name); + Assert.True(exit == 0, $"`docs read {topic.Name}` exited {exit}: {error}"); + Assert.StartsWith("Topic: " + topic.Name, output, StringComparison.Ordinal); + } + } + + /// + /// The tool answers from the same files the CuiSharp package ships, embedded rather than + /// copied. This pins that the bytes inside the tool are the checked-in ones, so an edit to the + /// guide cannot leave the tool answering from last release's snapshot. + /// + [Theory] + [InlineData("CuiSharp.Tools.docs.SKILL.md", "SKILL.md")] + [InlineData("CuiSharp.Tools.docs.llms.txt", "llms.txt")] + [InlineData("CuiSharp.Tools.docs.widgets.md", "widgets.md")] + [InlineData("CuiSharp.Tools.docs.widgets-more.md", "widgets-more.md")] + [InlineData("CuiSharp.Tools.docs.hosting.md", "hosting.md")] + [InlineData("CuiSharp.Tools.docs.native-abi.md", "native-abi.md")] + [InlineData("CuiSharp.Tools.docs.api.Layout.md", "api/Layout.md")] + public void ABundledPageIsTheCheckedInFile(string resource, string relative) + { + using Stream? data = typeof(Cli).Assembly.GetManifestResourceStream(resource); + Assert.NotNull(data); + string embedded = new StreamReader(data!).ReadToEnd(); + + string checkedIn = File.ReadAllText(Path.Combine(TestPaths.RepositoryRoot, "skills", "cui-dotnet", relative)); + Assert.Equal(checkedIn.Replace("\r\n", "\n"), embedded.Replace("\r\n", "\n")); + } + + [Fact] + public void AWholeDocumentIsReadByTheNameTheIndexPrints() + { + (int exit, string output, _) = Run("docs", "read", "SKILL.md"); + + Assert.Equal(0, exit); + // The guide itself, not a description of it: the first rule a reader has to obey. + Assert.Contains("Rebuild the entire UI every frame", output, StringComparison.Ordinal); + } + + /// + /// One section, not the page: a reader who asks for the pitfalls must not be handed forty + /// thousand characters of guide and left to find them. + /// + [Fact] + public void ASectionReadIsThatSectionAndStopsAtTheNextHeading() + { + (int exit, string pitfalls, _) = Run("docs", "read", "SKILL#pitfalls"); + + Assert.Equal(0, exit); + Assert.Contains("Focus and scroll jump between rows", pitfalls, StringComparison.Ordinal); + Assert.DoesNotContain("## The one rule", pitfalls, StringComparison.Ordinal); + Assert.DoesNotContain("## Async work", pitfalls, StringComparison.Ordinal); + Assert.DoesNotContain("## Publishing for size", pitfalls, StringComparison.Ordinal); + + // The heading as typed, the anchor, and a prefix of it are the same request. + foreach (string reference in new[] { "SKILL#Pitfalls", "SKILL#pitfalls", "SKILL#PITFALL" }) + { + (int sameExit, string same, string error) = Run("docs", "read", reference); + Assert.True(sameExit == 0, $"`docs read {reference}` exited {sameExit}: {error}"); + Assert.Contains("Focus and scroll jump between rows", same, StringComparison.Ordinal); + } + } + + /// + /// A search that ranks the guide's table of contents above the page it lists is a search a + /// reader stops trusting. The section whose own heading names the question comes first. + /// + [Fact] + public void SearchRanksTheSectionWhoseHeadingNamesTheQuestionFirst() + { + (int exit, string output, _) = Run("docs", "search", "publish size"); + + Assert.Equal(0, exit); + Assert.Equal("SKILL.md § Publishing for size", RankedHeadings(output)[0]); + + (exit, output, _) = Run("docs", "search", "pitfalls"); + Assert.Equal(0, exit); + Assert.Equal("SKILL.md § Pitfalls", RankedHeadings(output)[0]); + } + + /// + /// A question can use a word the answering page never does. `aot size` is answered by the + /// publish page, which is about size and describes no AOT switch; finding it is the difference + /// between a search that works and one that only matches strings. + /// + [Fact] + public void SearchFindsThePageThatAnswersAQuestionItDoesNotName() + { + (int exit, string output, _) = Run("docs", "search", "aot size"); + + Assert.Equal(0, exit); + Assert.Contains("SKILL.md § Publishing for size", output, StringComparison.Ordinal); + Assert.Contains("dotnet cui docs read SKILL#publishing-for-size", output, StringComparison.Ordinal); + } + + /// + /// Every hit carries the command that opens it, so a search answer is never a dead end: an + /// assistant can read the section without guessing an anchor. + /// + [Fact] + public void EverySearchHitCarriesTheReadThatOpensIt() + { + (int exit, string output, _) = Run("docs", "search", "decimate chart"); + + Assert.Equal(0, exit); + Assert.Contains("read: dotnet cui docs read SKILL#long-series-and-live-data", output, StringComparison.Ordinal); + // A snippet of the answer, not just the heading. + Assert.Contains("decimate", output, StringComparison.OrdinalIgnoreCase); + + int reads = CountOccurrences(output, " read: dotnet cui docs read "); + int headings = CountOccurrences(output, " § ") + CountOccurrences(output, "\n "); + Assert.True(reads > 0 && reads <= headings + 1, $"every hit needs a read line; saw {reads} for\n{output}"); + } + + /// + /// A hit is a section, and a section is not a task. When the bundled topics cover the question, + /// the answer names them, because that is what an instruction file tells an assistant to read. + /// + [Fact] + public void ASearchAnswerNamesTheTopicThatCoversTheQuestion() + { + (int exit, string output, _) = Run("docs", "search", "decimate chart"); + + Assert.Equal(0, exit); + Assert.Contains("whole topic:", output, StringComparison.Ordinal); + Assert.Contains("dotnet cui docs read performance", output, StringComparison.Ordinal); + } + + /// + /// The debugging topic is the page an instruction file points at instead of restating the + /// workflow, so it has to carry the three ways to reach an app and the rule that evidence is + /// not a fix. Asserting the shipped text is the point: a topic that quietly lost a section + /// would still resolve and still read, and still mislead. + /// + [Fact] + public void TheDebuggingTopicCarriesTheWorkflowItIsPointedAtFor() + { + (int exit, string debugging, _) = Run("docs", "read", "debugging"); + + Assert.Equal(0, exit); + Assert.Contains("SKILL.md § How to work on a UI", debugging, StringComparison.Ordinal); + Assert.Contains("SKILL.md § Driving a running app", debugging, StringComparison.Ordinal); + // Headless one-shots, one process for a script, and a live window a person is watching. + Assert.Contains("--stdin", debugging, StringComparison.Ordinal); + Assert.Contains("--attach", debugging, StringComparison.Ordinal); + Assert.Contains("one process", debugging, StringComparison.Ordinal); + // And the rule that keeps a debugging session from being the only evidence. + Assert.Contains("belongs in the test suite", debugging, StringComparison.Ordinal); + } + + [Fact] + public void AnUnknownTopicNamesTheNearestOneAndSaysItIsMissing() + { + (int exit, string output, _) = Run("docs", "read", "debuging"); + + Assert.Equal(1, exit); + Assert.Contains("nothing bundled with CuiSharp is called \"debuging\"", output, StringComparison.Ordinal); + Assert.Contains("debugging", output, StringComparison.Ordinal); + } + + [Fact] + public void ASectionThatDoesNotExistSendsTheReaderToThePage() + { + (int exit, string output, _) = Run("docs", "read", "SKILL#nosuchsection"); + + Assert.Equal(1, exit); + Assert.Contains("SKILL has no section \"nosuchsection\"", output, StringComparison.Ordinal); + Assert.Contains("dotnet cui docs read SKILL", output, StringComparison.Ordinal); + } + + [Fact] + public void AQuestionNothingAnswersSaysSoInTheToolsOwnVoice() + { + (int exit, string output, _) = Run("docs", "search", "zzqqxxvv"); + + Assert.Equal(1, exit); + Assert.Contains("no bundled section mentions \"zzqqxxvv\"", output, StringComparison.Ordinal); + Assert.DoesNotContain("is in the CuiSharp catalog", output, StringComparison.Ordinal); + } + + [Fact] + public void AQuestionMadeOnlyOfFillerWordsAsksForARealOne() + { + (int exit, string output, _) = Run("docs", "search", "the and of"); + + Assert.Equal(1, exit); + Assert.Contains("needs a word to look for", output, StringComparison.Ordinal); + } + + [Fact] + public void ReadAndSearchWithoutTheirArgumentAreUsageErrors() + { + (int exit, _, string error) = Run("docs", "read"); + Assert.Equal(2, exit); + Assert.Contains("`docs read` needs a topic", error, StringComparison.Ordinal); + + (exit, _, error) = Run("docs", "search"); + Assert.Equal(2, exit); + Assert.Contains("`docs search` needs words", error, StringComparison.Ordinal); + } + + /// + /// An assistant that drops the verb still gets the page: `docs debugging` is the same request + /// as `docs read debugging`, and correcting the grammar instead would be a wasted turn. + /// + [Fact] + public void TheVerbMayBeDroppedEntirely() + { + (int exit, string output, _) = Run("docs", "testing"); + + Assert.Equal(0, exit); + Assert.StartsWith("Topic: testing", output, StringComparison.Ordinal); + Assert.Contains("TestApp.Start", output, StringComparison.Ordinal); + } + + [Fact] + public void DocsAreMachineReadableWithJson() + { + (int exit, string output, _) = Run("docs", "search", "decimate", "--json"); + + Assert.Equal(0, exit); + using JsonDocument json = JsonDocument.Parse(output); + Assert.Equal("exact", json.RootElement.GetProperty("match").GetString()); + Assert.Equal("decimate", json.RootElement.GetProperty("term").GetString()); + JsonElement lines = json.RootElement.GetProperty("lines"); + Assert.True(lines.GetArrayLength() > 0); + Assert.Contains("docs read SKILL#", lines.GetRawText(), StringComparison.Ordinal); + + (exit, output, _) = Run("docs", "--json"); + Assert.Equal(0, exit); + using JsonDocument index = JsonDocument.Parse(output); + Assert.Equal("exact", index.RootElement.GetProperty("match").GetString()); + + (exit, output, _) = Run("docs", "read", "nosuchtopic", "--json"); + Assert.Equal(1, exit); + using JsonDocument missing = JsonDocument.Parse(output); + Assert.Equal("unknown", missing.RootElement.GetProperty("match").GetString()); + Assert.True(missing.RootElement.GetProperty("suggestions").GetArrayLength() >= 0); + } + + [Fact] + public void AnApiPageIsABundledDocumentLikeAnyOther() + { + (int exit, string output, _) = Run("docs", "read", "api/Layout"); + + Assert.Equal(0, exit); + Assert.Contains("api/Layout", output, StringComparison.Ordinal); + Assert.Contains("Generated by", output, StringComparison.Ordinal); + } + + /// + /// Two paths can read the bundle, and only one of them is a real answer. Pinning that the + /// reader parses sections rather than printing the file is what makes `docs read doc#section` + /// a section instead of the whole page with a heading somewhere in it. + /// + [Fact] + public void TheReaderSplitsPagesAtHeadingsAndIgnoresFences() + { + DocLibrary docs = DocLibrary.LoadEmbedded(); + Doc skill = docs.Docs.Single(d => d.Id == "SKILL"); + + Assert.True(skill.Sections.Count > 10, "the guide has sections"); + Section section = Assert.Single(skill.Sections, s => s.Heading == "Pitfalls"); + Assert.Equal("pitfalls", section.Anchor); + + // Every anchor is unique within its page, or `docs read doc#anchor` could open the wrong + // section than the one a search answer printed. + foreach (Doc doc in docs.Docs) + { + var seen = new HashSet(StringComparer.Ordinal); + foreach (Section s in doc.Sections) + Assert.True(seen.Add(s.Anchor), $"{doc.Id} repeats the anchor \"{s.Anchor}\""); + } + + // A heading inside a fence is sample code, not a section: the C# a page shows is full of + // `#` and `##` and none of it may become something `docs read` can open. + Assert.DoesNotContain(skill.Sections, s => s.Heading.Contains("region", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(skill.Sections, s => s.Anchor.Length == 0); + } + + [Fact] + public void TheUsageScreenListsTheDocumentationCommands() + { + var output = new StringWriter(); + var error = new StringWriter(); + int exit = Cli.Run(["docs", "--help"], output, error); + + Assert.Equal(0, exit); + string usage = output.ToString(); + Assert.Contains("docs read ", usage, StringComparison.Ordinal); + Assert.Contains("docs search ", usage, StringComparison.Ordinal); + } + + /// The hit headings of a search answer, in the order the answer ranked them. + private static string[] RankedHeadings(string output) => + [.. output.Split('\n') + .Where(line => line.StartsWith(" ", StringComparison.Ordinal) && line.Contains(" § ", StringComparison.Ordinal)) + .Select(line => line.Trim())]; + + private static int CountOccurrences(string text, string value) + { + int count = 0; + for (int at = text.IndexOf(value, StringComparison.Ordinal); at >= 0; at = text.IndexOf(value, at + value.Length, StringComparison.Ordinal)) + count++; + return count; + } +} diff --git a/src/CuiSharp.Tools/ApiCatalog.cs b/src/CuiSharp.Tools/ApiCatalog.cs index 0396c7d..13b3201 100644 --- a/src/CuiSharp.Tools/ApiCatalog.cs +++ b/src/CuiSharp.Tools/ApiCatalog.cs @@ -484,6 +484,13 @@ static void WriteArray(Utf8JsonWriter writer, string name, IReadOnlyList /// A result whose lines are the whole answer — the icon index needs no suggestions of its own. internal static CatalogResult Answer(CatalogMatch match, string term, IReadOnlyList lines) => new(match, term, lines, []); + /// + /// A miss whose explanation was written by the caller: docs answers in its own voice — + /// nearest topic, nearest section — where the catalog answers in its own. + /// + internal static CatalogResult Missing(string term, IReadOnlyList lines, IReadOnlyList suggestions) => + new(CatalogMatch.Unknown, term, lines, suggestions); + /// /// Unknown is never dead air: the explanation is the answer even when no name is close, /// so the caller learns to re-ask rather than reading silence as "no such API". diff --git a/src/CuiSharp.Tools/Cli.cs b/src/CuiSharp.Tools/Cli.cs index e18b675..0e503a5 100644 --- a/src/CuiSharp.Tools/Cli.cs +++ b/src/CuiSharp.Tools/Cli.cs @@ -35,12 +35,19 @@ quit close a live attached window line: wall and cpu milliseconds, frames, fps, and the app's heap, working-set and private bytes - Commands answered offline, from the API catalog inside this tool: + Commands answered offline, from the pages and the API catalog inside this tool: + docs the topics and documents bundled here, and what to run next. + Reads no project, builds nothing, starts nothing, needs no network. + docs read one topic, one document or one section: `docs read testing`, + `docs read SKILL`, `docs read SKILL#pitfalls`. + docs search the sections that answer a question, ranked, each with the + `docs read` line that opens it. api one declaration: `api ChartOpts`, `api Ui.LineChart`, `api Button`. An exact answer exits 0; an ambiguous or unknown one names the qualified or nearest names and exits nonzero. icons [filter] every icon constant: name, code point, and the literal to paste. - Both take --json for machine-readable output; neither builds, launches or loads anything. + All of these take --json for machine-readable output; none builds, launches or loads + anything. Options: -a, --attach [id] drive a running live window (--live) over its named pipe instead @@ -76,6 +83,8 @@ Anything after `--` goes to the app. } }); + private static readonly Lazy BundledDocs = new(DocLibrary.LoadEmbedded); + /// Runs the tool; the return value is the process exit code. internal static int Run(string[] args, TextWriter output, TextWriter error, TextReader? input = null) { @@ -91,6 +100,9 @@ internal static int Run(string[] args, TextWriter output, TextWriter error, Text if (options.Command is "api" or "icons") return AnswerCatalog(options, output, error); + if (options.Command == "docs") + return AnswerDocs(options, output, error); + if (options.Command == "ps") { output.WriteLine(LiveClient.FormatList(LiveClient.List(), options.Json)); @@ -125,6 +137,54 @@ internal static int Run(string[] args, TextWriter output, TextWriter error, Text } } + /// + /// Answers a docs call: a topic, a document, a section, or a search. Like the catalog + /// commands this returns before any project is resolved, so it works in an empty directory. + /// + private static int AnswerDocs(Options options, TextWriter output, TextWriter error) + { + (CatalogResult? answer, string? usage) = DocsAnswer(BundledDocs.Value, options.Argument); + if (usage is not null) + { + error.WriteLine(usage); + return 2; + } + + output.WriteLine(options.Json ? answer!.RenderJson() : answer!.Render()); + return answer!.IsExact ? 0 : 1; + } + + /// + /// The docs grammar, separated from the writers so the grammar is testable on its own: + /// no argument is the index, search and read are the two verbs, and anything else + /// is the thing to read — an assistant that drops read gets the page, not a correction. + /// + internal static (CatalogResult? Answer, string? Usage) DocsAnswer(DocLibrary docs, string argument) + { + string line = argument.Trim(); + if (line.Length == 0) return (docs.List(), null); + + int space = line.IndexOfAny([' ', '\t']); + string verb = space < 0 ? line : line[..space]; + string rest = space < 0 ? "" : line[(space + 1)..].Trim(); + + if (verb.Equals("search", StringComparison.OrdinalIgnoreCase)) + { + return rest.Length > 0 + ? (docs.Search(rest), null) + : (null, "`docs search` needs words to look for: `dotnet cui docs search dialog keyboard`."); + } + + if (verb.Equals("read", StringComparison.OrdinalIgnoreCase)) + { + return rest.Length > 0 + ? (docs.Read(rest), null) + : (null, "`docs read` needs a topic, a document or a section: `dotnet cui docs read debugging`."); + } + + return (docs.Read(line), null); + } + private static HeadlessClient StartHeadless(Options options, TextWriter error) { (string host, string[] hostArgs) = options.Host is { } exe @@ -440,8 +500,8 @@ internal static Options Parse(string[] args) string argument = words.Count > 1 ? string.Join(' ', words.Skip(1)) : ""; bool showHelp = help || (words.Count == 0 && !streaming && !waiting); - if (json && !showHelp && command is not ("api" or "icons" or "ps")) - throw new ToolException("Unknown option \"--json\". Only `api`, `icons`, and `ps` support --json."); + if (json && !showHelp && command is not ("api" or "icons" or "ps" or "docs")) + throw new ToolException("Unknown option \"--json\". Only `docs`, `api`, `icons`, and `ps` support --json."); return new Options { diff --git a/src/CuiSharp.Tools/CuiSharp.Tools.csproj b/src/CuiSharp.Tools/CuiSharp.Tools.csproj index 8172568..071868a 100644 --- a/src/CuiSharp.Tools/CuiSharp.Tools.csproj +++ b/src/CuiSharp.Tools/CuiSharp.Tools.csproj @@ -20,8 +20,11 @@ resolved. The shim is literally `dotnet-cui(.exe)` on every OS. --> dotnet-cui CuiSharp.Tools - CuiSharp.Tools - drive a CuiSharp app from the command line - Screenshot a CuiSharp app, print the controls of its last frame, and click or type into it - without a window, an agent or a test project. + + README.md + CuiSharp.Tools - drive a CuiSharp app and read its docs from the command line + Bundle of the CuiSharp guide and API reference for offline reading, plus a driver that prints a CuiSharp app's controls, clicks and types into it, and saves its frame as a PNG - without a window, an agent SDK or a test project. cui;ui;tools;dotnet-tool;agent @@ -38,6 +41,23 @@ --> + + + + + + + + + + diff --git a/src/CuiSharp.Tools/DocLibrary.cs b/src/CuiSharp.Tools/DocLibrary.cs new file mode 100644 index 0000000..a8a436c --- /dev/null +++ b/src/CuiSharp.Tools/DocLibrary.cs @@ -0,0 +1,941 @@ +using System.Reflection; +using System.Text; + +namespace CuiSharp.Tools; + +/// +/// The documentation bundle the tool carries: the guide, the reference, the behaviour pages and +/// the generated API pages, read out of this assembly's own resources. One lookup serves an +/// assistant or a person in a directory that holds no app at all — no repository, no NuGet cache, +/// no network, and nothing built or started. +/// +/// +/// A topic is the task an assistant comes with ("test this", "debug this"), answered by a +/// named set of sections of the shipped documents. Topics name sections rather than repeat them, so +/// there is one copy of every sentence in the repository and this file cannot drift from it. +/// +internal sealed class DocLibrary +{ + /// The prefix every bundled document's resource name carries; it is fixed in the csproj. + internal const string ResourcePrefix = "CuiSharp.Tools.docs."; + + private const int MaxSearchHits = 8; + + /// How much of a hit's own text a search answer shows before it points at the read. + private const int SnippetLength = 180; + + private readonly List docs; + private readonly List topics; + + private DocLibrary(List docs, List topics) + { + this.docs = docs; + this.topics = topics; + } + + internal IReadOnlyList Docs => docs; + + internal IReadOnlyList Topics => topics; + + /// The version of CuiSharp these pages describe, for a reader deciding whether they apply. + internal static string Version + { + get + { + string? version = typeof(DocLibrary).Assembly + .GetCustomAttribute()?.InformationalVersion; + if (string.IsNullOrEmpty(version)) return "unknown"; + int metadata = version.IndexOf('+'); + return metadata < 0 ? version : version[..metadata]; + } + } + + /// + /// Reads every bundled page once. The set is discovered from the assembly's resources rather + /// than listed here, so a document added to the csproj is searchable without a second edit — + /// and a document that vanishes is a missing answer instead of a stale one. + /// + internal static DocLibrary LoadEmbedded() + { + Assembly assembly = typeof(DocLibrary).Assembly; + string[] names = [.. assembly.GetManifestResourceNames() + .Where(n => n.StartsWith(ResourcePrefix, StringComparison.Ordinal))]; + + if (names.Length == 0) + throw new ToolException( + "this build of cui carries no bundled documentation; the CuiSharp.Tools package is " + + "broken — reinstall it, or read the pages at the repository's skills/cui-dotnet."); + + var docs = new List(); + foreach (string name in names) + { + string id = DocId(name); + if (id.Length == 0) continue; + + using Stream? data = assembly.GetManifestResourceStream(name); + if (data is null) continue; + + using var reader = new StreamReader(data, Encoding.UTF8); + string text = reader.ReadToEnd().Replace("\r\n", "\n", StringComparison.Ordinal); + docs.Add(Doc.Parse(id, FileName(id), text)); + } + + // The narrative pages lead, in the order a reader meets them; the generated API pages + // follow alphabetically. Search breaks a tie the same way, so a question about a rule + // answers with the guide before a page that merely mentions it. + docs.Sort(CompareDocs); + return new DocLibrary(docs, BundledTopics.Define(docs)); + } + + /// `CuiSharp.Tools.docs.api.Layout.md` is `api/Layout`; `…docs.SKILL.md` is `SKILL`. + private static string DocId(string resourceName) + { + string rest = resourceName[ResourcePrefix.Length..]; + int dot = rest.LastIndexOf('.'); + if (dot > 0) rest = rest[..dot]; + if (rest.Length == 0) return ""; + + return rest.StartsWith("api.", StringComparison.Ordinal) + ? "api/" + rest["api.".Length..] + : rest; + } + + /// A document id as a path a person reads: `SKILL.md`, `api/Layout.md`, `llms.txt`. + private static string FileName(string id) => + id.StartsWith("api/", StringComparison.Ordinal) ? id + ".md" + : id == "llms" ? "llms.txt" + : id + ".md"; + + private static int CompareDocs(Doc a, Doc b) + { + int rank = Rank(a.Id) - Rank(b.Id); + return rank != 0 ? rank : string.Compare(a.Id, b.Id, StringComparison.OrdinalIgnoreCase); + } + + /// The narrative pages in the order the guide introduces them; everything else is equal. + private static int Rank(string id) => id switch + { + "SKILL" => 0, + "llms" => 1, + "widgets" => 2, + "widgets-more" => 3, + "hosting" => 4, + "native-abi" => 5, + _ => 10, + }; + + internal Doc? FindDoc(string id) + { + // `SKILL.md`, `llms.txt`, `api/Layout.md` are what the index prints, so they are what a + // reader types back; the id is the same document with the extension dropped. + string name = id; + foreach (string extension in new[] { ".md", ".txt" }) + { + if (name.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) name = name[..^extension.Length]; + } + + return docs.FirstOrDefault(d => string.Equals(d.Id, name, StringComparison.OrdinalIgnoreCase)); + } + + private Topic? FindTopic(string name) => + topics.FirstOrDefault(t => string.Equals(t.Name, name, StringComparison.OrdinalIgnoreCase)); + + /// + /// The entry point an assistant is told to run first: the topics by task, the documents by + /// kind, and the two commands that come next. It answers from the bundle, so it is the same + /// page whether the reader has the repository open or not. + /// + internal CatalogResult List() + { + var lines = new List + { + $"dotnet cui docs — CuiSharp {Version}, bundled with this tool. Offline: it reads no", + "project, builds nothing, starts nothing and touches no network.", + "", + "Start with the task you have:", + }; + + int width = topics.Max(t => t.Name.Length); + foreach (Topic topic in topics) + lines.Add($" {topic.Name.PadRight(width)} {topic.Summary}"); + + lines.Add(""); + lines.Add("Or read a document whole:"); + int fileWidth = docs.Where(d => !d.Id.StartsWith("api/", StringComparison.Ordinal)).Max(d => d.FileName.Length) + 2; + foreach (Doc doc in docs) + { + if (doc.Id.StartsWith("api/", StringComparison.Ordinal)) continue; + lines.Add($" {doc.FileName.PadRight(fileWidth)} {doc.Summary}"); + } + + int apiPages = docs.Count(d => d.Id.StartsWith("api/", StringComparison.Ordinal)); + if (apiPages > 0) + { + lines.Add($" {"api/
".PadRight(fileWidth)} one page per generated section, {apiPages} pages; " + + "`dotnet cui docs read api/Layout`"); + } + + lines.Add(""); + lines.Add("Then:"); + lines.Add(" dotnet cui docs read one topic, page or section"); + lines.Add(" dotnet cui docs search ranked sections, with the read to run next"); + lines.Add(" dotnet cui api exact declarations: `api ChartOpts`"); + lines.Add(" dotnet cui icons [filter] the glyph index"); + lines.Add(" dotnet cui tree | find | click | png inspect and drive a real app"); + + return CatalogResult.Exact("docs", lines); + } + + /// + /// Answers one reference: a topic name, a document id, or `doc#section`. A topic is the shape + /// an instruction file uses ("read the debugging topic"); the other two are what its answer + /// tells the reader to run next. + /// + internal CatalogResult Read(string reference) + { + string clean = reference.Trim(); + if (clean.Length == 0) return CatalogResult.Unknown(reference, []); + + if (FindTopic(clean) is { } topic) return RenderTopic(topic); + + int hash = clean.IndexOf('#'); + string docId = hash < 0 ? clean : clean[..hash]; + string? section = hash < 0 ? null : clean[(hash + 1)..].Trim().TrimStart('#'); + if (section is { Length: 0 }) section = null; + + Doc? doc = FindDoc(docId); + if (doc is null) return Unresolved(reference, docId); + + if (section is null) + { + var whole = new List + { + $"{doc.Id} · CuiSharp {Version} · read: dotnet cui docs read {doc.Id}", + "", + doc.Text.TrimEnd(), + }; + return CatalogResult.Exact(reference, whole); + } + + Section? found = doc.FindSection(section); + if (found is null) + { + List anchors = [.. doc.SectionSuggestions(section)]; + var missing = new List { $"{doc.Id} has no section \"{section}\"." }; + if (anchors.Count > 0) missing.Add("nearest: " + string.Join(", ", anchors)); + missing.Add($"`dotnet cui docs read {doc.Id}` prints the whole page, headings included."); + return CatalogResult.Missing(reference, missing, anchors); + } + + var lines = new List + { + $"{doc.Id} § {found.Heading} · CuiSharp {Version}" + + $" · read: dotnet cui docs read {doc.Id}#{found.Anchor}", + "", + doc.SubtreeText(found).TrimEnd(), + }; + return CatalogResult.Exact(reference, lines); + } + + /// + /// Ranks the sections that answer a question. Every word has to appear somewhere in the + /// section for it to be a hit; when nothing satisfies that, the words are ranked one by one so + /// a slightly wrong question still gets the right page instead of an empty answer. + /// + internal CatalogResult Search(string query) + { + string[] words = [.. query.Split([' ', '\t', ',', '.', '?', '!'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(w => w.ToLowerInvariant()) + .Where(w => w.Length > 1 && !IsFiller(w)) + .Distinct()]; + + if (words.Length == 0) + return CatalogResult.Missing(query, ["`docs search` needs a word to look for, not just filler: `dotnet cui docs search dialog keyboard`."], []); + + List entries = Index(); + List all = Score(entries, words, requireEveryWord: true); + if (all.Count == 0) all = Score(entries, words, requireEveryWord: false); + + // Rank, then cut: the scan walks the bundle in reading order, so taking the first eight + // it found would answer with whichever section happens to come first in the guide. + // OrderBy is stable, and the score already carries the bundled order as its tie-break. + all = [.. all.OrderByDescending(h => h.Score)]; + if (all.Count == 0) + { + List related = [.. NearestTopics(words[0])]; + var missing = new List { $"no bundled section mentions \"{query.Trim()}\"." }; + if (related.Count > 0) missing.Add("these topics do: " + string.Join(", ", related)); + missing.Add("`dotnet cui docs` lists every topic and document; `dotnet cui api ` looks up a declaration."); + return CatalogResult.Missing(query, missing, related); + } + + List top = [.. all.Take(MaxSearchHits)]; + var lines = new List + { + $"{all.Count} section{(all.Count == 1 ? "" : "s")} for \"{query.Trim()}\" in the bundled CuiSharp {Version} docs:", + "", + }; + + foreach (Hit hit in top) + { + lines.Add($" {hit.Doc.FileName} § {hit.Section.Heading}"); + lines.Add($" read: dotnet cui docs read {hit.Doc.Id}#{hit.Section.Anchor}"); + if (Snippet(hit) is { Length: > 0 } snippet) lines.Add($" {snippet}"); + lines.Add(""); + } + + if (all.Count > top.Count) + lines.Add($"{all.Count - top.Count} more; narrow the words, or read a topic whole (`dotnet cui docs`)."); + + // A hit is a section, and a section is not a task. When the task exists, name it: the + // topic is the answer an assistant actually wants, and the sections above are its index. + List matchingTopics = [.. topics.Where(t => t.Sections.Any(s => top.Any(h => h.Doc == s.Doc && h.Section == s.Section))) + .Select(t => t.Name)]; + if (matchingTopics.Count > 0) + lines.Add("whole topic: " + string.Join(", ", matchingTopics.Select(t => $"dotnet cui docs read {t}"))); + + while (lines.Count > 0 && lines[^1].Length == 0) lines.RemoveAt(lines.Count - 1); + return CatalogResult.Exact(query, lines); + } + + /// + /// Words that carry no information about which section answers a question. Dropping them is + /// what lets `await in draw` rank the async rule rather than every section that happens to say + /// "in": a keyword search over prose is only useful when the keywords are the content ones. + /// + private static bool IsFiller(string word) => word switch + { + "a" or "an" or "and" or "are" or "as" or "at" or "be" or "but" or "by" or "can" or "do" + or "does" or "for" or "from" or "how" or "in" or "into" or "is" or "it" or "its" or "me" + or "my" or "not" or "of" or "on" or "or" or "our" or "should" or "that" or "the" or "then" + or "this" or "to" or "use" or "was" or "what" or "when" or "where" or "which" or "why" + or "with" or "you" => true, + _ => false, + }; + + /// + /// Ranks the sections that answer a question. Words are weighted by how rare they are in the + /// bundle: `decimate` decides what a question is about, `chart` — which half the pages say — + /// hardly does. A word in a section's own heading weighs most of all, because a section titled + /// "Publishing for size" is about size while a page that says "size" once is about something + /// else entirely. + /// + private static List Score(List entries, string[] words, bool requireEveryWord) + { + Dictionary weight = Weigh(entries, words); + var hits = new List(); + + foreach (Entry entry in entries) + { + double score = 0; + double missing = 0; + bool inHeading = false; + int named = 0; + foreach (string word in words) + { + if (!weight.TryGetValue(word, out double value)) { missing += 2; continue; } + + if (InHeading(entry.Section, word)) + { + score += value * 6; + inHeading = true; + named++; + } + else if (entry.Section.Heading.Contains(word, StringComparison.OrdinalIgnoreCase)) + { + // `charts` contains `chart` and is still weaker than the word itself: a plural + // in a heading is a passing mention, not the subject. + score += value * 1.5; + inHeading = true; + } + else if (entry.Body.Contains(word, StringComparison.OrdinalIgnoreCase)) + { + score += value * 3; + } + else + { + // Bounded by a constant, not by the word's value: a rare word must not veto a + // heading that names the question, only weaken it. + missing += Math.Min(value, 3); + } + } + + // A heading that carries every word of the question is the subject, not a mention: + // "Publishing for size" answers `aot size` although it never says AOT, and no page + // that merely happens to use both words outranks it. + if (named == words.Length) score += 40; + + // Owing a word still costs a little: a heading that says "chart" is not the answer to + // `decimate chart`, but the section that does decimate is found behind it. + score -= missing; + + // A section whose heading carries a word is about that word even when the question + // also used a second word it never says: `dialog keyboard` must reach the Dialog page, + // which describes its keyboard in prose this line cannot see. + if (missing > 0 && requireEveryWord && !inHeading) continue; + if (score <= 0) continue; + + // Break a tie by the order the pages were bundled in, which leads with the guide. + hits.Add(new Hit(entry.Doc, entry.Section, (int)Math.Round(score * 1000) - Math.Min(entry.Order, 99), words)); + } + + return hits; + } + + /// + /// How much each word says about which section answers a question, from how many sections + /// could have said it. A word no section uses is dropped, which is what makes the difference + /// between "this page uses the word once" and "this page is about the word". + /// + private static Dictionary Weigh(List entries, string[] words) + { + var weight = new Dictionary(StringComparer.Ordinal); + foreach (string word in words) + { + int sections = 0; + foreach (Entry entry in entries) + { + if (entry.Section.Heading.Contains(word, StringComparison.OrdinalIgnoreCase) + || entry.Body.Contains(word, StringComparison.OrdinalIgnoreCase)) + { + sections++; + } + } + + if (sections > 0) weight[word] = Math.Log(1.0 + ((double)entries.Count / sections)); + } + + return weight; + } + + /// + /// Every section of every page with its own body text, built once per search: a section is + /// asked about once per word while ranking and again while weighing, and re-slicing the page + /// for each question would read the whole bundle several times over. + /// + private List Index() + { + var entries = new List(); + for (int order = 0; order < docs.Count; order++) + { + Doc doc = docs[order]; + foreach (Section section in doc.Sections) + entries.Add(new Entry(doc, section, doc.BodyText(section), order)); + } + + return entries; + } + + private readonly record struct Entry(Doc Doc, Section Section, string Body, int Order); + + /// + /// Whether a heading names a word: the word itself, or the word a heading inflects — + /// `publish` is what "Publishing for size" is about, and refusing the prefix would rank the + /// guide's table of contents above the page it lists. + /// + private static bool InHeading(Section section, string word) + { + foreach (string token in section.HeadingWords) + { + if (string.Equals(token, word, StringComparison.Ordinal)) return true; + if (word.Length >= 4 && token.StartsWith(word, StringComparison.Ordinal)) return true; + } + + return false; + } + + /// + /// The line that best answers the question: the one carrying the most of its words. A section + /// is often a table or a list where only one row is the answer, and showing the first line + /// instead would send a reader to a paragraph that never mentions what they asked about. + /// + private static string Snippet(Hit hit) + { + string? best = null; + int bestHits = 0; + string? first = null; + + foreach (string raw in hit.Doc.BodyText(hit.Section).Split('\n')) + { + string line = Collapse(raw); + if (line.Length == 0 || line.StartsWith('#') || line.StartsWith("```", StringComparison.Ordinal)) continue; + if (line.StartsWith('|') && line.Replace("|", "", StringComparison.Ordinal).Replace("-", "", StringComparison.Ordinal).Trim().Length == 0) + continue; + + first ??= line; + int hits = hit.Words.Count(w => line.Contains(w, StringComparison.OrdinalIgnoreCase)); + if (hits > bestHits) + { + bestHits = hits; + best = line; + } + } + + return Clip(best ?? first ?? ""); + } + + private static string Collapse(string line) => + string.Join(' ', line.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries)); + + private static string Clip(string line) => + line.Length <= SnippetLength ? line : line[..(SnippetLength - 1)].TrimEnd() + "…"; + + private CatalogResult RenderTopic(Topic topic) + { + var lines = new List + { + $"Topic: {topic.Name} — {topic.Summary}", + $"Read: dotnet cui docs read {topic.Name} · CuiSharp {Version} · offline", + }; + + if (topic.Sections.Count > 1 || !string.IsNullOrEmpty(topic.Next)) + lines.Add($"Sources: {string.Join(", ", topic.Sections.Select(s => s.ToString()))}"); + lines.Add(""); + + foreach (Source source in topic.Sections) + { + lines.Add(source.Title); + lines.Add(""); + lines.Add(source.Text.TrimEnd()); + lines.Add(""); + } + + if (!string.IsNullOrEmpty(topic.Next)) lines.Add(topic.Next); + while (lines.Count > 0 && lines[^1].Length == 0) lines.RemoveAt(lines.Count - 1); + return CatalogResult.Exact(topic.Name, lines); + } + + /// + /// An unresolved topic is never dead air: the topics and documents that are close come back, + /// so the next call is one edit away and nothing has to be guessed from silence. + /// + private CatalogResult Unresolved(string reference, string term) + { + var suggestions = new List(); + foreach (Topic topic in topics) + if (Close(term, topic.Name) && !suggestions.Contains(topic.Name, StringComparer.OrdinalIgnoreCase)) + suggestions.Add(topic.Name); + foreach (Doc doc in docs) + if (Close(term, doc.Id) && !suggestions.Contains(doc.Id, StringComparer.OrdinalIgnoreCase)) + suggestions.Add(doc.Id); + + var lines = new List + { + suggestions.Count > 0 + ? $"nothing bundled with CuiSharp is called \"{reference.Trim()}\"." + : $"nothing bundled with CuiSharp is called \"{reference.Trim()}\", and no topic or page is close.", + }; + + if (suggestions.Count > 0) lines.Add("nearest: " + string.Join(", ", suggestions.Take(6))); + lines.Add("`dotnet cui docs` lists every topic and document; `dotnet cui docs search ` finds a section."); + return CatalogResult.Missing(reference, lines, [.. suggestions.Take(6)]); + } + + /// A topic typed roughly still resolves: `debuging`, `test`, `perf`. + private static bool Close(string typed, string name) + { + if (name.StartsWith(typed, StringComparison.OrdinalIgnoreCase)) return true; + if (typed.StartsWith(name, StringComparison.OrdinalIgnoreCase)) return true; + + int budget = Math.Max(1, typed.Length / 3); + return Math.Abs(name.Length - typed.Length) <= budget + && EditDistance(typed.ToLowerInvariant(), name.ToLowerInvariant()) <= budget; + } + + private IEnumerable NearestTopics(string word) + { + foreach (Topic topic in topics) + if (topic.Summary.Contains(word, StringComparison.OrdinalIgnoreCase)) yield return topic.Name; + } + + private static int EditDistance(string a, string b) + { + int[] row = new int[b.Length + 1]; + for (int j = 0; j <= b.Length; j++) row[j] = j; + for (int i = 1; i <= a.Length; i++) + { + int previous = row[0]; + row[0] = i; + for (int j = 1; j <= b.Length; j++) + { + int swap = row[j]; + row[j] = Math.Min(row[j] + 1, Math.Min(row[j - 1] + 1, previous + (a[i - 1] == b[j - 1] ? 0 : 1))); + previous = swap; + } + } + + return row[b.Length]; + } + + private readonly record struct Hit(Doc Doc, Section Section, int Score, string[] Words); +} + +/// One bundled Markdown page, split into the sections a reader can be pointed at. +internal sealed class Doc +{ + private readonly string[] lines; + + private Doc(string id, string fileName, string title, string summary, string text, string[] lines, List
sections) + { + Id = id; + FileName = fileName; + Title = title; + Summary = summary; + Text = text; + this.lines = lines; + Sections = sections; + } + + /// `SKILL`, `llms`, `widgets`, `api/Layout` — what docs read takes. + internal string Id { get; } + + internal string FileName { get; } + + internal string Title { get; } + + /// The first sentence of the page or its H1, for the index and the topic sources. + internal string Summary { get; } + + internal string Text { get; } + + internal IReadOnlyList
Sections { get; } + + /// The text directly under a heading, down to the next heading of any level. + internal string BodyText(Section section) => Join(section.BodyStart, section.BodyEnd); + + /// The heading and everything under it, down to the next heading of its own level or above. + internal string SubtreeText(Section section) => Join(section.Start, section.End); + + private string Join(int start, int end) + { + if (start >= end) return ""; + var text = new StringBuilder(); + for (int i = start; i < end; i++) + { + if (i > start) text.Append('\n'); + text.Append(lines[i]); + } + + return text.ToString(); + } + + /// + /// Finds a section by its anchor or by its heading as typed, so `SKILL#Pitfalls`, + /// `SKILL#pitfalls` and `SKILL#the-one-rule` are the same answer. + /// + internal Section? FindSection(string reference) + { + string wanted = reference.Trim(); + foreach (Section section in Sections) + { + if (string.Equals(section.Anchor, wanted, StringComparison.OrdinalIgnoreCase) + || string.Equals(section.Heading, wanted, StringComparison.OrdinalIgnoreCase) + || string.Equals(Section.Slug(section.Heading), Section.Slug(wanted), StringComparison.Ordinal)) + { + return section; + } + } + + // A partial heading is a reasonable way to ask: `SKILL#pitfall`, `SKILL#driving`. + foreach (Section section in Sections) + if (section.Heading.Contains(wanted, StringComparison.OrdinalIgnoreCase)) return section; + + return null; + } + + internal IEnumerable SectionSuggestions(string reference) + { + string typed = reference.Trim(); + return Sections + .Where(s => s.Heading.Contains(typed, StringComparison.OrdinalIgnoreCase) + || s.Anchor.Contains(typed, StringComparison.OrdinalIgnoreCase)) + .Select(s => s.Anchor) + .Take(5); + } + + /// + /// Splits the page at its ATX headings, ignoring anything inside a fence: a `#` line in a C# + /// sample or a shell transcript is not a section, and treating it as one would hand a reader + /// half a code block. + /// + internal static Doc Parse(string id, string fileName, string text) + { + string[] lines = text.Split('\n'); + var headings = new List<(int Line, int Level, string Heading)>(); + bool fenced = false; + + for (int i = 0; i < lines.Length; i++) + { + string line = lines[i]; + string trimmed = line.TrimStart(); + if (trimmed.StartsWith("```", StringComparison.Ordinal) || trimmed.StartsWith("~~~", StringComparison.Ordinal)) + { + fenced = !fenced; + continue; + } + + if (fenced || !TryHeading(line, out int level, out string heading)) continue; + headings.Add((i, level, heading)); + } + + var sections = new List
(); + var anchors = new HashSet(StringComparer.Ordinal); + for (int k = 0; k < headings.Count; k++) + { + (int start, int level, string heading) = headings[k]; + int bodyEnd = k + 1 < headings.Count ? headings[k + 1].Line : lines.Length; + + int end = lines.Length; + for (int j = k + 1; j < headings.Count; j++) + { + if (headings[j].Level > level) continue; + end = headings[j].Line; + break; + } + + string anchor = Unique(Section.Slug(heading), anchors); + sections.Add(new Section(anchor, heading, level, start, start + 1, bodyEnd, end)); + } + + string title = sections.FirstOrDefault(s => s.Level == 1)?.Heading ?? id; + return new Doc(id, fileName, title, Summarize(text, title), text, lines, sections); + + static string Unique(string anchor, HashSet taken) + { + if (taken.Add(anchor)) return anchor; + for (int n = 1; ; n++) + { + string candidate = anchor + "-" + n.ToString(System.Globalization.CultureInfo.InvariantCulture); + if (taken.Add(candidate)) return candidate; + } + } + } + + private static bool TryHeading(string line, out int level, out string heading) + { + level = 0; + heading = ""; + int at = 0; + while (at < line.Length && at < 6 && line[at] == '#') at++; + if (at == 0 || at >= line.Length || line[at] != ' ') return false; + + level = at; + heading = line[at..].Trim(); + return heading.Length > 0; + } + + /// + /// The page's own one-liner: its blockquote lede, or else the first prose paragraph under the + /// title. Every bundled page carries one, so the index reads like a table of contents rather + /// than a list of file names. + /// + private static string Summarize(string text, string title) + { + string[] lines = text.Split('\n'); + var quoted = new List(); + foreach (string line in lines) + { + string trimmed = line.Trim(); + if (trimmed.StartsWith('>')) + { + quoted.Add(trimmed.TrimStart('>').Trim()); + continue; + } + + if (quoted.Count > 0) break; + } + + string summary = quoted.Count > 0 + ? string.Join(' ', quoted.Where(q => q.Length > 0)) + : ""; + + if (summary.Length == 0) + { + bool pastTitle = false; + var prose = new List(); + bool fenced = false; + foreach (string line in lines) + { + string trimmed = line.Trim(); + if (trimmed.StartsWith("```", StringComparison.Ordinal)) { fenced = !fenced; continue; } + if (fenced) continue; + if (trimmed.StartsWith('#')) { if (pastTitle) break; pastTitle = true; continue; } + if (!pastTitle || trimmed.Length == 0) { if (prose.Count > 0) break; continue; } + prose.Add(trimmed); + } + + summary = string.Join(' ', prose); + } + + if (summary.Length == 0) summary = title; + int stop = summary.IndexOf(". ", StringComparison.Ordinal); + if (stop > 20) summary = summary[..(stop + 1)]; + return summary.Length <= 120 ? summary : summary[..119].TrimEnd() + "…"; + } +} + +/// One heading in a page, with the line spans a snippet and a subtree read need. +internal sealed record Section( + string Anchor, string Heading, int Level, int Start, int BodyStart, int BodyEnd, int End) +{ + private string[]? words; + + /// The heading's own words, so `pitfalls` beats a page that merely says "pitfall". + internal string[] HeadingWords => words ??= [.. Heading + .Split([' ', '`', '\'', '"', ',', '.', ':', '(', ')', '/', '·'], StringSplitOptions.RemoveEmptyEntries) + .Select(w => w.ToLowerInvariant())]; + + public override string ToString() => Heading; + + /// GitHub's anchor rule, so a link written for GitHub resolves here too. + internal static string Slug(string heading) + { + var slug = new StringBuilder(heading.Length); + foreach (char c in heading.ToLowerInvariant()) + { + if (char.IsLetterOrDigit(c) && c < 128) slug.Append(c); + else if (c is ' ' or '-' or '_') slug.Append('-'); + } + + string text = slug.ToString(); + while (text.Contains("--", StringComparison.Ordinal)) text = text.Replace("--", "-", StringComparison.Ordinal); + return text.Trim('-'); + } +} + +/// +/// One part of one document, as a topic names it: a section, or the whole page when the topic is +/// the page itself (`llms`, `widgets`). +/// +internal sealed record Source(Doc Doc, Section? Section) +{ + internal string Title => Section is null ? Doc.FileName : $"{Doc.FileName} § {Section.Heading}"; + + internal string Text => Section is null ? Doc.Text : Doc.SubtreeText(Section); + + public override string ToString() => Title; +} + +/// +/// The tasks an assistant arrives with, each answered by sections that already exist in the +/// bundled pages. Naming a section instead of copying it is what keeps one copy of every rule: +/// a topic cannot say something the guide does not. +/// +internal static class BundledTopics +{ + /// + /// A topic is a name, the task it answers, the sections it is made of and what to do after + /// reading it. The sections are `doc#anchor`; every one is checked by a test, so a heading + /// renamed in the guide fails the build instead of leaving a dead pointer in the tool. + /// + private static readonly (string Name, string Summary, string[] Sections, string Next)[] Definitions = + [ + ("getting-started", + "install, draw a first frame, choose a file-based or hosted app", + ["SKILL#a-whole-app", "SKILL#the-one-rule", "SKILL#cheat-sheet"], + "Next: `dotnet cui docs read writing-ui`."), + + ("writing-ui", + "the rules that make a frame: scopes, sizing, options, state, ids, pitfalls", + ["SKILL#frame-local-types-and-text", "SKILL#containers-are-using-scopes", "SKILL#sizing", + "SKILL#options-are-structs-of-fields", "SKILL#colours-come-from-the-theme", + "SKILL#state-goes-in-ref", "SKILL#containers-that-may-be-closed", "SKILL#pitfalls"], + "Next: `dotnet cui docs read testing`, then look a signature up with `dotnet cui api `."), + + ("custom-controls", + "draw your own control as an extension member, and the canvas primitives", + ["SKILL#custom-controls"], + "Next: `dotnet cui docs search canvas` for the primitives a control draws with."), + + ("hosting", + "components, dependency injection, Mutation, and who owns a redraw", + ["hosting", "SKILL#async-work"], + "Next: `dotnet cui docs read testing`."), + + ("testing", + "drive a real component with no window: frames on demand, clicks, pixels, assertions", + ["SKILL#testing-without-a-window", "SKILL#how-to-work-on-a-ui", "hosting#testing-a-hosted-app"], + "Every fix that matters ends here: a reproduction in the test suite, not a shell history."), + + ("debugging", + "find out why the app does something: inspect a frame, interact, capture, pin a test", + ["SKILL#how-to-work-on-a-ui", "SKILL#driving-a-running-app"], + "A `tree`, a `click` or a `png` is evidence, not a fix: end with a test that fails without the change."), + + ("layout", + "sizing, nesting, alignment and the rectangles a container actually got", + ["SKILL#sizing", "SKILL#containers-are-using-scopes", "SKILL#containers-that-may-be-closed", "api/Layout"], + "Then `dotnet cui layout` prints the rectangles, the observed union bounds and overflow."), + + ("live-debugging", + "attach to a window a person is watching, on Windows, and what that cannot promise", + ["SKILL#driving-a-running-app"], + "The opt-in, the discovery and the cancellation rules are the same page's bullets — read them before scripting it."), + + ("performance", + "0 B per frame, virtualized rows, decimated charts, and the numbers that prove it", + ["SKILL#long-series-and-live-data", "SKILL#frame-numbers", "SKILL#pitfalls"], + "Measure before claiming: `CUI_TRACE=trace.txt` writes the frame costs a WinExe cannot print."), + + ("theming", + "the theme, the accent, the design tokens and the rules that stop a UI looking generic", + ["SKILL#theming", "SKILL#colours-come-from-the-theme", "SKILL#design-rules"], + "Next: `dotnet cui api ThemeStyle` for every writable token."), + + ("translation", + "the one name table the date widgets read, and how a surface owns it", + ["SKILL#translating-the-date-names"], + "Text your font does not cover draws nothing: check `UseSystemFontFallback` on the same page."), + + ("publishing", + "one AOT executable, the four size switches, and what each gate keeps", + ["SKILL#publishing-for-size", "native-abi"], + "A published app drops the headless channel by default: publish with `-p:CuiSharpHeadless=true` before pointing `--app` at it."), + + ("signatures", + "every declaration, one line each, generated from the headers", + ["llms"], + "One name at a time is faster: `dotnet cui api ChartOpts`."), + + ("widgets", + "what each component does, its keyboard, and what its return value means", + ["widgets"], + "More components, grouped by task: `dotnet cui docs read widgets-more`."), + ]; + + internal static List Define(IReadOnlyList docs) + { + var topics = new List(); + foreach ((string name, string summary, string[] sections, string next) in Definitions) + { + var sources = new List(); + foreach (string reference in sections) + { + Source? source = Resolve(docs, reference); + // A definition that no longer resolves is dropped here and caught by the test + // that walks every topic; failing the build there beats a runtime surprise. + if (source is not null) sources.Add(source); + } + + if (sources.Count > 0) topics.Add(new Topic(name, summary, sources, next)); + } + + return topics; + } + + private static Source? Resolve(IReadOnlyList docs, string reference) + { + int hash = reference.IndexOf('#'); + string docId = hash < 0 ? reference : reference[..hash]; + Doc? doc = docs.FirstOrDefault(d => string.Equals(d.Id, docId, StringComparison.OrdinalIgnoreCase)); + if (doc is null) return null; + // A document with no section named is the whole document: a topic that says `llms` + // wants every signature, not the first heading of the page. + if (hash < 0) return new Source(doc, null); + + return doc.FindSection(reference[(hash + 1)..]) is { } section ? new Source(doc, section) : null; + } +} + +/// A named task and the shipped sections that answer it. +internal sealed record Topic(string Name, string Summary, IReadOnlyList Sections, string Next); diff --git a/tools/verify-template.ps1 b/tools/verify-template.ps1 index 66878c0..5df5d1d 100644 --- a/tools/verify-template.ps1 +++ b/tools/verify-template.ps1 @@ -251,8 +251,37 @@ internal static class AnalyzerProbe if (-not $catalog.HasExited) { $catalog.Kill($true); $catalog.WaitForExit() } $catalog.Dispose() } + + # And the documentation has to be inside the tool as well, not just beside the repository. + # An agent that installs the tool reads the guide from it in a directory that holds no + # project; `docs read testing` is the page it is pointed at first, so this asks for it and + # requires the rules of that page back. + $docsPsi = [System.Diagnostics.ProcessStartInfo]::new('dotnet') + $docsPsi.WorkingDirectory = $work + foreach ($argument in @('cui', 'docs', 'read', 'testing')) { $docsPsi.ArgumentList.Add($argument) } + $docsPsi.UseShellExecute = $false + $docsPsi.RedirectStandardOutput = $true + $docsPsi.RedirectStandardError = $true + $docsPsi.StandardOutputEncoding = [System.Text.UTF8Encoding]::new($false, $true) + $docsPsi.StandardErrorEncoding = [System.Text.UTF8Encoding]::new($false, $true) + $docs = [System.Diagnostics.Process]::Start($docsPsi) + try { + $docsOut = $docs.StandardOutput.ReadToEndAsync() + $docsErr = $docs.StandardError.ReadToEndAsync() + if (-not $docs.WaitForExit(60000)) { throw 'installed dotnet cui docs timed out' } + $page = $docsOut.GetAwaiter().GetResult() + $errors = $docsErr.GetAwaiter().GetResult() + if ($docs.ExitCode -ne 0) { throw "installed dotnet cui docs failed: $errors" } + if ($page -notmatch 'Topic: testing' -or $page -notmatch 'TestApp\.Start') { + throw "installed dotnet cui docs returned no testing topic: $page" + } + } + finally { + if (-not $docs.HasExited) { $docs.Kill($true); $docs.WaitForExit() } + $docs.Dispose() + } } finally { $env:PATH = $savedPath } - Write-Host " $shimName installed, and dotnet cui resolves through it" -ForegroundColor Green + Write-Host " $shimName installed, and dotnet cui resolves through it, offline api and docs included" -ForegroundColor Green Step 'generating and running a file-based app' $defaultMini = Join-Path $work 'demo1' From 206625258a19181587bf856789c6f4aaefc083ae Mon Sep 17 00:00:00 2001 From: CuiSharp Contributor Date: Thu, 12 Nov 2026 09:44:31 +0800 Subject: [PATCH 2/3] Declare the command line with System.CommandLine instead of parsing it by hand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asking for help meant reading a 62-line string literal, and that literal had already drifted from the parser: it promised `-a, --attach [id]` while `-a` took no value, so the id stayed a positional word and `dotnet cui click "中文" -a 15036` reported that no widget called "中文 15036" existed. `-t, --target` -- the one short flag that did take a value -- was not documented at all. The grammar is now declared once, in `Grammar.cs`: every verb is a Command whose arguments carry their own type and arity, every option is an Option, and the help is generated from that same declaration. `dotnet cui --help` and `dotnet cui click --help` describe what the parser actually does, because they are the parser. The verbs are still the app's wire protocol. Resolve re-serialises one command's arguments into exactly one protocol line, so "click Save" and "wheel 120 400 -8" travel unchanged; a label may now be quoted as one token, and the arity refuses an unquoted second word rather than silently joining it. What a reader gets that the hand parser could not give: - a mistyped verb names the verb that was meant (`clik` -> `click`); - a bad value is a sentence about the value (`as a whole number`), not a CLR type; - `--json` exists only on the four commands that answer JSON, so it cannot be typed where it would be ignored; - `--version` answers with a version instead of falling through to the help; - usage errors are exit 2 on stderr, and a bare `dotnet cui` prints the help there rather than into a pipeline; - a repeated option is refused instead of decided by whichever came last. Two rules survive because the protocol needs them: everything after a standalone `--` goes to the app untouched, and a value is a value even when it starts with a dash (`--wait-text -loading`). The `docs ` shorthand also survives, hidden from the usage lines so it cannot look like an argument of `docs search`. `Cli.Options` keeps its shape and `Cli.Run` keeps dispatching, so the headless client, the live client and the offline answers are untouched; only the reading of a command line changed. Tests now go through the same `Grammar.Parse` the tool dispatches on. ToolArgumentTests and ApiCatalogCliTests were rewritten for the new grammar rather than patched, including a walk of every command and every option spelling in the help, which is what would have caught the original `-a` drift. --- docs/ARCHITECTURE.md | 7 + skills/cui-dotnet/SKILL.md | 5 +- src/CuiSharp.Tests/ApiCatalogCliTests.cs | 127 +++-- src/CuiSharp.Tests/DocsCommandTests.cs | 15 +- src/CuiSharp.Tests/ToolArgumentTests.cs | 280 ++++++++--- src/CuiSharp.Tools/Cli.cs | 279 ++--------- src/CuiSharp.Tools/CuiSharp.Tools.csproj | 5 + src/CuiSharp.Tools/Grammar.cs | 575 +++++++++++++++++++++++ tools/verify-template.ps1 | 8 +- 9 files changed, 938 insertions(+), 363 deletions(-) create mode 100644 src/CuiSharp.Tools/Grammar.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2a545f7..97be515 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -22,6 +22,7 @@ Settled. Reopening one is a design change, not a refactor. | D9 | **Window behaviour the framework needs is a hook over CUI's own window procedure, not a native patch.** A drag on Windows makes Win32 send `WM_SIZE` per mouse sample and then a `WM_PAINT` that invalidates the whole client area, and CUI draws in both, inside the modal loop, with vsync on: one mouse step cost two frames and two display waits. `Hosting/LiveResize` subclasses the window procedure (the one place Windows lets a framework intervene), lets the coalesced paint draw once, and stops waiting for the display for the length of the drag — driving CUI's own live-frame path rather than replacing it. The same hook is where the window handle is recovered for `CuiWindow`, because the app loop keeps its `cui_app*` private and the window's user data holds it. A fix that needed `native/CUI/` would be upstream's, per D2. | | D10 | **A published binary is a product, and the package decides what goes in it.** `build/CuiSharp.Size.targets` applies the switches that decide size (`CuiSharpSizeProfile`, `CuiSharpHeadless`, `CuiSharpVerboseErrors`) to a self-contained AOT publish and to nothing else, because that is the only build whose size matters: `dotnet run` keeps the headless channel and the framework's exception messages. `PublishAot` alone is not the signal — the file-based app shape sets it for `dotnet run` too. Measured in [SIZE.md](SIZE.md), which is what a size decision has to cite. | | D11 | **The tool carries the documentation, and a topic names sections instead of repeating them.** `dotnet cui docs` answers from `skills/cui-dotnet/**` embedded in `CuiSharp.Tools`, so an assistant that has installed one tool can read the guide in a directory with no repository, no network and no app — the reason a project's instruction file can say `dotnet cui docs` instead of restating the framework's rules. A *topic* (`testing`, `debugging`, `publishing`) is a named list of `doc#section` pointers resolved at load, so every sentence has exactly one home and a renamed heading fails a test rather than leaving a topic that reads but misleads. The bundle is embedded, not copied, and a test compares the bytes with the checked-in files. | +| D12 | **The command line is declared, not parsed by hand.** `Grammar` builds a System.CommandLine tree: every verb is a `Command` whose arguments carry their own type and arity, and every option is one `Option`. The help, the arity, the value types and the error messages then come from a single declaration, which is what stops them disagreeing — the hand-rolled parser had already shipped a help line promising `-a, --attach [id]` while `-a` took no value, so `dotnet cui click "中文" -a 15036` searched for a widget called `中文 15036`. The verbs are still the app's wire protocol: `Grammar.Resolve` re-serialises one command's arguments into exactly one protocol line, so the parser changed and the protocol did not. `dotnet cui` keeps that shape because an agent reads a grammar it can discover (`--help` per verb) more reliably than a man page it has to remember. | ## Layout @@ -269,6 +270,12 @@ The three commands that are not a relay — `docs`, `api`, `icons` — return be resolved, so they answer in a directory that holds no app and start no process (D11). That is what lets one installed tool serve both halves of an assistant's job: read the rules, then drive a build. +The grammar itself is declared with System.CommandLine and the verbs are re-serialised into the +protocol lines above (D12), so `--help` — per verb, generated — is the manual. Two rules survive +from the hand-rolled parser because the protocol wants them: everything after a standalone `--` +is handed to the app untouched, and a value is a value even when it begins with a dash +(`--wait-text -loading`). + Live work runs on the owning window thread, between completed frames in the pinned Win32 backend's message pump. Callback nesting guards are thread/context-owned: an unrelated surface cannot change that window's guard. A connection has a bounded asynchronous reader that detects EOF even during diff --git a/skills/cui-dotnet/SKILL.md b/skills/cui-dotnet/SKILL.md index cc78fdc..f04f376 100644 --- a/skills/cui-dotnet/SKILL.md +++ b/skills/cui-dotnet/SKILL.md @@ -602,8 +602,9 @@ behind, so anything that should be checked twice belongs in a test instead. `--no-build` reuses the last build; `--` passes the rest to the app. - To drive a **live window** while a human watches it (`dotnet watch run -- --live`, `CUISHARP_LIVE=1`, or `CuiAppOptions { LiveAttach = true }` on Windows), run `dotnet cui ps` to list active windows and - pass `-a` / `--attach` (or `--attach=`) to any command (`dotnet cui tree -a`, - `dotnet cui click Save -a`, `dotnet cui png live.png -a`, `dotnet cui quit -a`). Commands travel over + pass `-a ` to any command, or a bare `-a` when only one window is open + (`dotnet cui tree -a 15036`, `dotnet cui click Save -a`, `dotnet cui png live.png -a`, + `dotnet cui quit -a`). Commands travel over a user-only local named pipe (`cui-live--`) and leave the window open between calls. Published AOT apps omit the listener by default: publish with `-p:CuiSharpLive=true` to retain it, then opt in at runtime with `--live` or `LiveAttach`. Disconnecting abandons pending commands; diff --git a/src/CuiSharp.Tests/ApiCatalogCliTests.cs b/src/CuiSharp.Tests/ApiCatalogCliTests.cs index bd63fd6..0eb1b8e 100644 --- a/src/CuiSharp.Tests/ApiCatalogCliTests.cs +++ b/src/CuiSharp.Tests/ApiCatalogCliTests.cs @@ -139,7 +139,7 @@ public void ApiWithoutATermIsAUsageErrorNotACrash() (int exit, _, string error) = Run("api"); Assert.Equal(2, exit); - Assert.Contains("needs a name", error, StringComparison.Ordinal); + Assert.Contains("Required argument 'name' missing", error, StringComparison.Ordinal); } [Fact] @@ -147,7 +147,7 @@ public void AFlagNeitherCommandKnowsIsReportedRatherThanSentOn() { (int exit, _, string error) = Run("api", "ChartOpts", "--pgae"); - Assert.Equal(1, exit); + Assert.Equal(2, exit); Assert.Contains("--pgae", error, StringComparison.Ordinal); } @@ -173,7 +173,7 @@ public void HelpAfterACatalogCommandIsTheQueryOrFilterNotTheUsage() (int exit, string output, _) = Run("icons", "help"); Assert.Equal(0, exit); Assert.Contains("CircleHelp", output, StringComparison.Ordinal); - Assert.DoesNotContain("dotnet cui ", output, StringComparison.Ordinal); + Assert.DoesNotContain("Usage:", output, StringComparison.Ordinal); (exit, output, _) = Run("icons", "help", "--json"); Assert.Equal(0, exit); @@ -190,7 +190,7 @@ public void HelpAfterACatalogCommandIsTheQueryOrFilterNotTheUsage() Assert.Equal(1, exit); Assert.Contains("CircleHelp", output, StringComparison.Ordinal); Assert.Contains("DialogHelper", output, StringComparison.Ordinal); - Assert.DoesNotContain("dotnet cui ", output, StringComparison.Ordinal); + Assert.DoesNotContain("Usage:", output, StringComparison.Ordinal); } [Fact] @@ -198,113 +198,103 @@ public void ExplicitHelpStillAsksForUsageEvenBesideACatalogCommand() { (int exit, string output, _) = Run("api", "--help"); Assert.Equal(0, exit); - Assert.Contains("dotnet cui ", output, StringComparison.Ordinal); + Assert.Contains("Usage:", output, StringComparison.Ordinal); (exit, output, _) = Run("icons", "-h"); Assert.Equal(0, exit); - Assert.Contains("dotnet cui ", output, StringComparison.Ordinal); + Assert.Contains("Usage:", output, StringComparison.Ordinal); } [Fact] - public void AnIgnoredOptionNeverSwallowsTheFlagBehindIt() + public void AnOptionWhoseValueIsNotItsTypeIsReportedInsteadOfSwallowed() { - // `--frames --json` lost its number. Eating the flag behind it would answer a - // different command than the one that was typed and call the answer successful. + // `--frames --json` used to lose its number and eat the flag behind it, answering a + // different command than the one that was typed and calling that success. A typed + // option now says what it wanted: a count, not `--json`. (int exit, _, string error) = Run("api", "ChartOpts", "--frames", "--json"); - Assert.Equal(1, exit); - Assert.Contains("--frames needs a value", error, StringComparison.Ordinal); + Assert.Equal(2, exit); + Assert.Contains("'--frames'", error, StringComparison.Ordinal); + Assert.Contains("whole number", error, StringComparison.Ordinal); (exit, _, error) = Run("api", "ChartOpts", "--wait-timeout", "--pgae"); - Assert.Equal(1, exit); - Assert.Contains("--wait-timeout needs a value", error, StringComparison.Ordinal); + Assert.Equal(2, exit); + Assert.Contains("'--wait-timeout'", error, StringComparison.Ordinal); + Assert.Contains("a number", error, StringComparison.Ordinal); // A value missing at the end of the line is the same mistake. No --project is needed // here: the catalog path never looks at a project, so the parse error is all there is. var answers = new StringWriter(); var errors = new StringWriter(); exit = Cli.Run(["api", "ChartOpts", "--app"], answers, errors); - Assert.Equal(1, exit); - Assert.Contains("--app needs a value", errors.ToString(), StringComparison.Ordinal); + Assert.Equal(2, exit); + Assert.Contains("'--app'", errors.ToString(), StringComparison.Ordinal); + Assert.Equal("", answers.ToString()); } [Fact] public void ANonCatalogCommandKeepsItsOwnGrammarEvenWithAwkwardOptionValues() { - // `tree --wait-text -loading` is legal: `-loading` is a value, not a flag. - Cli.Options options = Cli.Options.Parse(["tree", "--wait-text", "-loading", "--help"]); - Assert.Equal("tree", options.Command); - Assert.Equal("waittext 10 -loading", options.WaitLine); - Assert.True(options.Help); + // `tree --wait-text -loading` is legal: `-loading` is a value, not a flag, so text that + // begins with a dash is still text to wait for. + Grammar.Parsed parsed = Grammar.Parse(["tree", "--wait-text", "-loading"]); + Assert.Empty(parsed.Errors); + Assert.Equal("tree", parsed.Options!.Command); + Assert.Equal("waittext 10 -loading", parsed.Options.WaitLine); (int exit, _, string error) = Run("tree", "--wait-text", "-loading"); Assert.Equal(1, exit); Assert.DoesNotContain("needs a value", error, StringComparison.Ordinal); Assert.Contains(NoProjectHere, error, StringComparison.Ordinal); - // And `--json` is rejected on ordinary app commands. + // And `--json` exists only where there is JSON to answer with, so an app command cannot + // be handed a switch it would only ignore. (exit, _, error) = Run("tree", "--json"); - Assert.Equal(1, exit); - Assert.Contains("Unknown option \"--json\"", error, StringComparison.Ordinal); + Assert.Equal(2, exit); + Assert.Contains("'--json'", error, StringComparison.Ordinal); } [Fact] public void ACatalogCommandReportsMalformedOptionsThroughTheUnifiedParser() { + // A value that merely looks like a flag is still a value — `--wait-text -loading` needs + // that rule — so what is refused is a value the option's declared type cannot hold. var output = new StringWriter(); var errors = new StringWriter(); - int exit = Cli.Run(["api", "ChartOpts", "--project", "--json"], output, errors); - Assert.Equal(1, exit); - Assert.Contains("--project needs a value", errors.ToString(), StringComparison.Ordinal); - Assert.Equal("", output.ToString()); - - output = new StringWriter(); - errors = new StringWriter(); - exit = Cli.Run(["--wait-text", "--json", "api", "ChartOpts"], output, errors); - Assert.Equal(1, exit); - Assert.Contains("--wait-text needs a value", errors.ToString(), StringComparison.Ordinal); + int exit = Cli.Run(["api", "ChartOpts", "--wait-timeout", "abc"], output, errors); + Assert.Equal(2, exit); + Assert.Contains("'--wait-timeout'", errors.ToString(), StringComparison.Ordinal); + Assert.Contains("abc", errors.ToString(), StringComparison.Ordinal); Assert.Equal("", output.ToString()); - (int badExit, _, string badError) = Run("api", "ChartOpts", "--wait-timeout", "abc"); - Assert.Equal(1, badExit); - Assert.Contains("--wait-timeout needs a finite number of seconds", badError, StringComparison.Ordinal); - Assert.Contains("not \"abc\"", badError, StringComparison.Ordinal); + (int badExit, _, string badError) = Run("api", "ChartOpts", "--frames", "many"); + Assert.Equal(2, badExit); + Assert.Contains("whole number", badError, StringComparison.Ordinal); } [Fact] - public void AWaitTheCallerOverwritesIsJudgedByTheValueThatSurvives() + public void ARepeatedOptionIsRefusedRatherThanSilentlyOverwritten() { - Cli.Options overwritten = Cli.Options.Parse(["api", "ChartOpts", "--wait", "invalid", "--wait", "ready"]); - Assert.Equal("wait 10", overwritten.WaitLine); - - (int exit, string output, string error) = Run("api", "ChartOpts", "--wait", "invalid", "--wait", "ready"); - Assert.Equal(0, exit); - Assert.Contains("ChartOpts [struct]", output, StringComparison.Ordinal); - Assert.Equal("", error); - - (exit, output, error) = Run("api", "ChartOpts", "--wait", "invalid", "--wait", "ready", "--help"); - Assert.Equal(0, exit); - Assert.Contains("dotnet cui ", output.ToString(), StringComparison.Ordinal); - Assert.Equal("", error); - - (exit, output, error) = Run("icons", "help", "--wait", "invalid", "--wait", "ready"); - Assert.Equal(0, exit); - Assert.Contains("CircleHelp", output, StringComparison.Ordinal); - Assert.DoesNotContain("dotnet cui ", output, StringComparison.Ordinal); - Assert.Equal("", error); + // `--wait ready --wait invalid` used to be decided by whichever came last. Two answers + // to the same question mean the line was not settled, so neither answer is acted on. + var output = new StringWriter(); + var errors = new StringWriter(); - Assert.Throws(() => Cli.Options.Parse( - ["api", "ChartOpts", "--wait", "ready", "--wait", "invalid"])); + Assert.Equal(2, Cli.Run(["api", "ChartOpts", "--wait", "ready", "--wait", "ready"], output, errors)); + Assert.Contains("'--wait'", errors.ToString(), StringComparison.Ordinal); + Assert.Equal("", output.ToString()); - (exit, output, error) = Run("api", "ChartOpts", "--wait", "ready", "--wait", "invalid"); - Assert.Equal(1, exit); - Assert.Contains("--wait needs \"ready\", not \"invalid\"", error, StringComparison.Ordinal); - Assert.Equal("", output); + // A wrong value for a one-value option is the same class of mistake, said differently. + output = new StringWriter(); + errors = new StringWriter(); + Assert.Equal(2, Cli.Run(["api", "ChartOpts", "--wait", "invalid"], output, errors)); + Assert.Contains("'ready'", errors.ToString(), StringComparison.Ordinal); + Assert.Equal("", output.ToString()); } [Fact] public void JsonAndIgnoredAppFlagsAnswerTheSameOffline() { - (int exit, string output, _) = Run("--json", "api", "ChartOpts"); + (int exit, string output, _) = Run("api", "ChartOpts", "--json"); Assert.Equal(0, exit); using (JsonDocument json = JsonDocument.Parse(output)) Assert.Equal("exact", json.RootElement.GetProperty("match").GetString()); @@ -359,8 +349,15 @@ public void TheOfflineJsonAnswersWithReflectionBasedSerializationDisabled() try { string tool = Path.Combine(directory, "CuiSharp.Tools.dll"); - File.Copy(source, tool); - File.Copy(Path.ChangeExtension(source, ".deps.json"), Path.ChangeExtension(tool, ".deps.json")); + // Everything beside the tool, not just the tool: its own dependencies have to travel + // or the child dies before it can answer anything. + foreach (string file in Directory.GetFiles(Path.GetDirectoryName(source)!)) + { + string name = Path.GetFileName(file); + if (name.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase)) continue; + File.Copy(file, Path.Combine(directory, name), overwrite: true); + } + File.WriteAllText(Path.ChangeExtension(tool, ".runtimeconfig.json"), """ { "runtimeOptions": { diff --git a/src/CuiSharp.Tests/DocsCommandTests.cs b/src/CuiSharp.Tests/DocsCommandTests.cs index 551eaaa..38e721e 100644 --- a/src/CuiSharp.Tests/DocsCommandTests.cs +++ b/src/CuiSharp.Tests/DocsCommandTests.cs @@ -212,7 +212,7 @@ public void TheDebuggingTopicCarriesTheWorkflowItIsPointedAtFor() Assert.Contains("SKILL.md § Driving a running app", debugging, StringComparison.Ordinal); // Headless one-shots, one process for a script, and a live window a person is watching. Assert.Contains("--stdin", debugging, StringComparison.Ordinal); - Assert.Contains("--attach", debugging, StringComparison.Ordinal); + Assert.Contains("-a ", debugging, StringComparison.Ordinal); Assert.Contains("one process", debugging, StringComparison.Ordinal); // And the rule that keeps a debugging session from being the only evidence. Assert.Contains("belongs in the test suite", debugging, StringComparison.Ordinal); @@ -260,13 +260,15 @@ public void AQuestionMadeOnlyOfFillerWordsAsksForARealOne() [Fact] public void ReadAndSearchWithoutTheirArgumentAreUsageErrors() { + // The arity says both take something, so a missing one is the parser's own sentence + // naming the argument, with the command it belongs to. (int exit, _, string error) = Run("docs", "read"); Assert.Equal(2, exit); - Assert.Contains("`docs read` needs a topic", error, StringComparison.Ordinal); + Assert.Contains("Required argument 'what' missing", error, StringComparison.Ordinal); (exit, _, error) = Run("docs", "search"); Assert.Equal(2, exit); - Assert.Contains("`docs search` needs words", error, StringComparison.Ordinal); + Assert.Contains("Required argument 'words' missing", error, StringComparison.Ordinal); } /// @@ -357,8 +359,11 @@ public void TheUsageScreenListsTheDocumentationCommands() Assert.Equal(0, exit); string usage = output.ToString(); - Assert.Contains("docs read ", usage, StringComparison.Ordinal); - Assert.Contains("docs search ", usage, StringComparison.Ordinal); + // The subcommands are listed with their summary lines, not as a second copy of the + // grammar: each one answers `dotnet cui docs read --help` on its own. + Assert.Contains("read", usage, StringComparison.Ordinal); + Assert.Contains("search", usage, StringComparison.Ordinal); + Assert.Contains("Commands:", usage, StringComparison.Ordinal); } /// The hit headings of a search answer, in the order the answer ranked them. diff --git a/src/CuiSharp.Tests/ToolArgumentTests.cs b/src/CuiSharp.Tests/ToolArgumentTests.cs index 0ee7bfc..67cbfbe 100644 --- a/src/CuiSharp.Tests/ToolArgumentTests.cs +++ b/src/CuiSharp.Tests/ToolArgumentTests.cs @@ -1,73 +1,232 @@ +using System.CommandLine; using System.Diagnostics; using System.Text; using CuiSharp.Tools; +using CliArgument = System.CommandLine.Argument; +using CliCommand = System.CommandLine.Command; +using CliHelpOption = System.CommandLine.Help.HelpOption; +using CliOption = System.CommandLine.Option; + namespace CuiSharp.Tests; /// -/// How dotnet cui reads its own arguments. The important distinctions: a word is the -/// command, everything after -- belongs to the app, and a typo in a flag is reported -/// instead of being sent to the app as a command it will reject. +/// How dotnet cui reads its own arguments, through the same +/// the tool itself dispatches on. The important distinctions: a command owns its arity, an option +/// owns its value, everything after -- belongs to the app, and a typo is reported rather +/// than sent to the app as a command it will reject. /// public class ToolArgumentTests { [Fact] public void AWordIsTheCommandAndTheRestIsItsArgument() { - Cli.Options options = Cli.Options.Parse(["click", "Save"]); + Grammar.Parsed parsed = Grammar.Parse(["click", "Save"]); - Assert.Equal("click", options.Command); - Assert.Equal("Save", options.Argument); - Assert.Equal("click Save", options.Line); - Assert.False(options.Help); + Assert.Empty(parsed.Errors); + Assert.Equal("click", parsed.Options!.Command); + Assert.Equal("Save", parsed.Options.Argument); + Assert.Equal("click Save", parsed.Options.Line); } [Fact] - public void ALabelWithSpacesStaysOneArgument() + public void ALabelWithSpacesIsOneQuotedArgument() { - Cli.Options options = Cli.Options.Parse(["type", "Hello", "world"]); + // The shell hands a quoted label over as one token and it stays one label. Splitting it + // back into words would make `click Save now` a two-argument command that no app knows. + Grammar.Parsed parsed = Grammar.Parse(["type", "Hello world"]); - Assert.Equal("Hello world", options.Argument); - Assert.Equal("type Hello world", options.Line); + Assert.Empty(parsed.Errors); + Assert.Equal("type Hello world", parsed.Options!.Line); + } + + [Fact] + public void ASecondWordWhereOneArgumentIsDeclaredIsRefusedNotSilentlyJoined() + { + // `type Hello world` used to be joined back into "Hello world"; the arity now says one + // argument, so an unquoted label is a usage error rather than a different label. + Grammar.Parsed parsed = Grammar.Parse(["type", "Hello", "world"]); + + Assert.NotEmpty(parsed.Errors); + Assert.Contains("world", string.Join("; ", parsed.Errors), StringComparison.Ordinal); } [Fact] public void FlagsBeforeTheSeparatorAreTheToolsAndAfterItAreTheApps() { - Cli.Options options = Cli.Options.Parse(["tree", "--frames", "3", "--no-build", "--", "--page=2", "--dark"]); + Grammar.Parsed parsed = Grammar.Parse(["tree", "--frames", "3", "--no-build", "--", "--page=2", "--dark"]); - Assert.Equal("tree", options.Command); - Assert.Equal(3, options.Frames); - Assert.True(options.NoBuild); - Assert.Equal(["--page=2", "--dark"], options.AppArgs); + Assert.Empty(parsed.Errors); + Assert.Equal("tree", parsed.Options!.Command); + Assert.Equal(3, parsed.Options.Frames); + Assert.True(parsed.Options.NoBuild); + Assert.Equal(["--page=2", "--dark"], parsed.Options.AppArgs); } [Fact] public void AFlagTheToolDoesNotKnowIsReportedRatherThanForwarded() { - var error = Assert.Throws(() => Cli.Options.Parse(["tree", "--pgae=2"])); - Assert.Contains("--pgae=2", error.Message, StringComparison.Ordinal); - Assert.Contains("--", error.Message, StringComparison.Ordinal); + Grammar.Parsed parsed = Grammar.Parse(["tree", "--pgae=2"]); + + Assert.NotEmpty(parsed.Errors); + Assert.Contains("--pgae=2", string.Join("; ", parsed.Errors), StringComparison.Ordinal); } [Fact] public void NoCommandIsHelp() { - Assert.True(Cli.Options.Parse([]).Help); - Assert.True(Cli.Options.Parse(["--frames", "2"]).Help); + Assert.True(Grammar.Parse([]).Help); + Assert.True(Grammar.Parse(["--frames", "2"]).Help); // Streaming supplies its commands on stdin, so it needs no command word. - Assert.False(Cli.Options.Parse(["--stdin"]).Help); + Assert.False(Grammar.Parse(["--stdin"]).Help); + Assert.True(Grammar.Parse(["--stdin"]).Options!.Streaming); } [Fact] - public void RunningTheToolWithoutACommandExplainsItself() + public void RunningTheToolWithoutACommandExplainsItselfOnTheErrorStream() { var output = new StringWriter(); var error = new StringWriter(); + // Exit 2 is a usage error, so the help goes where a usage error goes: a pipeline reading + // stdout must not receive a help page in place of an answer. Assert.Equal(2, Cli.Run([], output, error)); - Assert.Contains("dotnet cui", output.ToString(), StringComparison.Ordinal); - Assert.Contains("tree", output.ToString(), StringComparison.Ordinal); + Assert.Equal("", output.ToString()); + Assert.Contains("Usage:", error.ToString(), StringComparison.Ordinal); + Assert.Contains("tree", error.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void AnAttachTargetIsAnOptionValueAndNeverPartOfTheLabel() + { + // The bug this pins: `click "中文" -a 15036` left `15036` a positional word, so the tool + // looked for a widget whose label was "中文 15036" and reported that none existed. + Grammar.Parsed parsed = Grammar.Parse(["click", "中文", "-a", "15036"]); + + Assert.Empty(parsed.Errors); + Assert.True(parsed.Options!.Attach); + Assert.Equal("15036", parsed.Options.AttachTarget); + Assert.Equal("click 中文", parsed.Options.Line); + } + + [Fact] + public void AnAttachWithNoTargetPicksTheOnlyLiveWindow() + { + Grammar.Parsed parsed = Grammar.Parse(["tree", "-a"]); + + Assert.Empty(parsed.Errors); + Assert.True(parsed.Options!.Attach); + Assert.Null(parsed.Options.AttachTarget); + } + + [Fact] + public void ANegativeNumberIsAValueAndNotAnOption() + { + // `wheel` scrolls up with a negative dy; a parser that reads every leading dash as an + // option cannot express the one thing the command exists for. + Grammar.Parsed parsed = Grammar.Parse(["wheel", "120", "400", "-8"]); + + Assert.Empty(parsed.Errors); + Assert.Equal("wheel 120 400 -8", parsed.Options!.Line); + } + + [Fact] + public void EveryCommandAndOptionTheHelpListsCanBeTyped() + { + // The help is generated from this grammar, so this walks exactly what a reader is shown: + // every command with its required arguments, and every option in both its spellings. An + // option the help advertises as taking a value but the parser ignores fails here. + foreach (CliCommand command in Grammar.Root.Subcommands) + { + var argv = new List { command.Name }; + // Every declared slot, optional ones included: the point is the position, and a + // value in an optional slot is still a legal command line. + foreach (CliArgument argument in command.Arguments) argv.Add(SampleArgument(argument)); + // A timeout without a wait to bound is a different mistake, tested on its own. + if (command.Name is not "tree") argv.AddRange([]); + + Grammar.Parsed bare = Grammar.Parse([.. argv]); + Assert.True(bare.Errors.Count == 0, $"`{string.Join(' ', argv)}` did not parse: {string.Join("; ", bare.Errors)}"); + } + + // The help and version switches are the parser's own, and root-only: `tree --version` + // is not a command line anyone means. + foreach (CliOption option in Grammar.Root.Options.Where(o => o is not CliHelpOption and not VersionOption)) + { + foreach (string spelling in new[] { option.Name }.Concat(option.Aliases)) + { + var argv = new List { "tree", spelling }; + if (option.ValueType != typeof(bool)) argv.Add(SampleOption(option)); + // `--wait-timeout` bounds a wait, so it is only legal beside one. + if (spelling is "--wait-timeout") argv.InsertRange(1, ["--wait", "ready"]); + + Grammar.Parsed parsed = Grammar.Parse([.. argv]); + Assert.True(parsed.Errors.Count == 0, $"`{spelling}` did not parse: {string.Join("; ", parsed.Errors)}"); + } + } + + } + + /// A value the argument's declared type can hold, so the test walks the grammar's + /// shape without hard-coding which slot is a number today. + private static string SampleArgument(CliArgument argument) => argument.ValueType switch + { + Type t when t == typeof(int?) || t == typeof(float?) || t == typeof(double?) => "2", + _ => "x", + }; + + private static string SampleOption(CliOption option) => option.Name switch + { + "--wait" => "ready", + _ => option.ValueType == typeof(string) ? "x" : "2", + }; + + [Fact] + public void TheVersionSwitchAnswersWithAVersionAndNotTheHelp() + { + // The generated help lists `--version`, so it has to do something: a switch that falls + // through to the help screen is a switch that lies about itself. + var output = new StringWriter(); + var error = new StringWriter(); + + Assert.Equal(0, Cli.Run(["--version"], output, error)); + Assert.Matches(@"\d+\.\d+", output.ToString()); + Assert.DoesNotContain("Usage:", output.ToString(), StringComparison.Ordinal); + Assert.Equal("", error.ToString()); + } + + [Fact] + public void AMistypedVerbNamesTheVerbMeant() + { + // One letter wrong is the mistake an agent (and a person) makes most, so the answer says + // which verb was meant instead of only which one was rejected. + Grammar.Parsed parsed = Grammar.Parse(["clik", "Save"]); + + Assert.NotEmpty(parsed.Errors); + Assert.Contains("did you mean `dotnet cui click`", string.Join(" ", parsed.Errors), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void AWordThatIsNoVerbAtAllGetsNoInventedSuggestion() + { + Grammar.Parsed parsed = Grammar.Parse(["migrate-history"]); + + Assert.NotEmpty(parsed.Errors); + Assert.DoesNotContain("Did you mean", string.Join(" ", parsed.Errors), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void HelpIsPerCommandAndNamesItsArguments() + { + var output = new StringWriter(); + var error = new StringWriter(); + + Assert.Equal(0, Cli.Run(["click", "--help"], output, error)); + + string help = output.ToString(); + Assert.Contains("click ", help, StringComparison.Ordinal); + Assert.Contains("--attach", help, StringComparison.Ordinal); + Assert.Equal("", error.ToString()); } [Fact] @@ -75,59 +234,59 @@ public void AWaitIsAPreconditionAndNotTheCommand() { // `png out.png --wait ready`: png is the command and keeps the payload; the wait is a // gate the way --frames is, so the line the command travels on stays untouched. - Cli.Options options = Cli.Options.Parse(["png", "out.png", "--wait", "ready"]); + Grammar.Parsed parsed = Grammar.Parse(["png", "out.png", "--wait", "ready"]); - Assert.False(options.Help); - Assert.Equal("png out.png", options.Line); - Assert.Equal("wait 10", options.WaitLine); - Assert.Equal(Cli.Options.DefaultWaitSeconds, options.WaitSeconds); + Assert.Empty(parsed.Errors); + Assert.Equal("png out.png", parsed.Options!.Line); + Assert.Equal("wait 10", parsed.Options.WaitLine); + Assert.Equal(Cli.Options.DefaultWaitSeconds, parsed.Options.WaitSeconds); } [Fact] public void AWaitWithNoCommandWaitsForTheTreeItPrints() { - Cli.Options options = Cli.Options.Parse(["--wait", "ready", "--wait-timeout", "30"]); + Grammar.Parsed parsed = Grammar.Parse(["--wait", "ready", "--wait-timeout", "30"]); - Assert.False(options.Help); - Assert.Equal("tree", options.Line); - Assert.Equal("wait 30", options.WaitLine); - Assert.Equal(30, options.WaitSeconds); + Assert.Empty(parsed.Errors); + Assert.Equal("tree", parsed.Options!.Line); + Assert.Equal("wait 30", parsed.Options.WaitLine); + Assert.Equal(30, parsed.Options.WaitSeconds); } [Fact] public void AWaitTextCarriesItsTimeoutAndItsTextUnchanged() { - Cli.Options options = Cli.Options.Parse( + Grammar.Parsed parsed = Grammar.Parse( ["click", "Save", "--wait-text", "添加 \"Save now\" 3", "--wait-timeout", "2.5"]); - Assert.Equal("click Save", options.Line); - Assert.Equal(2.5, options.WaitSeconds); + Assert.Empty(parsed.Errors); + Assert.Equal("click Save", parsed.Options!.Line); + Assert.Equal(2.5, parsed.Options.WaitSeconds); // The query is verbatim after the timeout: spaces, quotes and a numeric tail all stay // user text, and the number in front is the only one the app parses. - Assert.Equal("waittext 2.5 添加 \"Save now\" 3", options.WaitLine); + Assert.Equal("waittext 2.5 添加 \"Save now\" 3", parsed.Options.WaitLine); } [Fact] public void AWaitCoexistsWithFramesAndStdin() { - Cli.Options options = Cli.Options.Parse( - ["--frames", "3", "--wait", "ready", "--stdin"]); + Grammar.Parsed parsed = Grammar.Parse(["--frames", "3", "--wait", "ready", "--stdin"]); - Assert.Equal(3, options.Frames); - Assert.True(options.Streaming); - Assert.False(options.Help); + Assert.Empty(parsed.Errors); + Assert.Equal(3, parsed.Options!.Frames); + Assert.True(parsed.Options.Streaming); // In a session the gate passes before any stdin line is relayed, and no command word // is invented for it — stdin carries the commands. - Assert.Equal("wait 10", options.WaitLine); - Assert.Equal("", options.Line); + Assert.Equal("wait 10", parsed.Options.WaitLine); + Assert.Equal("", parsed.Options.Line); } [Fact] public void AWaitOptionThatContradictsItselfIsRefused() { - Assert.Throws(() => Cli.Options.Parse(["--wait", "busy"])); - Assert.Throws(() => Cli.Options.Parse(["--wait", "ready", "--wait-text", "Save"])); - Assert.Throws(() => Cli.Options.Parse(["--wait-timeout", "3"])); + Assert.NotEmpty(Grammar.Parse(["--wait", "busy"]).Errors); + Assert.NotEmpty(Grammar.Parse(["--wait", "ready", "--wait-text", "Save"]).Errors); + Assert.NotEmpty(Grammar.Parse(["--wait-timeout", "3"]).Errors); } [Theory] @@ -139,12 +298,13 @@ public void AWaitOptionThatContradictsItselfIsRefused() [InlineData("3601")] public void AWaitTimeoutThatCannotBoundAWaitIsRefusedNotThrown(string value) { - // TryParse takes NaN and Infinity, a raw parse throws FormatException at the user, and - // an unbounded timeout overflows the answer deadline downstream — all of them are - // typos, and a typo is a sentence with exit=1. - var error = Assert.Throws( - () => Cli.Options.Parse(["--wait", "ready", "--wait-timeout", value])); - Assert.Contains("wait-timeout", error.Message, StringComparison.Ordinal); + // TryParse accepts NaN and Infinity, and an unbounded timeout overflows the answer + // deadline downstream — all of them are typos, and a typo is a sentence with exit=2. + var output = new StringWriter(); + var error = new StringWriter(); + + Assert.Equal(2, Cli.Run(["--wait", "ready", "--wait-timeout", value, "tree"], output, error)); + Assert.Contains("wait-timeout", error.ToString(), StringComparison.Ordinal); } [Theory] @@ -155,8 +315,10 @@ public void AFramesCountThatCannotDrawIsRefusedNotThrown(string value) { // int.Parse used to throw FormatException and OverflowException out of the parser for // these; a negative count drew nothing while claiming it had drawn. - var error = Assert.Throws(() => Cli.Options.Parse(["tree", "--frames", value])); - Assert.Contains("frames", error.Message, StringComparison.Ordinal); + Grammar.Parsed parsed = Grammar.Parse(["tree", "--frames", value]); + + Assert.NotEmpty(parsed.Errors); + Assert.Contains("frames", string.Join("; ", parsed.Errors), StringComparison.Ordinal); } [Theory] @@ -319,7 +481,7 @@ public void AFramePreconditionTheAppRefusesStopsTheRunBeforeAnythingElse() new DelayedAnswer("CUI-READY 800 600\n", "CUI-ERR 13\nthe app threw\n"), sent); var error = new StringWriter(); - Cli.Options options = Cli.Options.Parse(["tree", "--frames", "2", "--wait", "ready"]); + Cli.Options options = Grammar.Parse(["tree", "--frames", "2", "--wait", "ready"]).Options!; Assert.False(Cli.PassPreconditions(client, options, error)); diff --git a/src/CuiSharp.Tools/Cli.cs b/src/CuiSharp.Tools/Cli.cs index 0e503a5..2a6e7ba 100644 --- a/src/CuiSharp.Tools/Cli.cs +++ b/src/CuiSharp.Tools/Cli.cs @@ -1,75 +1,18 @@ +using System.CommandLine; +using System.CommandLine.Help; +using System.CommandLine.Invocation; +using System.CommandLine.Parsing; using System.Globalization; namespace CuiSharp.Tools; /// /// dotnet cui: drives a CuiSharp app — either headless over stdio or attached to a live -/// window over a local named pipe — and answers offline API/icon catalog queries. +/// window over a local named pipe — and answers offline docs/API/icon queries. The grammar itself +/// is declared in ; this file is the dispatcher. /// internal static class Cli { - private const string Usage = """ - dotnet cui [argument] [options] - - Commands (what the app understands): - tree the controls the last frame drew, nested - layout layout rectangles, observed union bounds and overflow diagnostics - find one control: kind, label and rectangle - click click a control, then print the frame it produced - type type into the focused control, then print the frame - key press a key, e.g. `key Tab` - focus report that the window has focus (needed before typing) - move put the pointer somewhere without clicking (hover, tooltips) - wheel [dx] scroll at a point, in wheel notches (negative dy goes down) - frame [count] draw more frames, then print the tree - pulse [count] draw frames without printing the tree (answers: count, heap bytes) - wait [seconds] keep drawing until a frame marks itself ready (ui.MarkReady) - waittext keep drawing until a frame draws this text or query - faults what the app threw while drawing, if anything - png save the last frame as a PNG - quit close a live attached window - - Commands the tool answers itself: - ps [--json] list running live CuiSharp windows (--live / CUISHARP_LIVE=1) - dwell keep the app drawing for that long, then answer with one STATS - line: wall and cpu milliseconds, frames, fps, and the app's - heap, working-set and private bytes - - Commands answered offline, from the pages and the API catalog inside this tool: - docs the topics and documents bundled here, and what to run next. - Reads no project, builds nothing, starts nothing, needs no network. - docs read one topic, one document or one section: `docs read testing`, - `docs read SKILL`, `docs read SKILL#pitfalls`. - docs search the sections that answer a question, ranked, each with the - `docs read` line that opens it. - api one declaration: `api ChartOpts`, `api Ui.LineChart`, `api Button`. - An exact answer exits 0; an ambiguous or unknown one names the - qualified or nearest names and exits nonzero. - icons [filter] every icon constant: name, code point, and the literal to paste. - All of these take --json for machine-readable output; none builds, launches or loads - anything. - - Options: - -a, --attach [id] drive a running live window (--live) over its named pipe instead - of starting a new headless process; use --attach= when - more than one live window is open - -p, --project the app to run: a directory, a .csproj, or a file-based .cs - app. Default: here - --app run a published build instead of building - --framework the target framework, when the project has more than one - --frames draw this many frames before the command - -s, --stdin read commands from stdin and print each answer, one process - -q, --quiet only print the payload - --wait ready precondition: draw until a frame marks itself ready (ui.MarkReady) - --wait-text precondition: draw until a frame draws this text or query - --wait-timeout wait window for the two above, in seconds (default 10, max 3600) - - A wait is a precondition, not a command: `dotnet cui png out.png --wait ready` screenshots - the frame that proved the app ready, and an unsatisfied wait ends the run with exit=1 - before the command is sent. `--wait ready` alone waits and then prints the tree. - - Anything after `--` goes to the app. - """; private static readonly Lazy EmbeddedCatalog = new(() => { @@ -88,14 +31,34 @@ Anything after `--` goes to the app. /// Runs the tool; the return value is the process exit code. internal static int Run(string[] args, TextWriter output, TextWriter error, TextReader? input = null) { + Grammar.Parsed parsed = Grammar.Parse(args); + + if (parsed.Errors.Count > 0) + { + foreach (string message in parsed.Errors) error.WriteLine(message); + error.WriteLine(Grammar.UsageHint); + return 2; + } + + // `--help` and `--version` are System.CommandLine's own actions: let it render them, but + // through this tool's writers so a captured stream and a redirected one agree. + if (parsed.NothingAsked) + { + // A bare `dotnet cui` asked for nothing, so the help is a usage error: it belongs on + // stderr with a nonzero exit, not in a pipeline as if it were the answer. + Grammar.Root.Parse(["--help"]).Invoke(new InvocationConfiguration { Output = error, Error = error }); + return 2; + } + + if (parsed.Help || parsed.Version) + { + parsed.Result.Invoke(new InvocationConfiguration { Output = output, Error = error }); + return 0; + } + try { - Options options = Options.Parse(args); - if (options.Help) - { - output.WriteLine(Usage); - return args.Length == 0 ? 2 : 0; - } + Options options = parsed.Options!; if (options.Command is "api" or "icons") return AnswerCatalog(options, output, error); @@ -137,6 +100,21 @@ internal static int Run(string[] args, TextWriter output, TextWriter error, Text } } + /// + /// System.CommandLine reports a conversion failure with the CLR type name in it — + /// "expected type 'System.Nullable`1[System.Int32]'" — which is a fact about the language, not + /// about the number the reader meant to type. Translate the three types this grammar uses. + /// + internal static string Humanize(string message) => message + .Replace("System.Nullable`1[System.Int32]", "a whole number", StringComparison.Ordinal) + .Replace("System.Nullable`1[System.Single]", "a number", StringComparison.Ordinal) + .Replace("System.Nullable`1[System.Double]", "a number", StringComparison.Ordinal) + .Replace("System.Int32", "a whole number", StringComparison.Ordinal) + .Replace("System.Single", "a number", StringComparison.Ordinal) + .Replace("System.Double", "a number", StringComparison.Ordinal) + .Replace("as expected type 'a whole number'", "as a whole number", StringComparison.Ordinal) + .Replace("as expected type 'a number'", "as a number", StringComparison.Ordinal); + /// /// Answers a docs call: a topic, a document, a section, or a search. Like the catalog /// commands this returns before any project is resolved, so it works in an empty directory. @@ -364,7 +342,8 @@ internal sealed record Options internal bool Quiet { get; init; } - internal bool Help { get; init; } + /// True when no verb was given: either the help, or `--wait ready` alone. + internal bool NoCommand { get; init; } internal bool Json { get; init; } @@ -394,165 +373,5 @@ internal sealed record Options internal const double MaxWaitSeconds = 3600; - internal static Options Parse(string[] args) - { - if (args.Length == 0) return new Options { Help = true }; - - string? project = null, host = null, framework = null, attachTarget = null; - string? wait = null, waitText = null; - double? waitTimeout = null; - var appArgs = new List(); - var words = new List(); - int frames = 0; - bool quiet = false, help = false, streaming = false, noBuild = false, json = false, attach = false; - - for (int i = 0; i < args.Length; i++) - { - string arg = args[i]; - switch (arg) - { - case "-h" or "--help": - help = true; - break; - case "help" when words.Count == 0: - help = true; - break; - case "--json": - json = true; - break; - case "-a" or "--attach": - attach = true; - break; - case "-t" or "--target": - attach = true; - attachTarget = Value(args, ref i); - break; - case "-p" or "--project": - project = Value(args, ref i); - break; - case "--app": - host = Value(args, ref i); - break; - case "--framework": - framework = Value(args, ref i); - break; - case "--frames": - frames = ParseFrames(Value(args, ref i)); - break; - case "--wait": - wait = Value(args, ref i); - break; - case "--wait-text": - waitText = Value(args, ref i); - break; - case "--wait-timeout": - waitTimeout = ParseWaitTimeout(Value(args, ref i)); - break; - case "-q" or "--quiet": - quiet = true; - break; - case "-s" or "--stdin": - streaming = true; - break; - case "--no-build": - noBuild = true; - break; - case "--": - appArgs.AddRange(args[(i + 1)..]); - i = args.Length; - break; - default: - if (arg.StartsWith("--attach=", StringComparison.Ordinal)) - { - string value = arg["--attach=".Length..].Trim(); - if (value.Length == 0) throw new ToolException("--attach needs a target."); - attach = true; - attachTarget = value; - break; - } - - if (arg.StartsWith("--", StringComparison.Ordinal) || arg.StartsWith('-')) - throw new ToolException($"Unknown option \"{arg}\". Use `--` to pass it to the app."); - words.Add(arg); - break; - } - } - - bool waiting = wait is not null || waitText is not null; - if (wait is not null && !wait.Equals("ready", StringComparison.OrdinalIgnoreCase)) - throw new ToolException($"--wait needs \"ready\", not \"{wait}\"."); - if (wait is not null && waitText is not null) - throw new ToolException("Choose one wait: --wait ready or --wait-text , not both."); - if (waitTimeout is not null && !waiting) - throw new ToolException("--wait-timeout needs --wait or --wait-text to bound."); - - double waitSeconds = waitTimeout ?? DefaultWaitSeconds; - string? waitLine = null; - if (waiting) - { - string seconds = waitSeconds.ToString("R", CultureInfo.InvariantCulture); - waitLine = waitText is not null ? $"waittext {seconds} {waitText}" : $"wait {seconds}"; - } - - string command = words.Count > 0 - ? words[0] - : waiting && !streaming ? "tree" : ""; - string argument = words.Count > 1 ? string.Join(' ', words.Skip(1)) : ""; - - bool showHelp = help || (words.Count == 0 && !streaming && !waiting); - if (json && !showHelp && command is not ("api" or "icons" or "ps" or "docs")) - throw new ToolException("Unknown option \"--json\". Only `docs`, `api`, `icons`, and `ps` support --json."); - - return new Options - { - Command = command, - Argument = argument, - Frames = frames, - Quiet = quiet, - Json = json, - Attach = attach, - AttachTarget = attachTarget, - Streaming = streaming, - NoBuild = noBuild, - Help = showHelp, - Project = project, - Host = host, - Framework = framework, - AppArgs = [.. appArgs], - WaitSeconds = waiting ? waitSeconds : 0, - WaitLine = waitLine, - }; - } - - internal static int ParseFrames(string value) - { - if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out int frames) || frames < 0) - throw new ToolException($"--frames needs a count of at least 0, not \"{value}\"."); - - return frames; - } - - internal static double ParseWaitTimeout(string value) - { - if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double seconds) - || !double.IsFinite(seconds) || seconds <= 0 || seconds > MaxWaitSeconds) - { - throw new ToolException( - $"--wait-timeout needs a finite number of seconds between 0 and {MaxWaitSeconds:0}, not \"{value}\"."); - } - - return seconds; - } - - private static string Value(string[] args, ref int index) - { - if (index + 1 >= args.Length || IsFlagToken(args[index + 1])) - throw new ToolException($"{args[index]} needs a value."); - return args[++index]; - } - - private static bool IsFlagToken(string token) => - token.StartsWith("--", StringComparison.Ordinal) - || token is "-h" or "-q" or "-s" or "-p" or "-a" or "-t"; } } diff --git a/src/CuiSharp.Tools/CuiSharp.Tools.csproj b/src/CuiSharp.Tools/CuiSharp.Tools.csproj index 071868a..c103a5e 100644 --- a/src/CuiSharp.Tools/CuiSharp.Tools.csproj +++ b/src/CuiSharp.Tools/CuiSharp.Tools.csproj @@ -27,6 +27,11 @@ Bundle of the CuiSharp guide and API reference for offline reading, plus a driver that prints a CuiSharp app's controls, clicks and types into it, and saves its frame as a PNG - without a window, an agent SDK or a test project. cui;ui;tools;dotnet-tool;agent + + + + diff --git a/src/CuiSharp.Tools/Grammar.cs b/src/CuiSharp.Tools/Grammar.cs new file mode 100644 index 0000000..39d23c2 --- /dev/null +++ b/src/CuiSharp.Tools/Grammar.cs @@ -0,0 +1,575 @@ +using System.CommandLine; +using System.CommandLine.Help; +using System.CommandLine.Parsing; +using System.Globalization; + +namespace CuiSharp.Tools; + +/// +/// The dotnet cui grammar, declared once with System.CommandLine so that the help text, +/// the arity, the value types and the error messages cannot drift apart. +/// +/// The verbs are the app's wire protocol, not this tool's invention: each is a +/// whose typed arguments re-serialises into exactly +/// one protocol line. Declaring them — rather than splitting a token list by hand — is what gives +/// click an arity, frame an , a mistyped verb a suggestion, and +/// -a 15036 its value. +/// +/// +internal static class Grammar +{ + /// What to say after a usage error, so a reader (or an agent) has the next step. + internal const string UsageHint = "Run `dotnet cui --help`, or `dotnet cui --help`."; + + /// The verbs the app answers: name, description, and one argument per protocol slot. + private static readonly (string Name, string Description, Argument[] Arguments)[] Verbs = + [ + ("tree", "the controls the last frame drew, nested (`print` is an alias)", []), + ("layout", "layout rectangles, observed union bounds and overflow diagnostics", []), + ("focus", "report that the window has focus (needed before typing)", []), + ("faults", "what the app threw while drawing, if anything", []), + ("quit", "close a live attached window", []), + + ("find", "one control: kind, label and rectangle", [Text("text", "the label, text or id to find")]), + ("click", "click a control, then print the frame it produced", [Text("text", "the label, text or id to click")]), + ("type", "type into the focused control, then print the frame", [Text("text", "the text to type")]), + ("key", "press a key, e.g. `key Tab`", [Text("name", "the key: Tab, Enter, F1, Ctrl+S")]), + + ("move", "put the pointer somewhere without clicking (hover, tooltips)", + [Number("x", required: true), Number("y", required: true)]), + ("wheel", "scroll at a point, in wheel notches (negative dy scrolls down)", + [Number("x", required: true), Number("y", required: true), Number("dy", required: true), Number("dx", required: false)]), + + ("frame", "draw more frames, then print the tree", [Count("how many frames (default 1)")]), + ("pulse", "draw frames without printing the tree (answers: count, heap bytes)", [Count("how many frames (default 1)")]), + ("wait", "keep drawing until a frame marks itself ready (ui.MarkReady)", [Seconds("how long to keep waiting (default: the app's own budget)")]), + ("waittext", "keep drawing until a frame draws this text or query", + [Seconds("how long to keep waiting"), Text("query", "the text or query to wait for")]), + + ("png", "save the last frame as a PNG", [OptionalText("path", "where to write it (default: a temporary file)")]), + ("dwell", "keep the app drawing, then answer with one STATS line", [Seconds("how long to draw for")]), + ]; + + /// Every symbol the resolver needs, in one place. + private sealed class Symbols + { + internal Option Project = new("--project", "-p") + { + Description = "the app to drive: a directory, a .csproj, or a file-based .cs app (default: here)", + HelpName = "path", + Recursive = true, + }; + + internal Option App = new("--app") + { + Description = "drive a published build instead of building", + HelpName = "exe", + Recursive = true, + }; + + internal Option Framework = new("--framework", "-f") + { + Description = "the target framework, when the project has more than one", + HelpName = "tfm", + Recursive = true, + }; + + internal Option Frames = new("--frames") + { + Description = "draw this many frames before the command runs", + HelpName = "count", + Arity = ArgumentArity.ExactlyOne, + Recursive = true, + }; + + internal Option Attach = new("--attach", "-a") + { + Description = "drive a running live window (started with --live) over its named pipe, " + + "instead of starting a new headless process; the value is its pid or title", + HelpName = "pid|title", + Arity = ArgumentArity.ZeroOrOne, + Recursive = true, + }; + + internal Option Wait = new("--wait") + { + Description = "precondition: draw until a frame marks itself ready (ui.MarkReady)", + HelpName = "ready", + Arity = ArgumentArity.ExactlyOne, + Recursive = true, + }; + + internal Option WaitText = new("--wait-text") + { + Description = "precondition: draw until a frame draws this text or query", + HelpName = "text", + Recursive = true, + }; + + internal Option WaitTimeout = new("--wait-timeout") + { + Description = "the window for --wait and --wait-text, in seconds (default 10, max 3600)", + HelpName = "seconds", + Recursive = true, + }; + + internal Option Quiet = new("--quiet", "-q") + { + Description = "print only the payload, never the tool's own progress", + Recursive = true, + }; + + internal Option Stdin = new("--stdin", "-s") + { + Description = "read one protocol command per line from stdin, with no command word", + Recursive = true, + }; + + internal Option NoBuild = new("--no-build") + { + Description = "reuse the last build instead of rebuilding", + Recursive = true, + }; + + /// + /// A fresh --json per command. Sharing one instance across commands makes the + /// symbol resolve ambiguously, and a flag nobody typed reads back as present. + /// + internal Option Json() => new("--json") { Description = "answer as JSON instead of text" }; + + internal Argument ApiName = new("name") { Description = "a type or member: ChartOpts, Ui.LineChart, Button" }; + + internal Argument IconsFilter = OptionalText("filter", "only the icons whose name contains this"); + + /// + /// The shorthand `docs testing`. Hidden from the usage lines because it belongs to the + /// `docs` command, and rendering it would suggest `docs testing search `. + /// + internal Argument DocsWhat = OptionalText("what", "a topic, a document, or a document#section"); + + + internal Argument DocsReadWhat = new("what") { Description = "a topic, a document, or a document#section" }; + + internal Argument DocsWords = new("words") + { + Description = "the words to look for", + Arity = ArgumentArity.OneOrMore, + }; + + internal void Validate() + { + Wait.AcceptOnlyFromAmong("ready"); + + Frames.Validators.Add(result => + { + if (Peek(result) is { } frames && frames < 0) + result.AddError("--frames needs a count of at least 0"); + }); + + WaitTimeout.Validators.Add(result => + { + if (Peek(result) is { } seconds && !InRange(seconds)) + result.AddError($"--wait-timeout needs seconds above 0 and at most {Cli.Options.MaxWaitSeconds:0}"); + }); + } + + /// + /// A validator-safe read. System.CommandLine has already recorded a conversion failure + /// (and reports it), but asking for the value would throw instead of returning. + /// + internal static T? Peek(SymbolResult result) where T : struct + { + try + { + return result switch + { + OptionResult option => option.GetValueOrDefault(), + ArgumentResult argument => argument.GetValueOrDefault(), + _ => null, + }; + } + catch (InvalidOperationException) + { + return null; + } + } + + internal static bool InRange(double seconds) => + double.IsFinite(seconds) && seconds > 0 && seconds <= Cli.Options.MaxWaitSeconds; + } + + // One initializer, because field initializers run in textual order: two separate ones could + // leave Root assigned before the symbols that give it its meaning. + private static void NoOp(ParseResult _) + { + // Dispatch happens in Cli.Run; an action exists only so a parent command can be terminal. + } + + private static readonly (Symbols Symbols, RootCommand Root) Built = Build(); + + private static Symbols S => Built.Symbols; + + internal static RootCommand Root => Built.Root; + + private static (Symbols Symbols, RootCommand Root) Build() + { + var symbols = new Symbols(); + symbols.Validate(); + + var root = new RootCommand("drive a CuiSharp app from a shell or an agent, and read its rules offline."); + // Without an action a parent command is only a namespace; `dotnet cui --stdin` and the + // help path need the root itself to be a valid terminal. + root.SetAction(NoOp); + + foreach ((string name, string description, Argument[] arguments) in Verbs) + { + var command = new Command(name, description); + foreach (Argument argument in arguments) command.Arguments.Add(argument); + root.Subcommands.Add(command); + } + + var ps = new Command("ps", "list running live CuiSharp windows (started with --live)"); + ps.Options.Add(symbols.Json()); + ps.SetAction(NoOp); + root.Subcommands.Add(ps); + + root.Subcommands.Add(Api(symbols)); + root.Subcommands.Add(Icons(symbols)); + root.Subcommands.Add(Docs(symbols)); + + foreach (Option option in new Option[] + { + symbols.Project, symbols.App, symbols.Framework, symbols.Frames, symbols.Attach, + symbols.Wait, symbols.WaitText, symbols.WaitTimeout, symbols.Quiet, symbols.Stdin, + symbols.NoBuild, + }) + { + root.Options.Add(option); + } + + return (symbols, root); + } + + private static Command Api(Symbols symbols) + { + var api = new Command("api", "one declaration from the API catalog inside this tool, offline"); + api.Arguments.Add(symbols.ApiName); + api.Options.Add(symbols.Json()); + return api; + } + + private static Command Icons(Symbols symbols) + { + var icons = new Command("icons", "every icon constant: name, code point, and the literal to paste"); + icons.Arguments.Add(symbols.IconsFilter); + icons.Options.Add(symbols.Json()); + return icons; + } + + private static Command Docs(Symbols symbols) + { + symbols.DocsWhat.Hidden = true; + var docs = new Command( + "docs", + "the guide and the reference bundled with this tool, read offline; " + + "`docs ` is shorthand for `docs read `") + { + symbols.DocsWhat, + }; + docs.Options.Add(symbols.Json()); + // A command with subcommands still has to be usable on its own: `dotnet cui docs` is the + // index, and `dotnet cui docs SKILL#pitfalls` is one section. + docs.SetAction(NoOp); + + var read = new Command("read", "print one topic, one document, or one #section of one"); + read.Arguments.Add(symbols.DocsReadWhat); + read.Options.Add(symbols.Json()); + read.SetAction(NoOp); + docs.Subcommands.Add(read); + + var search = new Command("search", "the sections that answer a question, ranked"); + search.Arguments.Add(symbols.DocsWords); + search.Options.Add(symbols.Json()); + search.SetAction(NoOp); + docs.Subcommands.Add(search); + + return docs; + } + + // ---- argument builders ------------------------------------------------------------------ + + private static Argument Text(string name, string description) => + new(name) { Description = description }; + + private static Argument OptionalText(string name, string description) => + new(name) { Description = description, Arity = ArgumentArity.ZeroOrOne }; + + private static Argument Number(string name, bool required) => + new(name) { Description = name, Arity = required ? ArgumentArity.ExactlyOne : ArgumentArity.ZeroOrOne }; + + private static Argument Count(string description) + { + var count = new Argument("count") { Description = description, Arity = ArgumentArity.ZeroOrOne }; + count.Validators.Add(result => + { + if (Symbols.Peek(result) is { } value && value < 1) + result.AddError("needs a count of at least 1"); + }); + return count; + } + + private static Argument Seconds(string description) + { + var seconds = new Argument("seconds") { Description = description, Arity = ArgumentArity.ZeroOrOne }; + seconds.Validators.Add(result => + { + if (Symbols.Peek(result) is { } value && !Symbols.InRange(value)) + result.AddError($"needs seconds above 0 and at most {Cli.Options.MaxWaitSeconds:0}"); + }); + return seconds; + } + + // ---- parse result → Options ------------------------------------------------------------- + + /// + /// One parsed command line: the help, the version, the diagnostics, or the options to + /// dispatch. is the bare invocation, which is a usage error + /// rather than a request for the help. + /// + internal sealed record Parsed( + bool Help, + bool Version, + bool NothingAsked, + Cli.Options? Options, + IReadOnlyList Errors, + ParseResult Result); + + /// + /// Reads a command line. This is the only path: the dispatcher and the tests both come + /// through here, so what a test asserts is what a user gets. + /// + internal static Parsed Parse(string[] args) + { + string[] grammarArgs = SplitAppArguments(args, out string[] appArguments); + ParseResult result = Root.Parse(grammarArgs); + + var errors = new List(); + foreach (ParseError parseError in result.Errors) errors.Add(Cli.Humanize(parseError.Message)); + if (errors.Count == 0 && !TryCheckCombinations(result, out string conflict)) errors.Add(conflict); + if (errors.Count > 0 && NearestCommand(grammarArgs) is { } meant) + { + // One unknown verb leaves every following token unrecognised too. Only the verb is + // the mistake, so say that once and name the verb that was meant. + errors = [errors[0], $"Did you mean `dotnet cui {meant}`? Run `dotnet cui --help` for the rest."]; + } + if (errors.Count > 0) return new Parsed(false, false, false, null, errors, result); + + // `--version` is System.CommandLine's own action, and it is root-only: answering it + // here keeps the help page from advertising a switch that would otherwise fall through + // to the help screen. + if (Root.Options.Any(option => option is VersionOption && result.GetResult(option) is { Implicit: false })) + return new Parsed(false, true, false, null, [], result); + + // The help does not need resolved options, and resolving would demand the arguments it + // is about to explain: `dotnet cui click --help` must not require a label. + if (result.Action is HelpAction) return new Parsed(true, false, false, null, [], result); + + Cli.Options options = Resolve(result, appArguments); + if (options.NoCommand && !options.Streaming) + { + // A bare `--wait ready` still means "wait, then show me the tree"; anything else has + // no command to run, and guessing would hide a typo. + if (options.WaitLine is null) return new Parsed(true, false, true, null, [], result); + options = options with { Command = "tree", NoCommand = false }; + } + + return new Parsed(false, false, false, options, [], result); + } + + /// + /// The verb a mistyped first word was probably meant to be: same opening letters, or a + /// near miss. A suggestion is worth this much arithmetic because the alternative is a reader + /// (or an agent) spending a turn re-reading the help to find out it typed one letter wrong. + /// + private static string? NearestCommand(string[] args) + { + string? token = args.FirstOrDefault(word => word.Length > 0 && word[0] != '-'); + if (token is null || Root.Subcommands.Any(command => command.Name == token)) return null; + + string? best = null; + int bestDistance = int.MaxValue; + foreach (Command command in Root.Subcommands) + { + int distance = Distance(token, command.Name); + if (distance < bestDistance) + { + bestDistance = distance; + best = command.Name; + } + } + + // One edit in five letters is a typo; more than that is a different word. + int budget = Math.Max(1, Math.Min(3, token.Length / 2)); + return bestDistance <= budget ? best : null; + } + + private static int Distance(string left, string right) + { + ReadOnlySpan a = left.ToLowerInvariant(); + ReadOnlySpan b = right.ToLowerInvariant(); + Span previous = stackalloc int[b.Length + 1]; + Span current = stackalloc int[b.Length + 1]; + for (int j = 0; j <= b.Length; j++) previous[j] = j; + + for (int i = 1; i <= a.Length; i++) + { + current[0] = i; + for (int j = 1; j <= b.Length; j++) + { + int cost = a[i - 1] == b[j - 1] ? 0 : 1; + current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost); + } + + previous.Clear(); + current.CopyTo(previous); + } + + return previous[b.Length]; + } + + /// + /// Splits the app's own arguments off at the first standalone --. Doing it here keeps + /// one owner of that rule: everything to the right is handed to the app untouched, and the + /// grammar never has to treat an app flag as a mistake. + /// + internal static string[] SplitAppArguments(string[] args, out string[] appArguments) + { + int separator = Array.IndexOf(args, "--"); + if (separator < 0) + { + appArguments = []; + return args; + } + + appArguments = args[(separator + 1)..]; + return args[..separator]; + } + + /// + /// Rules that span two options. They live here rather than in a Command.Validators on + /// the root because root validators do not run when a subcommand is the one being invoked. + /// + internal static bool TryCheckCombinations(ParseResult result, out string message) + { + bool wait = result.GetResult(S.Wait) is not null; + bool waitText = result.GetResult(S.WaitText) is not null; + if (wait && waitText) + { + message = "choose one wait: --wait ready or --wait-text , not both"; + return false; + } + + if (result.GetResult(S.WaitTimeout) is not null && !wait && !waitText) + { + message = "--wait-timeout needs --wait or --wait-text to bound"; + return false; + } + + message = ""; + return true; + } + + /// Turns a successfully parsed command line into the options the tool dispatches on. + internal static Cli.Options Resolve(ParseResult result, string[] appArguments) + { + CommandResult matched = result.CommandResult; + // A RootCommand takes its name from the running assembly (the test host, the shim...), + // so "nothing was asked for" is decided by identity, never by an empty string. + string name = ReferenceEquals(matched.Command, Root) ? "" : matched.Command.Name; + + (string command, string argument) = name switch + { + // `docs read X` and `docs search a b` are subcommands so their help is per-verb; the + // docs reader still takes one line, so the verb goes back on the front here. + "read" or "search" => ("docs", $"{name} {Join(result, matched)}"), + "api" => ("api", Join(result, matched)), + "icons" => ("icons", Join(result, matched)), + "docs" => ("docs", Join(result, matched)), + "ps" => ("ps", ""), + _ => (name, Join(result, matched)), + }; + + bool waiting = result.GetResult(S.Wait) is not null || result.GetResult(S.WaitText) is not null; + double waitSeconds = result.GetValue(S.WaitTimeout) ?? Cli.Options.DefaultWaitSeconds; + string? waitLine = null; + if (waiting) + { + string seconds = waitSeconds.ToString("R", CultureInfo.InvariantCulture); + waitLine = result.GetResult(S.WaitText) is not null + ? $"waittext {seconds} {result.GetValue(S.WaitText)}" + : $"wait {seconds}"; + } + + OptionResult? attach = result.GetResult(S.Attach); + bool streaming = result.GetValue(S.Stdin); + return new Cli.Options + { + Command = command, + Argument = argument, + // `--wait ready` with no command means "wait, then show me the tree". + NoCommand = command.Length == 0, + Streaming = streaming, + Frames = result.GetValue(S.Frames) ?? 0, + Quiet = result.GetValue(S.Quiet), + Json = JsonRequested(result, matched), + Attach = attach is not null, + AttachTarget = attach?.GetValueOrDefault() is { Length: > 0 } target ? target : null, + NoBuild = result.GetValue(S.NoBuild), + Project = result.GetValue(S.Project), + Host = result.GetValue(S.App), + Framework = result.GetValue(S.Framework), + AppArgs = appArguments, + WaitSeconds = waiting ? waitSeconds : 0, + WaitLine = waitLine, + }; + } + + /// + /// Whether the command that ran was asked for JSON. Each command owns its own --json, + /// so the answer is looked up on the command that was actually matched, never globally. + /// + private static bool JsonRequested(ParseResult result, CommandResult matched) => + matched.Command.Options.Any(option => + // A bool option gets a result even when nobody typed it, and carries no tokens even + // when somebody did: only Implicit tells the two apart. + option.Name == "--json" && result.GetResult(option) is { Implicit: false }); + + /// + /// One protocol line from a command's arguments, in declaration order: the wire protocol is a + /// line, so the join is by the value that was actually supplied, formatted invariantly. + /// + private static string Join(ParseResult result, CommandResult matched) + { + var parts = new List(); + foreach (Argument argument in matched.Command.Arguments) + { + // The argument's declared type is the protocol slot's type, so the formatting is + // decided once, here, and stays invariant whatever the machine's culture is. + string? text = argument switch + { + Argument many => Join(result.GetValue(many)), + Argument number => result.GetValue(number)?.ToString(CultureInfo.InvariantCulture), + Argument number => result.GetValue(number)?.ToString("R", CultureInfo.InvariantCulture), + Argument number => result.GetValue(number)?.ToString("R", CultureInfo.InvariantCulture), + Argument one => result.GetValue(one), + _ => null, + }; + + if (!string.IsNullOrEmpty(text)) parts.Add(text); + } + + return string.Join(' ', parts); + + static string Join(string[]? words) => words is null ? "" : string.Join(' ', words); + } +} diff --git a/tools/verify-template.ps1 b/tools/verify-template.ps1 index 5df5d1d..878226f 100644 --- a/tools/verify-template.ps1 +++ b/tools/verify-template.ps1 @@ -213,12 +213,16 @@ internal static class AnalyzerProbe throw "the installed tool must ship a $shimName command; found: $((Get-ChildItem $toolPath).Name -join ', ')" } $shimHelp = (& $shim --help | Out-String) - if ($LASTEXITCODE -ne 0 -or $shimHelp -notmatch 'dotnet cui ') { throw "the installed command did not print its usage: $shimHelp" } + # The usage screen is generated from the grammar, so the check is the shape it always has: + # a usage line and the verbs, not a sentence somebody remembered to keep in step. + if ($LASTEXITCODE -ne 0 -or $shimHelp -notmatch 'Usage:' -or $shimHelp -notmatch 'docs') { + throw "the installed command did not print its usage: $shimHelp" + } $savedPath = $env:PATH try { $env:PATH = "$toolPath$([System.IO.Path]::PathSeparator)$savedPath" $dispatchHelp = (& dotnet cui --help | Out-String) - if ($LASTEXITCODE -ne 0 -or $dispatchHelp -notmatch 'dotnet cui ') { + if ($LASTEXITCODE -ne 0 -or $dispatchHelp -notmatch 'Usage:' -or $dispatchHelp -notmatch 'docs') { throw "dotnet cui did not dispatch to the installed tool: $dispatchHelp" } From 6da080cd837cda46c507bb57705849c3886b8b53 Mon Sep 17 00:00:00 2001 From: CuiSharp Contributor Date: Thu, 12 Nov 2026 10:11:05 +0800 Subject: [PATCH 3/3] Give every option an owner, and make the docs a test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review of the declared grammar found six things the green CI could not see, because it tested the grammar's acceptance rather than its honesty. Options were inherited from the root, so every command accepted every option. `docs --attach`, `api ChartOpts --stdin` and `ps --wait ready` all parsed and were then ignored: the help advertised them, the parser took them, and nothing used them. They now live on the commands that can honour them, which also makes the offline promise structural — `docs`, `api` and `icons` declare no way to name a project, so no spelling of the line can make them resolve one. The price is that options follow the verb (`dotnet cui tree --frames 2`), and a reader who puts them first is told exactly that instead of being told the option does not exist. Contradictions are refused rather than resolved: --project, --app and --attach are three answers to one question, --no-build needs something to build, --framework picks among a project's frameworks, and a NaN coordinate is not a place to point at. `--stdin` became `dotnet cui session`. As a flag it could be written beside a verb, and the verb was then silently dropped; as a command the line says which of the two things is happening. `dwell` and `waittext` require their duration. Bare `dwell` started an app and then sent it a command it does not have; the protocol always needed the seconds. A failed launch printed an unhandled Win32Exception out of Process.Start. It now exits 1 with one sentence. `tree`'s description promised a `print` alias that did not exist; it exists now, and the help for `key` names keys the parser accepts rather than `Ctrl+S`, which it never did. `--json=false` answers with text: a bool option's result is trusted for its value, not for the fact that it is present. Finally the docs are a test. `EveryCommandLineTheDocsShowIsOneTheToolAccepts` walks the `dotnet cui` lines in README, SKILL.md, llms.txt and the template, and would have caught what the review found by hand: `click Button "Save type"` (two arguments where a query is one) and `click editor > Save` (a path separator the shell reads as redirection), both printed to readers for weeks and neither parseable. The docs grammar is dispatched on instead of re-parsed: `docs.read` and `docs.search` are commands, so the tool no longer joins a string back together to decide what it was asked. The `docs ` shorthand is gone with it — an argument on a command that has subcommands is where that string round-trip came from — and the dropped verb is answered with `dotnet cui docs read testing` instead. --- CHANGELOG.md | 34 ++ README.md | 19 +- docs/ARCHITECTURE.md | 15 +- skills/cui-dotnet/SKILL.md | 8 +- skills/cui-dotnet/llms.txt | 2 +- src/CuiSharp.Tests/ApiCatalogCliTests.cs | 55 ++- src/CuiSharp.Tests/DocsCommandTests.cs | 40 +- src/CuiSharp.Tests/DocumentationDriftTests.cs | 100 +++++ src/CuiSharp.Tests/ToolArgumentTests.cs | 112 ++++-- src/CuiSharp.Tools/Cli.cs | 88 ++--- src/CuiSharp.Tools/Grammar.cs | 364 +++++++++++------- src/CuiSharp.Tools/HeadlessClient.cs | 14 +- tools/bindgen.cs | 2 +- tools/page-costs.ps1 | 2 +- tools/snapshots.ps1 | 2 +- 15 files changed, 559 insertions(+), 298 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f38360e..163e4f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,40 @@ Every user-visible change, newest first. Pre-1.0 the minor number carries new su carries fixes. The section for a tag is what the GitHub release publishes, so the two cannot say different things. +## [0.4.0] — unreleased + +`dotnet cui` answers offline and reads its own command line properly. + +### Added + +- **`dotnet cui docs`** prints the guide, the reference and the behaviour pages that ship *inside* + the tool: no repository, no network, nothing built and nothing started. `docs read ` + reads one of fourteen task topics or any page whole, `docs search ` ranks the sections that + answer a question and names the read that opens each one, and `--json` makes any of them + machine-readable. The topics are pointers into the shipped pages, so the tool cannot say anything + the documentation does not. +- **`dotnet cui session`** relays one protocol command per line from stdin against a single build, + replacing the `--stdin` flag. + +### Changed + +- **The command line is declared with System.CommandLine**, so `dotnet cui --help` and the + parser cannot disagree. What the old hand-written help promised and the parser ignored now works: + `-a ` takes its value, `-t/--target` is gone in its favour, `--version` answers with a + version, and a mistyped verb is answered with the verb that was meant (`clik` → ``Did you mean + `dotnet cui click`?``). Usage errors exit 2 with the reason on stderr; a failed launch exits 1 with + one sentence instead of a stack trace. +- **Options belong to the commands that honour them.** `docs`, `api`, `icons` and `ps` answer + offline and declare no `--project`, `--app`, `--attach`, `--no-build`, `--frames` or `--wait`, so + they cannot be given one that would be ignored. Options follow the command they belong to: + `dotnet cui tree --frames 2`, not `dotnet cui --frames 2 tree`. +- **A query is one quoted argument.** `dotnet cui click "Button Save"` is a click on the button + labelled *Save*; the unquoted `dotnet cui click Button Save` was two words and is now a usage + error rather than a different query. `dotnet cui click "editor > Save"` quotes the path separator + that the shell would otherwise read as redirection. +- **`dwell` and `waittext` require their duration**, which the protocol always needed: bare `dwell` + used to start an app and then fail with an unknown command. + ## [0.3.2] — 2026-11-11 The gallery now draws everything CUI ships, and a published binary carries only what it uses. diff --git a/README.md b/README.md index cb124db..471845b 100644 --- a/README.md +++ b/README.md @@ -218,14 +218,21 @@ Assert.True(app.PaintedAnything()); `dotnet cui` does the same thing to a real app process from a shell, with nothing added to the app: ```sh -dotnet cui tree # the controls of the last frame, nested -dotnet cui click Save # click a control, print the frame it produced -dotnet cui click Button "Save" # …when a heading says the same word -dotnet cui click editor > Save # …or name the path to the control -dotnet cui type Milk # type into the focused field -dotnet cui png shot.png # the frame as a PNG, for a person or a model +dotnet cui tree # the controls of the last frame, nested +dotnet cui click Save # click a control, print the frame it produced +dotnet cui click "Button Save" # …when a heading says the same word +dotnet cui click "editor > Save" # …or the path to the control +dotnet cui type Milk # type into the focused field +dotnet cui png shot.png # the frame as a PNG, for a person or a model +dotnet cui tree -a 15036 # the same, on a live window a person is watching +dotnet cui session < script.txt # many commands, one build, one app ``` +The query is **one quoted argument** — `click "Button Save"`, not `click Button Save` — because the +app parses that little grammar and this tool hands it over untouched. The tool's own options follow +the command they belong to (`dotnet cui tree --frames 2`, not `dotnet cui --frames 2 tree`), and +`dotnet cui --help` lists exactly the options that command honours. + ``` Frame Card "editor" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 97be515..1c7cc02 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -22,7 +22,7 @@ Settled. Reopening one is a design change, not a refactor. | D9 | **Window behaviour the framework needs is a hook over CUI's own window procedure, not a native patch.** A drag on Windows makes Win32 send `WM_SIZE` per mouse sample and then a `WM_PAINT` that invalidates the whole client area, and CUI draws in both, inside the modal loop, with vsync on: one mouse step cost two frames and two display waits. `Hosting/LiveResize` subclasses the window procedure (the one place Windows lets a framework intervene), lets the coalesced paint draw once, and stops waiting for the display for the length of the drag — driving CUI's own live-frame path rather than replacing it. The same hook is where the window handle is recovered for `CuiWindow`, because the app loop keeps its `cui_app*` private and the window's user data holds it. A fix that needed `native/CUI/` would be upstream's, per D2. | | D10 | **A published binary is a product, and the package decides what goes in it.** `build/CuiSharp.Size.targets` applies the switches that decide size (`CuiSharpSizeProfile`, `CuiSharpHeadless`, `CuiSharpVerboseErrors`) to a self-contained AOT publish and to nothing else, because that is the only build whose size matters: `dotnet run` keeps the headless channel and the framework's exception messages. `PublishAot` alone is not the signal — the file-based app shape sets it for `dotnet run` too. Measured in [SIZE.md](SIZE.md), which is what a size decision has to cite. | | D11 | **The tool carries the documentation, and a topic names sections instead of repeating them.** `dotnet cui docs` answers from `skills/cui-dotnet/**` embedded in `CuiSharp.Tools`, so an assistant that has installed one tool can read the guide in a directory with no repository, no network and no app — the reason a project's instruction file can say `dotnet cui docs` instead of restating the framework's rules. A *topic* (`testing`, `debugging`, `publishing`) is a named list of `doc#section` pointers resolved at load, so every sentence has exactly one home and a renamed heading fails a test rather than leaving a topic that reads but misleads. The bundle is embedded, not copied, and a test compares the bytes with the checked-in files. | -| D12 | **The command line is declared, not parsed by hand.** `Grammar` builds a System.CommandLine tree: every verb is a `Command` whose arguments carry their own type and arity, and every option is one `Option`. The help, the arity, the value types and the error messages then come from a single declaration, which is what stops them disagreeing — the hand-rolled parser had already shipped a help line promising `-a, --attach [id]` while `-a` took no value, so `dotnet cui click "中文" -a 15036` searched for a widget called `中文 15036`. The verbs are still the app's wire protocol: `Grammar.Resolve` re-serialises one command's arguments into exactly one protocol line, so the parser changed and the protocol did not. `dotnet cui` keeps that shape because an agent reads a grammar it can discover (`--help` per verb) more reliably than a man page it has to remember. | +| D12 | **The command line is declared, not parsed by hand, and every option lives on a command that can honour it.** `Grammar` builds a System.CommandLine tree: every verb is a `Command` whose arguments carry their own type and arity, and every option is an `Option` on the commands that use it. The help, the arity, the value types and the error messages then come from a single declaration, which is what stops them disagreeing — the hand-rolled parser had already shipped a help line promising `-a, --attach [id]` while `-a` took no value, so `dotnet cui click "中文" -a 15036` searched for a widget called `中文 15036`. Options are deliberately *not* inherited from the root: `docs`, `api` and `icons` answer offline and name no app, so `--project`, `--attach` and `--frames` do not exist there and cannot be silently ignored. The verbs are still the app's wire protocol: `Grammar.Resolve` re-serialises one command's arguments into exactly one protocol line, so the parser changed and the protocol did not. `dotnet cui` keeps that shape because an agent reads a grammar it can discover (`--help` per verb) more reliably than a man page it has to remember. | ## Layout @@ -271,10 +271,15 @@ resolved, so they answer in a directory that holds no app and start no process ( lets one installed tool serve both halves of an assistant's job: read the rules, then drive a build. The grammar itself is declared with System.CommandLine and the verbs are re-serialised into the -protocol lines above (D12), so `--help` — per verb, generated — is the manual. Two rules survive -from the hand-rolled parser because the protocol wants them: everything after a standalone `--` -is handed to the app untouched, and a value is a value even when it begins with a dash -(`--wait-text -loading`). +protocol lines above (D12), so `--help` — per verb, generated — is the manual, and it lists only +the options that verb honours. Three rules survive from the hand-rolled parser because the protocol +wants them: everything after a standalone `--` is handed to the app untouched, a value is a value +even when it begins with a dash (`--wait-text -loading`), and a query is one quoted argument +(`click "Button Save type"`), because the app parses the query grammar and this tool does not. + +A session is a command (`dotnet cui session < commands.txt`), not a flag. As a flag it could be +written beside a verb, and the verb would then be silently dropped; as a command the line says +which of the two things is happening. Live work runs on the owning window thread, between completed frames in the pinned Win32 backend's message pump. Callback nesting guards are thread/context-owned: an unrelated surface cannot change diff --git a/skills/cui-dotnet/SKILL.md b/skills/cui-dotnet/SKILL.md index f04f376..dca39e5 100644 --- a/skills/cui-dotnet/SKILL.md +++ b/skills/cui-dotnet/SKILL.md @@ -585,15 +585,15 @@ dotnet cui tree # the controls of the last frame, nested dotnet cui find Save # one control: kind, label, rectangle dotnet cui find id:draft # or by the id the app gave it, in any language dotnet cui click Save # click it, print the frame that reacted -dotnet cui click Button "Save type" # be specific when a label is ambiguous -dotnet cui click editor > Save # or name the path to the control +dotnet cui click "Button Save type" # be specific when a label is ambiguous +dotnet cui click "editor > Save" # or the path to the control dotnet cui type Milk # type into the focused control dotnet cui key Tab # a key by name dotnet cui png frame.png # the frame as a PNG, to look at -dotnet cui --stdin < commands.txt # many commands against one build, in order +dotnet cui session < commands.txt # many commands against one build, in order ``` -`--stdin` is the cheapest way to explore an app you have not read: one build, one startup, a page of +`session` is the cheapest way to explore an app you have not read: one build, one startup, a page of answers. State carries across the commands because it is one process — but a run leaves nothing behind, so anything that should be checked twice belongs in a test instead. diff --git a/skills/cui-dotnet/llms.txt b/skills/cui-dotnet/llms.txt index 7d79fd2..bd094d8 100644 --- a/skills/cui-dotnet/llms.txt +++ b/skills/cui-dotnet/llms.txt @@ -30,7 +30,7 @@ - `HeadlessApp` — the same driver in the framework, which `TestApp` names; `dotnet cui` drives it in a real process. - A tab strip and a segmented control record one control per item (`Tab "Markets"`, `Segment "Dark"`), so an item is found and clicked like any other control. - `surface.LastFrame` -> `FrameSample` — what the last frame that surface drew cost: `DrawMs`, `AllocatedBytes`, `FrameMs`, `Fps`, `IsLive`. `FrameStats` holds the same for the one window in the process, which is what a footer reads. -- `dotnet cui tree|find|click|type|key|focus|move|frame|faults|png` — one command per line, `--stdin` for a session, `--app` for a published build, `--no-build`, `--frames N`. Any app opts in by running headless (`CUISHARP_HEADLESS=1` or `--headless`). +- `dotnet cui tree|find|click|type|key|focus|move|frame|faults|png` — one command per line; `dotnet cui session` relays a whole script against one build; `--app` for a published build, `--no-build`, `--frames N`. Any app opts in by running headless (`CUISHARP_HEADLESS=1` or `--headless`). ## Core API (hand-written, in `src/CuiSharp/Ui`) diff --git a/src/CuiSharp.Tests/ApiCatalogCliTests.cs b/src/CuiSharp.Tests/ApiCatalogCliTests.cs index 0eb1b8e..b49173f 100644 --- a/src/CuiSharp.Tests/ApiCatalogCliTests.cs +++ b/src/CuiSharp.Tests/ApiCatalogCliTests.cs @@ -16,21 +16,38 @@ namespace CuiSharp.Tests; /// public class ApiCatalogCliTests { - /// A path with no project and no app, for the proof described above. + /// A path with no project and no app, for the proofs that need a real one. private static readonly string NoProjectHere = Path.Combine(Path.GetTempPath(), "cui-api-offline-" + Guid.NewGuid()); private static (int Exit, string Out, string Err) Run(params string[] args) { var output = new StringWriter(); var error = new StringWriter(); - int exit = Cli.Run([.. args, "--project", NoProjectHere], output, error); + int exit = Cli.Run(args, output, error); return (exit, output.ToString(), error.ToString()); } + [Fact] + public void TheCatalogCommandsCannotBePointedAtAnApp() + { + // What was a promise in a comment is now the grammar: `api` and `icons` declare no way to + // name a project, a published build or a live window, so no spelling of the line can make + // the catalog resolve one. + foreach (string argument in new[] { "--project", "--app", "--attach", "--no-build" }) + { + (int exit, _, string error) = Run("api", "ChartOpts", argument); + + Assert.Equal(2, exit); + Assert.Contains($"'{argument}'", error, StringComparison.Ordinal); + } + } + [Fact] public void AnExactOptionTypeAnswersWithItsFieldDeclarationsWithoutAnyProject() { - (int exit, string output, _) = Run("api", "ChartOpts", "-q"); + // Asked for as JSON-free text, with the only output switch a catalog command declares: + // `-q` and `--frames` are app-command options now, and `api` refuses them outright. + (int exit, string output, _) = Run("api", "ChartOpts", "--json=false"); Assert.Equal(0, exit); Assert.Contains("ChartOpts [struct]", output, StringComparison.Ordinal); @@ -211,24 +228,20 @@ public void AnOptionWhoseValueIsNotItsTypeIsReportedInsteadOfSwallowed() // `--frames --json` used to lose its number and eat the flag behind it, answering a // different command than the one that was typed and calling that success. A typed // option now says what it wanted: a count, not `--json`. - (int exit, _, string error) = Run("api", "ChartOpts", "--frames", "--json"); + (int exit, _, string error) = Run("tree", "--frames", "--json"); Assert.Equal(2, exit); Assert.Contains("'--frames'", error, StringComparison.Ordinal); Assert.Contains("whole number", error, StringComparison.Ordinal); - (exit, _, error) = Run("api", "ChartOpts", "--wait-timeout", "--pgae"); + (exit, _, error) = Run("tree", "--wait-timeout", "--pgae"); Assert.Equal(2, exit); Assert.Contains("'--wait-timeout'", error, StringComparison.Ordinal); Assert.Contains("a number", error, StringComparison.Ordinal); - // A value missing at the end of the line is the same mistake. No --project is needed - // here: the catalog path never looks at a project, so the parse error is all there is. - var answers = new StringWriter(); - var errors = new StringWriter(); - exit = Cli.Run(["api", "ChartOpts", "--app"], answers, errors); + // A value missing at the end of the line is the same mistake. + (exit, _, error) = Run("tree", "--app"); Assert.Equal(2, exit); - Assert.Contains("'--app'", errors.ToString(), StringComparison.Ordinal); - Assert.Equal("", answers.ToString()); + Assert.Contains("'--app'", error, StringComparison.Ordinal); } [Fact] @@ -241,7 +254,7 @@ public void ANonCatalogCommandKeepsItsOwnGrammarEvenWithAwkwardOptionValues() Assert.Equal("tree", parsed.Options!.Command); Assert.Equal("waittext 10 -loading", parsed.Options.WaitLine); - (int exit, _, string error) = Run("tree", "--wait-text", "-loading"); + (int exit, _, string error) = Run("tree", "--project", NoProjectHere, "--wait-text", "-loading"); Assert.Equal(1, exit); Assert.DoesNotContain("needs a value", error, StringComparison.Ordinal); Assert.Contains(NoProjectHere, error, StringComparison.Ordinal); @@ -260,13 +273,13 @@ public void ACatalogCommandReportsMalformedOptionsThroughTheUnifiedParser() // that rule — so what is refused is a value the option's declared type cannot hold. var output = new StringWriter(); var errors = new StringWriter(); - int exit = Cli.Run(["api", "ChartOpts", "--wait-timeout", "abc"], output, errors); + int exit = Cli.Run(["tree", "--wait-timeout", "abc"], output, errors); Assert.Equal(2, exit); Assert.Contains("'--wait-timeout'", errors.ToString(), StringComparison.Ordinal); Assert.Contains("abc", errors.ToString(), StringComparison.Ordinal); Assert.Equal("", output.ToString()); - (int badExit, _, string badError) = Run("api", "ChartOpts", "--frames", "many"); + (int badExit, _, string badError) = Run("tree", "--frames", "many"); Assert.Equal(2, badExit); Assert.Contains("whole number", badError, StringComparison.Ordinal); } @@ -279,27 +292,29 @@ public void ARepeatedOptionIsRefusedRatherThanSilentlyOverwritten() var output = new StringWriter(); var errors = new StringWriter(); - Assert.Equal(2, Cli.Run(["api", "ChartOpts", "--wait", "ready", "--wait", "ready"], output, errors)); - Assert.Contains("'--wait'", errors.ToString(), StringComparison.Ordinal); + Assert.Equal(2, Cli.Run(["tree", "--frames", "2", "--frames", "3"], output, errors)); + Assert.Contains("'--frames'", errors.ToString(), StringComparison.Ordinal); Assert.Equal("", output.ToString()); // A wrong value for a one-value option is the same class of mistake, said differently. output = new StringWriter(); errors = new StringWriter(); - Assert.Equal(2, Cli.Run(["api", "ChartOpts", "--wait", "invalid"], output, errors)); + Assert.Equal(2, Cli.Run(["tree", "--wait", "invalid"], output, errors)); Assert.Contains("'ready'", errors.ToString(), StringComparison.Ordinal); Assert.Equal("", output.ToString()); } [Fact] - public void JsonAndIgnoredAppFlagsAnswerTheSameOffline() + public void JsonAndTextAnswerTheSameQuestionsOffline() { (int exit, string output, _) = Run("api", "ChartOpts", "--json"); Assert.Equal(0, exit); using (JsonDocument json = JsonDocument.Parse(output)) Assert.Equal("exact", json.RootElement.GetProperty("match").GetString()); - (exit, output, _) = Run("api", "ChartOpts", "--app", "nowhere.exe"); + // The same name in text, from the same call, with nothing built or started: an app flag + // is not accepted at all now, so there is no "ignored" path left to be identical to. + (exit, output, _) = Run("api", "ChartOpts"); Assert.Equal(0, exit); Assert.Contains("ChartOpts [struct]", output, StringComparison.Ordinal); } diff --git a/src/CuiSharp.Tests/DocsCommandTests.cs b/src/CuiSharp.Tests/DocsCommandTests.cs index 38e721e..9d18df1 100644 --- a/src/CuiSharp.Tests/DocsCommandTests.cs +++ b/src/CuiSharp.Tests/DocsCommandTests.cs @@ -6,23 +6,34 @@ namespace CuiSharp.Tests; /// /// dotnet cui docs: the guide, the reference and the behaviour pages answered from bytes -/// inside the tool. Like the catalog commands, every call here passes --project pointing at -/// a directory that does not exist — an ordinary command would fail there with "No app found", so -/// an answer with exit 0 is itself the proof that reading the documentation discovered no project, -/// built nothing, started no process and opened no socket. +/// inside the tool. Offline by construction rather than by promise — these commands declare no +/// --project, --app or --attach, so a run of one cannot reach a build, a +/// process or a socket even by accident (see ). /// public class DocsCommandTests { - private static readonly string NoProjectHere = Path.Combine(Path.GetTempPath(), "cui-docs-offline-" + Guid.NewGuid()); - private static (int Exit, string Out, string Err) Run(params string[] args) { var output = new StringWriter(); var error = new StringWriter(); - int exit = Cli.Run([.. args, "--project", NoProjectHere], output, error); + int exit = Cli.Run(args, output, error); return (exit, output.ToString(), error.ToString()); } + [Fact] + public void TheDocsCommandsCannotBePointedAtAnApp() + { + // The strongest form of the offline promise: there is no option here that could name a + // project, so the documentation cannot resolve one, however the line is spelled. + foreach (string argument in new[] { "--project", "--app", "--attach", "--frames" }) + { + (int exit, _, string error) = Run("docs", argument); + + Assert.Equal(2, exit); + Assert.Contains($"'{argument}'", error, StringComparison.Ordinal); + } + } + [Fact] public void TheIndexNamesTheTopicsAndTheDocumentsAndStartsNoAppToDoIt() { @@ -211,7 +222,7 @@ public void TheDebuggingTopicCarriesTheWorkflowItIsPointedAtFor() Assert.Contains("SKILL.md § How to work on a UI", debugging, StringComparison.Ordinal); Assert.Contains("SKILL.md § Driving a running app", debugging, StringComparison.Ordinal); // Headless one-shots, one process for a script, and a live window a person is watching. - Assert.Contains("--stdin", debugging, StringComparison.Ordinal); + Assert.Contains("dotnet cui session", debugging, StringComparison.Ordinal); Assert.Contains("-a ", debugging, StringComparison.Ordinal); Assert.Contains("one process", debugging, StringComparison.Ordinal); // And the rule that keeps a debugging session from being the only evidence. @@ -272,17 +283,16 @@ public void ReadAndSearchWithoutTheirArgumentAreUsageErrors() } /// - /// An assistant that drops the verb still gets the page: `docs debugging` is the same request - /// as `docs read debugging`, and correcting the grammar instead would be a wasted turn. + /// A reader who drops the verb gets a sentence naming the call that works, not a glossary of + /// what `docs` accepts: the page is one word away, and a correction is cheaper than a search. /// [Fact] - public void TheVerbMayBeDroppedEntirely() + public void ADroppedVerbIsAnsweredWithTheReadThatWouldWork() { - (int exit, string output, _) = Run("docs", "testing"); + (int exit, _, string error) = Run("docs", "testing"); - Assert.Equal(0, exit); - Assert.StartsWith("Topic: testing", output, StringComparison.Ordinal); - Assert.Contains("TestApp.Start", output, StringComparison.Ordinal); + Assert.Equal(2, exit); + Assert.Contains("dotnet cui docs read testing", error, StringComparison.Ordinal); } [Fact] diff --git a/src/CuiSharp.Tests/DocumentationDriftTests.cs b/src/CuiSharp.Tests/DocumentationDriftTests.cs index def5e8a..cc7418b 100644 --- a/src/CuiSharp.Tests/DocumentationDriftTests.cs +++ b/src/CuiSharp.Tests/DocumentationDriftTests.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using CuiSharp.Tools; namespace CuiSharp.Tests; @@ -34,6 +35,105 @@ public void EveryCallInTheGuideExistsInTheReference() "SKILL.md calls API that llms.txt does not list: " + string.Join(", ", missing)); } + /// + /// Every command line the docs hand a reader has to be a line the tool accepts. This is the + /// test that was missing when the grammar was declared: the guide still said + /// dotnet cui click Button "Save type" and dotnet cui click editor > Save, and + /// both had stopped parsing — the first because a label is one quoted argument, the second + /// because an unquoted > is the shell's redirection, not the query's path separator. + /// A guide that prints a line the tool rejects turns a one-turn fix into a debugging session. + /// + [Fact] + public void EveryCommandLineTheDocsShowIsOneTheToolAccepts() + { + string[] sources = + [ + Find("README.md"), + Find(Path.Combine("skills", "cui-dotnet", "SKILL.md")), + Find(Path.Combine("skills", "cui-dotnet", "llms.txt")), + Find(Path.Combine("src", "CuiSharp.Templates", "CuiSharp.App", "AGENTS.md")), + Find(Path.Combine("src", "CuiSharp.Templates", "CuiSharp.App", "README.md")), + ]; + + var rejected = new List(); + foreach (string source in sources) + { + foreach (string raw in File.ReadAllLines(source)) + { + // A trailing comment is prose for the reader, not part of the command line, and a + // line has to *be* a command line: one that starts with the command, not a + // sentence that mentions it in passing. + string line = Regex.Replace(raw, @"\s+#.*$", "").Trim(); + if (!line.StartsWith("dotnet cui ", StringComparison.Ordinal)) continue; + if (CountDotnetCui(line) != 1) continue; + + // Placeholders (``, `tree|find`), elisions and pipes are not command lines. + if (line.Contains('<', StringComparison.Ordinal) || line.Contains('|', StringComparison.Ordinal)) continue; + if (line.Contains('…', StringComparison.Ordinal) || line.Contains("...", StringComparison.Ordinal)) continue; + + string[] argv = Split(line[(line.IndexOf("dotnet cui", StringComparison.Ordinal) + "dotnet cui".Length)..]); + Grammar.Parsed parsed = Grammar.Parse(argv); + if (parsed.Errors.Count > 0) + rejected.Add($"{Path.GetFileName(source)}: `{line}` -> {string.Join("; ", parsed.Errors)}"); + } + } + + Assert.True( + rejected.Count == 0, + "the docs show command lines the tool rejects:" + Environment.NewLine + " " + + string.Join(Environment.NewLine + " ", rejected)); + } + + private static int CountDotnetCui(string line) + { + int count = 0; + for (int at = line.IndexOf("dotnet cui", StringComparison.Ordinal); at >= 0; + at = line.IndexOf("dotnet cui", at + 1, StringComparison.Ordinal)) + { + count++; + at += "dotnet cui".Length - 1; + } + + return count; + } + + /// Words the way a shell hands them over: a double-quoted word stays one word. + private static string[] Split(string text) + { + var words = new List(); + var current = new System.Text.StringBuilder(); + bool quoted = false; + + foreach (char c in text) + { + if (c == '"') + { + quoted = !quoted; + current.Append(c); + continue; + } + + if (!quoted && char.IsWhiteSpace(c)) + { + if (current.Length > 0) words.Add(current.ToString()); + current.Clear(); + continue; + } + + current.Append(c); + } + + if (current.Length > 0) words.Add(current.ToString()); + return [.. words.Select(word => word.Trim('"')).Where(word => word.Length > 0)]; + } + + private static string Find(string relative) + { + string path = Path.Combine(TestPaths.RepositoryRoot, relative); + Assert.True(File.Exists(path), $"{path} is missing"); + return path; + } + /// The reference is generated, so it must at least mention every option type. [Fact] public void EveryOptionTypeInTheReferenceHasAFieldsPage() diff --git a/src/CuiSharp.Tests/ToolArgumentTests.cs b/src/CuiSharp.Tests/ToolArgumentTests.cs index 67cbfbe..dd52449 100644 --- a/src/CuiSharp.Tests/ToolArgumentTests.cs +++ b/src/CuiSharp.Tests/ToolArgumentTests.cs @@ -76,10 +76,25 @@ public void AFlagTheToolDoesNotKnowIsReportedRatherThanForwarded() public void NoCommandIsHelp() { Assert.True(Grammar.Parse([]).Help); - Assert.True(Grammar.Parse(["--frames", "2"]).Help); - // Streaming supplies its commands on stdin, so it needs no command word. - Assert.False(Grammar.Parse(["--stdin"]).Help); - Assert.True(Grammar.Parse(["--stdin"]).Options!.Streaming); + Assert.True(Grammar.Parse([]).NothingAsked); + + // A session supplies its own commands on stdin, so it needs no verb of its own. + Grammar.Parsed session = Grammar.Parse(["session", "--app", "x.exe"]); + Assert.Empty(session.Errors); + Assert.Equal("session", session.Options!.Command); + Assert.False(session.Help); + } + + [Fact] + public void TheRootTakesNoOptionsSoABareLineCannotMeanSomethingElse() + { + // `dotnet cui --wait ready` used to invent a `tree`. With the options on the commands + // that honour them, the line is a usage error with the ordering to fix it — which is + // cheaper to read than an answer for a command nobody typed. + Grammar.Parsed parsed = Grammar.Parse(["--wait", "ready"]); + + Assert.NotEmpty(parsed.Errors); + Assert.Contains("belongs after the command", string.Join(" ", parsed.Errors), StringComparison.Ordinal); } [Fact] @@ -136,42 +151,49 @@ public void EveryCommandAndOptionTheHelpListsCanBeTyped() // The help is generated from this grammar, so this walks exactly what a reader is shown: // every command with its required arguments, and every option in both its spellings. An // option the help advertises as taking a value but the parser ignores fails here. - foreach (CliCommand command in Grammar.Root.Subcommands) + // Every command, by its own name and by each alias: the help names both, so both work. + foreach (string name in Grammar.Root.Subcommands.SelectMany(c => c.Aliases.Prepend(c.Name))) { - var argv = new List { command.Name }; + CliCommand command = Grammar.Root.Subcommands.First(c => c.Name == name || c.Aliases.Contains(name)); + var argv = new List { name }; // Every declared slot, optional ones included: the point is the position, and a // value in an optional slot is still a legal command line. foreach (CliArgument argument in command.Arguments) argv.Add(SampleArgument(argument)); - // A timeout without a wait to bound is a different mistake, tested on its own. - if (command.Name is not "tree") argv.AddRange([]); Grammar.Parsed bare = Grammar.Parse([.. argv]); Assert.True(bare.Errors.Count == 0, $"`{string.Join(' ', argv)}` did not parse: {string.Join("; ", bare.Errors)}"); } - // The help and version switches are the parser's own, and root-only: `tree --version` - // is not a command line anyone means. - foreach (CliOption option in Grammar.Root.Options.Where(o => o is not CliHelpOption and not VersionOption)) + // Every option every command declares, in both spellings, on the command that declares + // it: an option the help advertises as taking a value but the parser ignores fails here, + // and so does an option the help lists on a command that cannot honour it. + foreach (CliCommand command in Grammar.Root.Subcommands) { - foreach (string spelling in new[] { option.Name }.Concat(option.Aliases)) + foreach (CliOption option in command.Options.Where(o => o is not CliHelpOption and not VersionOption)) { - var argv = new List { "tree", spelling }; - if (option.ValueType != typeof(bool)) argv.Add(SampleOption(option)); - // `--wait-timeout` bounds a wait, so it is only legal beside one. - if (spelling is "--wait-timeout") argv.InsertRange(1, ["--wait", "ready"]); - - Grammar.Parsed parsed = Grammar.Parse([.. argv]); - Assert.True(parsed.Errors.Count == 0, $"`{spelling}` did not parse: {string.Join("; ", parsed.Errors)}"); + foreach (string spelling in new[] { option.Name }.Concat(option.Aliases)) + { + var argv = new List { command.Name }; + foreach (CliArgument argument in command.Arguments) argv.Add(SampleArgument(argument)); + argv.Add(spelling); + if (option.ValueType != typeof(bool)) argv.Add(SampleOption(option)); + // A timeout bounds a wait, so it is only legal beside one. + if (spelling is "--wait-timeout") argv.InsertRange(1, ["--wait", "ready"]); + + Grammar.Parsed parsed = Grammar.Parse([.. argv]); + Assert.True(parsed.Errors.Count == 0, $"`{spelling}` on `{command.Name}` did not parse: {string.Join("; ", parsed.Errors)}"); + } } } - } /// A value the argument's declared type can hold, so the test walks the grammar's /// shape without hard-coding which slot is a number today. private static string SampleArgument(CliArgument argument) => argument.ValueType switch { - Type t when t == typeof(int?) || t == typeof(float?) || t == typeof(double?) => "2", + Type t when t == typeof(int) || t == typeof(int?) + || t == typeof(float) || t == typeof(float?) + || t == typeof(double) || t == typeof(double?) => "2", _ => "x", }; @@ -243,9 +265,9 @@ public void AWaitIsAPreconditionAndNotTheCommand() } [Fact] - public void AWaitWithNoCommandWaitsForTheTreeItPrints() + public void AWaitIsWrittenAfterTheCommandItGates() { - Grammar.Parsed parsed = Grammar.Parse(["--wait", "ready", "--wait-timeout", "30"]); + Grammar.Parsed parsed = Grammar.Parse(["tree", "--wait", "ready", "--wait-timeout", "30"]); Assert.Empty(parsed.Errors); Assert.Equal("tree", parsed.Options!.Line); @@ -268,25 +290,35 @@ public void AWaitTextCarriesItsTimeoutAndItsTextUnchanged() } [Fact] - public void AWaitCoexistsWithFramesAndStdin() + public void AWaitCoexistsWithFramesInASession() { - Grammar.Parsed parsed = Grammar.Parse(["--frames", "3", "--wait", "ready", "--stdin"]); + Grammar.Parsed parsed = Grammar.Parse(["session", "--frames", "3", "--wait", "ready"]); Assert.Empty(parsed.Errors); - Assert.Equal(3, parsed.Options!.Frames); - Assert.True(parsed.Options.Streaming); - // In a session the gate passes before any stdin line is relayed, and no command word - // is invented for it — stdin carries the commands. + Assert.Equal("session", parsed.Options!.Command); + Assert.Equal(3, parsed.Options.Frames); + // The gate passes before any stdin line is relayed, and stdin carries the commands: no + // protocol line is invented for the session itself. Assert.Equal("wait 10", parsed.Options.WaitLine); - Assert.Equal("", parsed.Options.Line); + } + + [Fact] + public void ASessionCannotBeGivenACommandWordToIgnore() + { + // `tree --stdin` used to read stdin and drop the `tree`. The session is a command now, + // so the only way to reach it is to ask for it. + Grammar.Parsed parsed = Grammar.Parse(["tree", "--stdin"]); + + Assert.NotEmpty(parsed.Errors); + Assert.Contains("'--stdin'", string.Join(" ", parsed.Errors), StringComparison.Ordinal); } [Fact] public void AWaitOptionThatContradictsItselfIsRefused() { - Assert.NotEmpty(Grammar.Parse(["--wait", "busy"]).Errors); - Assert.NotEmpty(Grammar.Parse(["--wait", "ready", "--wait-text", "Save"]).Errors); - Assert.NotEmpty(Grammar.Parse(["--wait-timeout", "3"]).Errors); + Assert.NotEmpty(Grammar.Parse(["tree", "--wait", "busy"]).Errors); + Assert.NotEmpty(Grammar.Parse(["tree", "--wait", "ready", "--wait-text", "Save"]).Errors); + Assert.NotEmpty(Grammar.Parse(["tree", "--wait-timeout", "3"]).Errors); } [Theory] @@ -303,7 +335,7 @@ public void AWaitTimeoutThatCannotBoundAWaitIsRefusedNotThrown(string value) var output = new StringWriter(); var error = new StringWriter(); - Assert.Equal(2, Cli.Run(["--wait", "ready", "--wait-timeout", value, "tree"], output, error)); + Assert.Equal(2, Cli.Run(["tree", "--wait", "ready", "--wait-timeout", value], output, error)); Assert.Contains("wait-timeout", error.ToString(), StringComparison.Ordinal); } @@ -371,7 +403,7 @@ public void TheToolWaitsForReadinessBeforeItLetsTheCommandThrough() var output = new StringWriter(); var error = new StringWriter(); int exit = Cli.Run( - ["--project", project, "--wait", "ready", "--wait-timeout", "20", "png", png, "--", "--ready-after", "3"], + ["png", png, "--project", project, "--wait", "ready", "--wait-timeout", "20", "--", "--ready-after", "3"], output, error); @@ -385,7 +417,7 @@ public void TheToolWaitsForReadinessBeforeItLetsTheCommandThrough() // gate rather than re-entering MSBuild on the shared project reference. var missError = new StringWriter(); int miss = Cli.Run( - ["--project", project, "--no-build", "--wait", "ready", "--wait-timeout", "1", "png", late, "--", "--ready-after", "0"], + ["png", late, "--project", project, "--no-build", "--wait", "ready", "--wait-timeout", "1", "--", "--ready-after", "0"], new StringWriter(), missError); @@ -398,7 +430,7 @@ public void TheToolWaitsForReadinessBeforeItLetsTheCommandThrough() // the miss still exits 1 with the reason. var directError = new StringWriter(); int direct = Cli.Run( - ["--project", project, "--no-build", "wait", "0.1", "--", "--ready-after", "0"], + ["wait", "0.1", "--project", project, "--no-build", "--", "--ready-after", "0"], new StringWriter(), directError); Assert.Equal(1, direct); @@ -450,7 +482,7 @@ public void ATimeoutNamesTheDeadlineThatActuallyRanOut() public void ARelayedWaitBuysItsOwnDeadlineToo() { // The per-command deadline is not a one-shot-only rule. The same slow server sits - // behind a --stdin session: the relayed `wait 3` has to get the override its grammar + // behind a `session`: the relayed `wait 3` has to get the override its grammar // asks for, or the session dies of the client's default give-up while the app is // still answering honestly. using var client = HeadlessClient.Attach( @@ -570,14 +602,14 @@ public void TheFixtureCarriesItsNativePayloadIntoAnyWorkingDirectory() string png = Path.Combine(scratch, "ready.png"); (int exit, _, string errors) = RunFrom( scratch, tool, - ["--project", project, "--wait", "ready", "--wait-timeout", "20", "png", png, "--", "--ready-after", "3"]); + ["png", png, "--project", project, "--wait", "ready", "--wait-timeout", "20", "--", "--ready-after", "3"]); Assert.True(exit == 0, $"a ready-marking probe should pass its gate from an empty cwd: {errors}"); Assert.True(File.Exists(png), "the PNG is the proof the app ran on its own native payload"); string late = Path.Combine(scratch, "late.png"); (int miss, _, string missErrors) = RunFrom( scratch, tool, - ["--project", project, "--no-build", "--wait", "ready", "--wait-timeout", "1", "png", late, "--", "--ready-after", "0"]); + ["png", late, "--project", project, "--no-build", "--wait", "ready", "--wait-timeout", "1", "--", "--ready-after", "0"]); Assert.Equal(1, miss); Assert.Contains("ready", missErrors, StringComparison.Ordinal); Assert.False(File.Exists(late), "an unsatisfied gate must still stop the command from a foreign cwd"); diff --git a/src/CuiSharp.Tools/Cli.cs b/src/CuiSharp.Tools/Cli.cs index 2a6e7ba..9c675ae 100644 --- a/src/CuiSharp.Tools/Cli.cs +++ b/src/CuiSharp.Tools/Cli.cs @@ -60,11 +60,13 @@ internal static int Run(string[] args, TextWriter output, TextWriter error, Text { Options options = parsed.Options!; - if (options.Command is "api" or "icons") - return AnswerCatalog(options, output, error); + // The offline answers return before any project is resolved: they read resources in + // this assembly, so they work in an empty directory with nothing built or running. + if (options.Command is "docs" or "docs.read" or "docs.search") + return AnswerDocs(options, output); - if (options.Command == "docs") - return AnswerDocs(options, output, error); + if (options.Command is "api" or "icons") + return AnswerCatalog(options, output); if (options.Command == "ps") { @@ -78,10 +80,11 @@ internal static int Run(string[] args, TextWriter output, TextWriter error, Text if (!PassPreconditions(client, options, error)) return 1; - if (options.Streaming) return Relay(client, input, output, error); + if (options.Command == "session") return Relay(client, input, output, error); - if (TryReadDwell(options.Line, out double seconds)) - return Dwell(client, output, error, seconds) ? 0 : 1; + // `dwell` is the one command the tool answers itself: it keeps the app drawing and + // reports what that cost. + if (options.Command == "dwell") return Dwell(client, output, error, options.Seconds) ? 0 : 1; Answer answer = Ask(client, options.Line); if (!answer.Ok) @@ -116,51 +119,22 @@ internal static string Humanize(string message) => message .Replace("as expected type 'a number'", "as a number", StringComparison.Ordinal); /// - /// Answers a docs call: a topic, a document, a section, or a search. Like the catalog - /// commands this returns before any project is resolved, so it works in an empty directory. + /// Answers a docs call: the index, one topic or section, or a ranked search. Like the + /// catalog commands this returns before any project is resolved, so it works in an empty + /// directory with nothing built. /// - private static int AnswerDocs(Options options, TextWriter output, TextWriter error) + private static int AnswerDocs(Options options, TextWriter output) { - (CatalogResult? answer, string? usage) = DocsAnswer(BundledDocs.Value, options.Argument); - if (usage is not null) - { - error.WriteLine(usage); - return 2; - } - - output.WriteLine(options.Json ? answer!.RenderJson() : answer!.Render()); - return answer!.IsExact ? 0 : 1; - } - - /// - /// The docs grammar, separated from the writers so the grammar is testable on its own: - /// no argument is the index, search and read are the two verbs, and anything else - /// is the thing to read — an assistant that drops read gets the page, not a correction. - /// - internal static (CatalogResult? Answer, string? Usage) DocsAnswer(DocLibrary docs, string argument) - { - string line = argument.Trim(); - if (line.Length == 0) return (docs.List(), null); - - int space = line.IndexOfAny([' ', '\t']); - string verb = space < 0 ? line : line[..space]; - string rest = space < 0 ? "" : line[(space + 1)..].Trim(); - - if (verb.Equals("search", StringComparison.OrdinalIgnoreCase)) + DocLibrary docs = BundledDocs.Value; + CatalogResult answer = options.Command switch { - return rest.Length > 0 - ? (docs.Search(rest), null) - : (null, "`docs search` needs words to look for: `dotnet cui docs search dialog keyboard`."); - } - - if (verb.Equals("read", StringComparison.OrdinalIgnoreCase)) - { - return rest.Length > 0 - ? (docs.Read(rest), null) - : (null, "`docs read` needs a topic, a document or a section: `dotnet cui docs read debugging`."); - } + "docs.read" => docs.Read(options.Argument), + "docs.search" => docs.Search(options.Argument), + _ => docs.List(), + }; - return (docs.Read(line), null); + output.WriteLine(options.Json ? answer.RenderJson() : answer.Render()); + return answer.IsExact ? 0 : 1; } private static HeadlessClient StartHeadless(Options options, TextWriter error) @@ -177,16 +151,8 @@ private static HeadlessClient StartHeadless(Options options, TextWriter error) host, hostArgs, echo: options.Quiet ? null : error.WriteLine); } - private static int AnswerCatalog(Options options, TextWriter output, TextWriter error) + private static int AnswerCatalog(Options options, TextWriter output) { - if (options.Command == "api" && options.Argument.Length == 0) - { - error.WriteLine( - "`api` needs a name to look up: `dotnet cui api ChartOpts`, `dotnet cui api Ui.LineChart`, " - + "or `dotnet cui icons` for the glyph list."); - return 2; - } - ApiCatalog catalog = EmbeddedCatalog.Value; CatalogResult answer = options.Command == "icons" ? catalog.IconsResult(options.Argument.Length == 0 ? null : options.Argument) @@ -342,17 +308,12 @@ internal sealed record Options internal bool Quiet { get; init; } - /// True when no verb was given: either the help, or `--wait ready` alone. - internal bool NoCommand { get; init; } - internal bool Json { get; init; } internal bool Attach { get; init; } internal string? AttachTarget { get; init; } - internal bool Streaming { get; init; } - internal bool NoBuild { get; init; } internal string? Project { get; init; } @@ -365,6 +326,9 @@ internal sealed record Options internal string Line => Argument.Length == 0 ? Command : $"{Command} {Argument}"; + /// How long dwell should keep drawing. + internal double Seconds { get; init; } + internal double WaitSeconds { get; init; } internal string? WaitLine { get; init; } diff --git a/src/CuiSharp.Tools/Grammar.cs b/src/CuiSharp.Tools/Grammar.cs index 39d23c2..da0e587 100644 --- a/src/CuiSharp.Tools/Grammar.cs +++ b/src/CuiSharp.Tools/Grammar.cs @@ -15,80 +15,95 @@ namespace CuiSharp.Tools; /// click an arity, frame an , a mistyped verb a suggestion, and /// -a 15036 its value. /// +/// +/// Every option lives on the commands that can honour it. A window target, a draw count and a +/// readiness gate are meaningless to docs and api, which answer offline without +/// starting anything; leaving them on the root as inherited options would have put them in those +/// commands' help pages, where a reader would reasonably type them and be silently ignored. +/// /// internal static class Grammar { /// What to say after a usage error, so a reader (or an agent) has the next step. internal const string UsageHint = "Run `dotnet cui --help`, or `dotnet cui --help`."; - /// The verbs the app answers: name, description, and one argument per protocol slot. - private static readonly (string Name, string Description, Argument[] Arguments)[] Verbs = + /// The verbs the app answers: name, aliases, description, and one argument per slot. + private static readonly (string Name, string[] Aliases, string Description, Argument[] Arguments)[] Verbs = [ - ("tree", "the controls the last frame drew, nested (`print` is an alias)", []), - ("layout", "layout rectangles, observed union bounds and overflow diagnostics", []), - ("focus", "report that the window has focus (needed before typing)", []), - ("faults", "what the app threw while drawing, if anything", []), - ("quit", "close a live attached window", []), - - ("find", "one control: kind, label and rectangle", [Text("text", "the label, text or id to find")]), - ("click", "click a control, then print the frame it produced", [Text("text", "the label, text or id to click")]), - ("type", "type into the focused control, then print the frame", [Text("text", "the text to type")]), - ("key", "press a key, e.g. `key Tab`", [Text("name", "the key: Tab, Enter, F1, Ctrl+S")]), - - ("move", "put the pointer somewhere without clicking (hover, tooltips)", - [Number("x", required: true), Number("y", required: true)]), - ("wheel", "scroll at a point, in wheel notches (negative dy scrolls down)", - [Number("x", required: true), Number("y", required: true), Number("dy", required: true), Number("dx", required: false)]), - - ("frame", "draw more frames, then print the tree", [Count("how many frames (default 1)")]), - ("pulse", "draw frames without printing the tree (answers: count, heap bytes)", [Count("how many frames (default 1)")]), - ("wait", "keep drawing until a frame marks itself ready (ui.MarkReady)", [Seconds("how long to keep waiting (default: the app's own budget)")]), - ("waittext", "keep drawing until a frame draws this text or query", - [Seconds("how long to keep waiting"), Text("query", "the text or query to wait for")]), - - ("png", "save the last frame as a PNG", [OptionalText("path", "where to write it (default: a temporary file)")]), - ("dwell", "keep the app drawing, then answer with one STATS line", [Seconds("how long to draw for")]), + ("tree", ["print"], "the controls the last frame drew, nested", []), + ("layout", [], "layout rectangles, observed union bounds and overflow diagnostics", []), + ("focus", [], "report that the window has focus (needed before typing)", []), + ("faults", [], "what the app threw while drawing, if anything", []), + ("quit", [], "close the app, or a live attached window", []), + + ("find", [], "one control: kind, label and rectangle", [Text("text", "the label, text or id to find")]), + ("click", [], "click a control, then print the frame it produced", [Text("text", "the label, text or id to click")]), + ("type", [], "type into the focused control, then print the frame", [Text("text", "the text to type")]), + ("key", [], "press a key by the name a person would use", [Text("name", "the key: Tab, Enter, Escape, F1, PageDown")]), + + ("move", [], "put the pointer somewhere without clicking (hover, tooltips)", + [Number("x", true, "the column to point at, from the window's left edge"), + Number("y", true, "the row to point at, from the window's top edge")]), + ("wheel", [], "scroll at a point, in wheel notches (negative dy scrolls down)", + [Number("x", true, "the column to scroll at"), + Number("y", true, "the row to scroll at"), + Number("dy", true, "notches up (negative) or down (positive)"), + Number("dx", false, "notches left (negative) or right (positive)")]), + + ("frame", [], "draw more frames, then print the tree", [Count("how many frames (default 1)")]), + ("pulse", [], "draw frames without printing the tree (answers: count, heap bytes)", [Count("how many frames (default 1)")]), + ("wait", [], "keep drawing until a frame marks itself ready (ui.MarkReady)", + [OptionalSeconds("how long to keep waiting (default: the app's own budget)")]), + ("waittext", [], "keep drawing until a frame draws this text or query", + [RequiredSeconds("how long to keep waiting"), Text("query", "the text or query to wait for")]), + + ("png", [], "save the last frame as a PNG", [OptionalText("path", "where to write it (default: a temporary file)")]), + ("dwell", [], "keep the app drawing, then answer with one STATS line", [RequiredSeconds("how long to draw for")]), ]; /// Every symbol the resolver needs, in one place. private sealed class Symbols { + // ---- what to drive ------------------------------------------------------------------ + internal Option Project = new("--project", "-p") { Description = "the app to drive: a directory, a .csproj, or a file-based .cs app (default: here)", HelpName = "path", - Recursive = true, }; internal Option App = new("--app") { - Description = "drive a published build instead of building", + Description = "drive a published build instead of building one", HelpName = "exe", - Recursive = true, }; internal Option Framework = new("--framework", "-f") { Description = "the target framework, when the project has more than one", HelpName = "tfm", - Recursive = true, }; - internal Option Frames = new("--frames") + internal Option NoBuild = new("--no-build") { - Description = "draw this many frames before the command runs", - HelpName = "count", - Arity = ArgumentArity.ExactlyOne, - Recursive = true, + Description = "reuse the last build of the project instead of rebuilding it", }; internal Option Attach = new("--attach", "-a") { - Description = "drive a running live window (started with --live) over its named pipe, " - + "instead of starting a new headless process; the value is its pid or title", + Description = "drive a running live window (started with --live) over its named pipe; " + + "the value is its pid or title, or leave it out when only one window is open", HelpName = "pid|title", Arity = ArgumentArity.ZeroOrOne, - Recursive = true, + }; + + // ---- what has to be true before the command runs ------------------------------------ + + internal Option Frames = new("--frames") + { + Description = "draw this many frames before the command runs", + HelpName = "count", + Arity = ArgumentArity.ExactlyOne, }; internal Option Wait = new("--wait") @@ -96,39 +111,25 @@ private sealed class Symbols Description = "precondition: draw until a frame marks itself ready (ui.MarkReady)", HelpName = "ready", Arity = ArgumentArity.ExactlyOne, - Recursive = true, }; internal Option WaitText = new("--wait-text") { Description = "precondition: draw until a frame draws this text or query", HelpName = "text", - Recursive = true, }; internal Option WaitTimeout = new("--wait-timeout") { Description = "the window for --wait and --wait-text, in seconds (default 10, max 3600)", HelpName = "seconds", - Recursive = true, }; + // ---- output ------------------------------------------------------------------------- + internal Option Quiet = new("--quiet", "-q") { Description = "print only the payload, never the tool's own progress", - Recursive = true, - }; - - internal Option Stdin = new("--stdin", "-s") - { - Description = "read one protocol command per line from stdin, with no command word", - Recursive = true, - }; - - internal Option NoBuild = new("--no-build") - { - Description = "reuse the last build instead of rebuilding", - Recursive = true, }; /// @@ -137,18 +138,13 @@ private sealed class Symbols /// internal Option Json() => new("--json") { Description = "answer as JSON instead of text" }; + // ---- offline arguments -------------------------------------------------------------- + internal Argument ApiName = new("name") { Description = "a type or member: ChartOpts, Ui.LineChart, Button" }; internal Argument IconsFilter = OptionalText("filter", "only the icons whose name contains this"); - /// - /// The shorthand `docs testing`. Hidden from the usage lines because it belongs to the - /// `docs` command, and rendering it would suggest `docs testing search `. - /// - internal Argument DocsWhat = OptionalText("what", "a topic, a document, or a document#section"); - - - internal Argument DocsReadWhat = new("what") { Description = "a topic, a document, or a document#section" }; + internal Argument DocsWhat = new("what") { Description = "a topic, a document, or a document#section" }; internal Argument DocsWords = new("words") { @@ -156,6 +152,13 @@ private sealed class Symbols Arity = ArgumentArity.OneOrMore, }; + /// + /// The options every command that drives an app shares, in the order the help reads them: + /// what to drive, what to draw first, then how loud to be. + /// + internal Option[] AppOptions => + [Project, App, Framework, NoBuild, Attach, Frames, Wait, WaitText, WaitTimeout, Quiet]; + internal void Validate() { Wait.AcceptOnlyFromAmong("ready"); @@ -198,13 +201,13 @@ internal static bool InRange(double seconds) => double.IsFinite(seconds) && seconds > 0 && seconds <= Cli.Options.MaxWaitSeconds; } - // One initializer, because field initializers run in textual order: two separate ones could - // leave Root assigned before the symbols that give it its meaning. private static void NoOp(ParseResult _) { // Dispatch happens in Cli.Run; an action exists only so a parent command can be terminal. } + // One initializer, because field initializers run in textual order: two separate ones could + // leave Root assigned before the symbols that give it its meaning. private static readonly (Symbols Symbols, RootCommand Root) Built = Build(); private static Symbols S => Built.Symbols; @@ -216,40 +219,43 @@ private static (Symbols Symbols, RootCommand Root) Build() var symbols = new Symbols(); symbols.Validate(); - var root = new RootCommand("drive a CuiSharp app from a shell or an agent, and read its rules offline."); - // Without an action a parent command is only a namespace; `dotnet cui --stdin` and the - // help path need the root itself to be a valid terminal. + var root = new RootCommand("inspect and drive a CuiSharp app, and read its rules offline."); + // Without an action a parent command is only a namespace; the root and `docs` are both + // terminal (the help, and the docs index). root.SetAction(NoOp); - foreach ((string name, string description, Argument[] arguments) in Verbs) + foreach ((string name, string[] aliases, string description, Argument[] arguments) in Verbs) { var command = new Command(name, description); + foreach (string alias in aliases) command.Aliases.Add(alias); foreach (Argument argument in arguments) command.Arguments.Add(argument); + foreach (Option option in symbols.AppOptions) command.Options.Add(option); root.Subcommands.Add(command); } - var ps = new Command("ps", "list running live CuiSharp windows (started with --live)"); - ps.Options.Add(symbols.Json()); - ps.SetAction(NoOp); - root.Subcommands.Add(ps); + var session = new Command( + "session", + "read one protocol command per line from stdin, against one build and one app: " + + "`dotnet cui session < commands.txt`"); + foreach (Option option in symbols.AppOptions) session.Options.Add(option); + root.Subcommands.Add(session); + root.Subcommands.Add(Ps(symbols)); root.Subcommands.Add(Api(symbols)); root.Subcommands.Add(Icons(symbols)); root.Subcommands.Add(Docs(symbols)); - foreach (Option option in new Option[] - { - symbols.Project, symbols.App, symbols.Framework, symbols.Frames, symbols.Attach, - symbols.Wait, symbols.WaitText, symbols.WaitTimeout, symbols.Quiet, symbols.Stdin, - symbols.NoBuild, - }) - { - root.Options.Add(option); - } - return (symbols, root); } + private static Command Ps(Symbols symbols) + { + var ps = new Command("ps", "list running live CuiSharp windows (started with --live)"); + ps.Options.Add(symbols.Json()); + ps.SetAction(NoOp); + return ps; + } + private static Command Api(Symbols symbols) { var api = new Command("api", "one declaration from the API catalog inside this tool, offline"); @@ -268,29 +274,20 @@ private static Command Icons(Symbols symbols) private static Command Docs(Symbols symbols) { - symbols.DocsWhat.Hidden = true; - var docs = new Command( - "docs", - "the guide and the reference bundled with this tool, read offline; " - + "`docs ` is shorthand for `docs read `") - { - symbols.DocsWhat, - }; + var docs = new Command("docs", "the guide and the reference bundled with this tool, read offline"); docs.Options.Add(symbols.Json()); // A command with subcommands still has to be usable on its own: `dotnet cui docs` is the - // index, and `dotnet cui docs SKILL#pitfalls` is one section. + // index of every topic and document. docs.SetAction(NoOp); var read = new Command("read", "print one topic, one document, or one #section of one"); - read.Arguments.Add(symbols.DocsReadWhat); + read.Arguments.Add(symbols.DocsWhat); read.Options.Add(symbols.Json()); - read.SetAction(NoOp); docs.Subcommands.Add(read); var search = new Command("search", "the sections that answer a question, ranked"); search.Arguments.Add(symbols.DocsWords); search.Options.Add(symbols.Json()); - search.SetAction(NoOp); docs.Subcommands.Add(search); return docs; @@ -304,8 +301,21 @@ private static Argument Text(string name, string description) => private static Argument OptionalText(string name, string description) => new(name) { Description = description, Arity = ArgumentArity.ZeroOrOne }; - private static Argument Number(string name, bool required) => - new(name) { Description = name, Arity = required ? ArgumentArity.ExactlyOne : ArgumentArity.ZeroOrOne }; + private static Argument Number(string name, bool required, string description) + { + var number = new Argument(name) + { + Description = description, + Arity = required ? ArgumentArity.ExactlyOne : ArgumentArity.ZeroOrOne, + }; + // float.Parse accepts NaN and Infinity; neither is a place on the screen to point at. + number.Validators.Add(result => + { + if (Symbols.Peek(result) is { } value && !float.IsFinite(value)) + result.AddError($"<{name}> needs a finite number"); + }); + return number; + } private static Argument Count(string description) { @@ -318,7 +328,18 @@ private static Argument Text(string name, string description) => return count; } - private static Argument Seconds(string description) + private static Argument RequiredSeconds(string description) + { + var seconds = new Argument("seconds") { Description = description }; + seconds.Validators.Add(result => + { + if (Symbols.Peek(result) is { } value && !Symbols.InRange(value)) + result.AddError($"needs seconds above 0 and at most {Cli.Options.MaxWaitSeconds:0}"); + }); + return seconds; + } + + private static Argument OptionalSeconds(string description) { var seconds = new Argument("seconds") { Description = description, Arity = ArgumentArity.ZeroOrOne }; seconds.Validators.Add(result => @@ -356,12 +377,7 @@ internal static Parsed Parse(string[] args) var errors = new List(); foreach (ParseError parseError in result.Errors) errors.Add(Cli.Humanize(parseError.Message)); if (errors.Count == 0 && !TryCheckCombinations(result, out string conflict)) errors.Add(conflict); - if (errors.Count > 0 && NearestCommand(grammarArgs) is { } meant) - { - // One unknown verb leaves every following token unrecognised too. Only the verb is - // the mistake, so say that once and name the verb that was meant. - errors = [errors[0], $"Did you mean `dotnet cui {meant}`? Run `dotnet cui --help` for the rest."]; - } + if (errors.Count > 0 && Hint(result, grammarArgs) is { } hint) errors = [errors[0], hint]; if (errors.Count > 0) return new Parsed(false, false, false, null, errors, result); // `--version` is System.CommandLine's own action, and it is root-only: answering it @@ -374,37 +390,80 @@ internal static Parsed Parse(string[] args) // is about to explain: `dotnet cui click --help` must not require a label. if (result.Action is HelpAction) return new Parsed(true, false, false, null, [], result); - Cli.Options options = Resolve(result, appArguments); - if (options.NoCommand && !options.Streaming) + // Nothing was asked for: the root is terminal, so there is no command to resolve. + if (ReferenceEquals(result.CommandResult.Command, Root)) return new Parsed(true, false, true, null, [], result); + + return new Parsed(false, false, false, Resolve(result, appArguments), [], result); + } + + /// + /// What a rejected command line was probably meant to be. One line only: a wrong verb leaves + /// every following token unrecognised, so repeating the list buries the one mistake that + /// matters under the tokens that were never the problem. + /// + private static string? Hint(ParseResult result, string[] grammarArgs) + { + string? unknown = result.UnmatchedTokens.FirstOrDefault(token => token.Length > 0 && token[0] != '-'); + + // Options belong to a command now, so they are read after it. There are two ways to get + // that wrong, and they need different answers: an option written before its own command, + // and an option this command does not have at all. + Command matched = result.CommandResult.Command; + if (result.UnmatchedTokens.FirstOrDefault(token => IsOptionOf(matched, token)) is { } misordered) + return $"`{misordered}` belongs after the command, as in `dotnet cui {matched.Name} {misordered} ...`."; + + if (result.UnmatchedTokens.FirstOrDefault(token => token.StartsWith('-')) is { } orphan) { - // A bare `--wait ready` still means "wait, then show me the tree"; anything else has - // no command to run, and guessing would hide a typo. - if (options.WaitLine is null) return new Parsed(true, false, true, null, [], result); - options = options with { Command = "tree", NoCommand = false }; + Command? owner = Root.Subcommands.FirstOrDefault(command => IsOptionOf(command, orphan)); + if (owner is not null && ReferenceEquals(matched, Root)) + return $"`{orphan}` belongs after the command, as in `dotnet cui {owner.Name} {orphan} ...`."; + + if (owner is not null) + return $"`{orphan}` is not an option of `{matched.Name}`: run `dotnet cui {matched.Name} --help` for the ones it takes."; } - return new Parsed(false, false, false, options, [], result); + if (unknown is null) return null; + + // `docs testing` is the shorthand a reader reaches for; the page is one word away. + if (result.CommandResult.Command.Name == "docs" && result.CommandResult.Parent is not null) + return $"Did you mean `dotnet cui docs read {unknown}`? Run `dotnet cui docs` for every topic."; + + if (NearestCommand(unknown) is { } meant) + return $"Did you mean `dotnet cui {meant}`? Run `dotnet cui --help` for the rest."; + + return null; + } + + /// Whether an unmatched token names an option the given command declares. + private static bool IsOptionOf(Command command, string token) + { + if (!token.StartsWith('-')) return false; + + string name = token.Split('=')[0]; + return command.Options.Any(option => option.Name == name || option.Aliases.Contains(name)); } /// - /// The verb a mistyped first word was probably meant to be: same opening letters, or a - /// near miss. A suggestion is worth this much arithmetic because the alternative is a reader - /// (or an agent) spending a turn re-reading the help to find out it typed one letter wrong. + /// The verb a mistyped word was probably meant to be. A suggestion is worth this much + /// arithmetic because the alternative is a reader (or an agent) spending a turn re-reading + /// the help to find out it typed one letter wrong. /// - private static string? NearestCommand(string[] args) + private static string? NearestCommand(string token) { - string? token = args.FirstOrDefault(word => word.Length > 0 && word[0] != '-'); - if (token is null || Root.Subcommands.Any(command => command.Name == token)) return null; - string? best = null; int bestDistance = int.MaxValue; foreach (Command command in Root.Subcommands) { - int distance = Distance(token, command.Name); - if (distance < bestDistance) + foreach (string name in command.Aliases.Prepend(command.Name)) { - bestDistance = distance; - best = command.Name; + if (name == token) return null; + + int distance = Distance(token, name); + if (distance < bestDistance) + { + bestDistance = distance; + best = command.Name; + } } } @@ -456,8 +515,9 @@ internal static string[] SplitAppArguments(string[] args, out string[] appArgume } /// - /// Rules that span two options. They live here rather than in a Command.Validators on - /// the root because root validators do not run when a subcommand is the one being invoked. + /// Rules that span two options. They live here rather than in per-command validators because + /// every app command shares one instance of each option, so the question "was this typed?" + /// has a single answer wherever it is asked. /// internal static bool TryCheckCombinations(ParseResult result, out string message) { @@ -475,6 +535,32 @@ internal static bool TryCheckCombinations(ParseResult result, out string message return false; } + bool published = result.GetResult(S.App) is not null; + bool attached = result.GetResult(S.Attach) is not null; + bool project = result.GetResult(S.Project) is not null; + int targets = (published ? 1 : 0) + (attached ? 1 : 0) + (project ? 1 : 0); + if (targets > 1) + { + message = "--project, --app and --attach are three ways to say what to drive: choose one"; + return false; + } + + // A bool option gets a result even when nobody typed it, so presence is `Implicit: false`. + if (result.GetResult(S.NoBuild) is { Implicit: false } && (published || attached)) + { + message = published + ? "--no-build reuses a project build; there is nothing to build for --app" + : "--no-build reuses a project build; a live window is already running"; + return false; + } + + if (result.GetResult(S.Framework) is not null && (published || attached)) + { + message = "--framework picks among a project's target frameworks: it does not apply to " + + (published ? "--app" : "--attach"); + return false; + } + message = ""; return true; } @@ -487,17 +573,17 @@ internal static Cli.Options Resolve(ParseResult result, string[] appArguments) // so "nothing was asked for" is decided by identity, never by an empty string. string name = ReferenceEquals(matched.Command, Root) ? "" : matched.Command.Name; - (string command, string argument) = name switch + // Each offline command gets its own name so the dispatcher switches on one value instead + // of re-reading a joined string: `docs read X` and `docs search W` are different answers. + string command = name switch { - // `docs read X` and `docs search a b` are subcommands so their help is per-verb; the - // docs reader still takes one line, so the verb goes back on the front here. - "read" or "search" => ("docs", $"{name} {Join(result, matched)}"), - "api" => ("api", Join(result, matched)), - "icons" => ("icons", Join(result, matched)), - "docs" => ("docs", Join(result, matched)), - "ps" => ("ps", ""), - _ => (name, Join(result, matched)), + "read" => "docs.read", + "search" => "docs.search", + _ => name, }; + // `Join` is "" for a command with no arguments, so one line covers every command: the + // offline ones take a name or words, the app verbs take their protocol slots. + string argument = name.Length == 0 ? "" : Join(result, matched); bool waiting = result.GetResult(S.Wait) is not null || result.GetResult(S.WaitText) is not null; double waitSeconds = result.GetValue(S.WaitTimeout) ?? Cli.Options.DefaultWaitSeconds; @@ -511,14 +597,10 @@ internal static Cli.Options Resolve(ParseResult result, string[] appArguments) } OptionResult? attach = result.GetResult(S.Attach); - bool streaming = result.GetValue(S.Stdin); return new Cli.Options { Command = command, Argument = argument, - // `--wait ready` with no command means "wait, then show me the tree". - NoCommand = command.Length == 0, - Streaming = streaming, Frames = result.GetValue(S.Frames) ?? 0, Quiet = result.GetValue(S.Quiet), Json = JsonRequested(result, matched), @@ -529,6 +611,7 @@ internal static Cli.Options Resolve(ParseResult result, string[] appArguments) Host = result.GetValue(S.App), Framework = result.GetValue(S.Framework), AppArgs = appArguments, + Seconds = command == "dwell" ? double.Parse(argument, CultureInfo.InvariantCulture) : 0, WaitSeconds = waiting ? waitSeconds : 0, WaitLine = waitLine, }; @@ -536,13 +619,11 @@ internal static Cli.Options Resolve(ParseResult result, string[] appArguments) /// /// Whether the command that ran was asked for JSON. Each command owns its own --json, - /// so the answer is looked up on the command that was actually matched, never globally. + /// so the answer is looked up on the command that was actually matched, and the value decides: + /// --json=false is a request for text, not a request for JSON that nobody can undo. /// private static bool JsonRequested(ParseResult result, CommandResult matched) => - matched.Command.Options.Any(option => - // A bool option gets a result even when nobody typed it, and carries no tokens even - // when somebody did: only Implicit tells the two apart. - option.Name == "--json" && result.GetResult(option) is { Implicit: false }); + matched.Command.Options.Any(option => option is Option { Name: "--json" } flag && result.GetValue(flag)); /// /// One protocol line from a command's arguments, in declaration order: the wire protocol is a @@ -561,6 +642,7 @@ private static string Join(ParseResult result, CommandResult matched) Argument number => result.GetValue(number)?.ToString(CultureInfo.InvariantCulture), Argument number => result.GetValue(number)?.ToString("R", CultureInfo.InvariantCulture), Argument number => result.GetValue(number)?.ToString("R", CultureInfo.InvariantCulture), + Argument number => result.GetValue(number).ToString("R", CultureInfo.InvariantCulture), Argument one => result.GetValue(one), _ => null, }; diff --git a/src/CuiSharp.Tools/HeadlessClient.cs b/src/CuiSharp.Tools/HeadlessClient.cs index e6f70af..2589eec 100644 --- a/src/CuiSharp.Tools/HeadlessClient.cs +++ b/src/CuiSharp.Tools/HeadlessClient.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Diagnostics; using System.Globalization; using System.Text; @@ -106,7 +107,18 @@ internal static HeadlessClient Start( foreach (string argument in arguments) info.ArgumentList.Add(argument); info.Environment[CuiSharpEnvironmentVariable] = "1"; - Process process = Process.Start(info) ?? throw new ToolException($"Could not start {host}."); + Process process; + try + { + process = Process.Start(info) ?? throw new ToolException($"Could not start {host}."); + } + catch (Win32Exception failed) + { + // A path that is not there is the most likely --app/--project mistake, and the answer + // belongs in one line with exit 1, not in a stack trace out of the tool's own frame. + throw new ToolException($"Could not start \"{host}\": {failed.Message}"); + } + var client = new HeadlessClient( process, ownsProcess: true, diff --git a/tools/bindgen.cs b/tools/bindgen.cs index 63baa93..e2af3ed 100644 --- a/tools/bindgen.cs +++ b/tools/bindgen.cs @@ -2821,7 +2821,7 @@ public string LlmsTxt(IReadOnlyList<(string Name, string File)> manual) sb.AppendLine("- `HeadlessApp` — the same driver in the framework, which `TestApp` names; `dotnet cui` drives it in a real process."); sb.AppendLine("- A tab strip and a segmented control record one control per item (`Tab \"Markets\"`, `Segment \"Dark\"`), so an item is found and clicked like any other control."); sb.AppendLine("- `surface.LastFrame` -> `FrameSample` — what the last frame that surface drew cost: `DrawMs`, `AllocatedBytes`, `FrameMs`, `Fps`, `IsLive`. `FrameStats` holds the same for the one window in the process, which is what a footer reads."); - sb.AppendLine("- `dotnet cui tree|find|click|type|key|focus|move|frame|faults|png` — one command per line, `--stdin` for a session, `--app` for a published build, `--no-build`, `--frames N`. Any app opts in by running headless (`CUISHARP_HEADLESS=1` or `--headless`)."); + sb.AppendLine("- `dotnet cui tree|find|click|type|key|focus|move|frame|faults|png` — one command per line; `dotnet cui session` relays a whole script against one build; `--app` for a published build, `--no-build`, `--frames N`. Any app opts in by running headless (`CUISHARP_HEADLESS=1` or `--headless`)."); sb.AppendLine(); sb.AppendLine("## Core API (hand-written, in `src/CuiSharp/Ui`)"); sb.AppendLine(); diff --git a/tools/page-costs.ps1 b/tools/page-costs.ps1 index 170eb8e..7790604 100644 --- a/tools/page-costs.ps1 +++ b/tools/page-costs.ps1 @@ -74,7 +74,7 @@ foreach ($page in $GalleryPages) { Write-Host "==> dwelling on $($GalleryPages.Count) page(s), ${Each}s each, $Theme" -ForegroundColor Cyan $env:CUI_THEME = $Theme try { - $output = $script.ToString() | & $cui --stdin --no-build --project $gallery + $output = $script.ToString() | & $cui session --no-build --project $gallery if ($LASTEXITCODE -ne 0) { throw 'dotnet cui failed' } } finally { diff --git a/tools/snapshots.ps1 b/tools/snapshots.ps1 index 6aa5f5a..98ee3e2 100644 --- a/tools/snapshots.ps1 +++ b/tools/snapshots.ps1 @@ -98,7 +98,7 @@ foreach ($page in $chosen) { Write-Host "==> drawing $($chosen.Count) page(s), $Theme" -ForegroundColor Cyan $env:CUI_THEME = $Theme try { - $script.ToString() | & $cui --stdin --no-build --project $gallery | Out-Null + $script.ToString() | & $cui session --no-build --project $gallery | Out-Null if ($LASTEXITCODE -ne 0) { throw 'dotnet cui failed' } } finally {