diff --git a/CHANGELOG.md b/CHANGELOG.md index f7565f2..4684dbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,18 +8,44 @@ different things. ### 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. - **A demo icon in both templates.** `dotnet new cui-app` ships `.ico` wired in with `ApplicationIcon`, and `dotnet new cui-mini` generates `.cs` plus a matching `.ico`; replacing the file and rebuilding changes the exe icon and the icon on the title bar, taskbar button and `Alt+Tab` card. The shipped icon is a black square reading CUI. +### 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. + ### Fixed - **The embedded icon reaches the window.** An apphost embeds `ApplicationIcon` as `IDI_APPLICATION`, which Explorer reads off the exe but the CUI window class does not find, so a published app wore the system's generic logo in the title bar and taskbar. `CuiApp` now loads that resource and sends `WM_SETICON` itself when the window opens, small and large, at the system's icon metrics. - - **`dotnet new cui-mini` no longer opens a console window.** The generated file-based app now sets `#:property OutputType=WinExe`, so `dotnet publish` produces a GUI-subsystem executable like the one `dotnet new cui-app` builds; `tools/verify-template.ps1` reads that subsystem out diff --git a/README.md b/README.md index 33cca32..398a1a6 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,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, @@ -179,14 +219,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" @@ -257,7 +304,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 @@ -288,7 +335,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..1c7cc02 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -21,6 +21,8 @@ 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. | +| 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 @@ -37,7 +39,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 +266,21 @@ 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. + +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, 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 that window's guard. A connection has a bounded asynchronous reader that detects EOF even during @@ -293,5 +310,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 04d0ab6..af0163e 100644 --- a/skills/cui-dotnet/SKILL.md +++ b/skills/cui-dotnet/SKILL.md @@ -516,6 +516,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 @@ -581,15 +586,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. @@ -598,8 +603,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; @@ -685,6 +691,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/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.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/ApiCatalogCliTests.cs b/src/CuiSharp.Tests/ApiCatalogCliTests.cs index bd63fd6..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); @@ -139,7 +156,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 +164,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 +190,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 +207,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,118 +215,106 @@ 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. - (int exit, _, string error) = Run("api", "ChartOpts", "--frames", "--json"); - Assert.Equal(1, exit); - Assert.Contains("--frames needs a value", error, StringComparison.Ordinal); + // `--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("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"); - Assert.Equal(1, exit); - Assert.Contains("--wait-timeout needs a value", error, StringComparison.Ordinal); + (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); - Assert.Equal(1, exit); - Assert.Contains("--app needs a value", errors.ToString(), StringComparison.Ordinal); + // 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'", error, StringComparison.Ordinal); } [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); - - (int exit, _, string error) = Run("tree", "--wait-text", "-loading"); + // `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", "--project", NoProjectHere, "--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(["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", "--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("tree", "--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(["tree", "--frames", "2", "--frames", "3"], output, errors)); + Assert.Contains("'--frames'", 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(["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("--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()); - (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); } @@ -359,8 +364,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 new file mode 100644 index 0000000..9d18df1 --- /dev/null +++ b/src/CuiSharp.Tests/DocsCommandTests.cs @@ -0,0 +1,392 @@ +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. 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 (int Exit, string Out, string Err) Run(params string[] args) + { + var output = new StringWriter(); + var error = new StringWriter(); + 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() + { + (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("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. + 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() + { + // 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("Required argument 'what' missing", error, StringComparison.Ordinal); + + (exit, _, error) = Run("docs", "search"); + Assert.Equal(2, exit); + Assert.Contains("Required argument 'words' missing", error, StringComparison.Ordinal); + } + + /// + /// 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 ADroppedVerbIsAnsweredWithTheReadThatWouldWork() + { + (int exit, _, string error) = Run("docs", "testing"); + + Assert.Equal(2, exit); + Assert.Contains("dotnet cui docs read testing", error, 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(); + // 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. + 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.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 0ee7bfc..dd52449 100644 --- a/src/CuiSharp.Tests/ToolArgumentTests.cs +++ b/src/CuiSharp.Tests/ToolArgumentTests.cs @@ -1,73 +1,254 @@ +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.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 ALabelWithSpacesIsOneQuotedArgument() + { + // 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("click", options.Command); - Assert.Equal("Save", options.Argument); - Assert.Equal("click Save", options.Line); - Assert.False(options.Help); + Assert.Empty(parsed.Errors); + Assert.Equal("type Hello world", parsed.Options!.Line); } [Fact] - public void ALabelWithSpacesStaysOneArgument() + public void ASecondWordWhereOneArgumentIsDeclaredIsRefusedNotSilentlyJoined() { - Cli.Options options = Cli.Options.Parse(["type", "Hello", "world"]); + // `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.Equal("Hello world", options.Argument); - Assert.Equal("type Hello world", options.Line); + 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); - // Streaming supplies its commands on stdin, so it needs no command word. - Assert.False(Cli.Options.Parse(["--stdin"]).Help); + Assert.True(Grammar.Parse([]).Help); + 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] - 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. + // 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))) + { + 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)); + + Grammar.Parsed bare = Grammar.Parse([.. argv]); + Assert.True(bare.Errors.Count == 0, $"`{string.Join(' ', argv)}` did not parse: {string.Join("; ", bare.Errors)}"); + } + + // 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 (CliOption option in command.Options.Where(o => o is not CliHelpOption and not VersionOption)) + { + 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(int?) + || t == typeof(float) || t == typeof(float?) + || t == typeof(double) || 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 +256,69 @@ 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() + public void AWaitIsWrittenAfterTheCommandItGates() { - Cli.Options options = Cli.Options.Parse(["--wait", "ready", "--wait-timeout", "30"]); + Grammar.Parsed parsed = Grammar.Parse(["tree", "--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() + public void AWaitCoexistsWithFramesInASession() { - Cli.Options options = Cli.Options.Parse( - ["--frames", "3", "--wait", "ready", "--stdin"]); + Grammar.Parsed parsed = Grammar.Parse(["session", "--frames", "3", "--wait", "ready"]); + + Assert.Empty(parsed.Errors); + 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(3, options.Frames); - Assert.True(options.Streaming); - Assert.False(options.Help); - // 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); + [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.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(["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] @@ -139,12 +330,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(["tree", "--wait", "ready", "--wait-timeout", value], output, error)); + Assert.Contains("wait-timeout", error.ToString(), StringComparison.Ordinal); } [Theory] @@ -155,8 +347,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] @@ -209,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); @@ -223,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); @@ -236,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); @@ -288,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( @@ -319,7 +513,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)); @@ -408,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/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..9c675ae 100644 --- a/src/CuiSharp.Tools/Cli.cs +++ b/src/CuiSharp.Tools/Cli.cs @@ -1,68 +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 API catalog inside this tool: - 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. - - 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(() => { @@ -76,20 +26,47 @@ 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) { + 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!; + + // 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 is "api" or "icons") - return AnswerCatalog(options, output, error); + return AnswerCatalog(options, output); if (options.Command == "ps") { @@ -103,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) @@ -125,6 +103,40 @@ 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: 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) + { + DocLibrary docs = BundledDocs.Value; + CatalogResult answer = options.Command switch + { + "docs.read" => docs.Read(options.Argument), + "docs.search" => docs.Search(options.Argument), + _ => docs.List(), + }; + + output.WriteLine(options.Json ? answer.RenderJson() : answer.Render()); + return answer.IsExact ? 0 : 1; + } + private static HeadlessClient StartHeadless(Options options, TextWriter error) { (string host, string[] hostArgs) = options.Host is { } exe @@ -139,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) @@ -304,16 +308,12 @@ internal sealed record Options internal bool Quiet { get; init; } - internal bool Help { 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; } @@ -326,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; } @@ -334,165 +337,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")) - throw new ToolException("Unknown option \"--json\". Only `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 8172568..c103a5e 100644 --- a/src/CuiSharp.Tools/CuiSharp.Tools.csproj +++ b/src/CuiSharp.Tools/CuiSharp.Tools.csproj @@ -20,10 +20,18 @@ 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 +46,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/src/CuiSharp.Tools/Grammar.cs b/src/CuiSharp.Tools/Grammar.cs new file mode 100644 index 0000000..da0e587 --- /dev/null +++ b/src/CuiSharp.Tools/Grammar.cs @@ -0,0 +1,657 @@ +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. +/// +/// +/// 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, aliases, description, and one argument per slot. + private static readonly (string Name, string[] Aliases, string Description, Argument[] Arguments)[] Verbs = + [ + ("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", + }; + + internal Option App = new("--app") + { + Description = "drive a published build instead of building one", + HelpName = "exe", + }; + + internal Option Framework = new("--framework", "-f") + { + Description = "the target framework, when the project has more than one", + HelpName = "tfm", + }; + + internal Option NoBuild = new("--no-build") + { + 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; " + + "the value is its pid or title, or leave it out when only one window is open", + HelpName = "pid|title", + Arity = ArgumentArity.ZeroOrOne, + }; + + // ---- 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") + { + Description = "precondition: draw until a frame marks itself ready (ui.MarkReady)", + HelpName = "ready", + Arity = ArgumentArity.ExactlyOne, + }; + + internal Option WaitText = new("--wait-text") + { + Description = "precondition: draw until a frame draws this text or query", + HelpName = "text", + }; + + internal Option WaitTimeout = new("--wait-timeout") + { + Description = "the window for --wait and --wait-text, in seconds (default 10, max 3600)", + HelpName = "seconds", + }; + + // ---- output ------------------------------------------------------------------------- + + internal Option Quiet = new("--quiet", "-q") + { + Description = "print only the payload, never the tool's own progress", + }; + + /// + /// 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" }; + + // ---- 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"); + + internal Argument DocsWhat = 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, + }; + + /// + /// 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"); + + 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; + } + + 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; + + internal static RootCommand Root => Built.Root; + + private static (Symbols Symbols, RootCommand Root) Build() + { + var symbols = new Symbols(); + symbols.Validate(); + + 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[] 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 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)); + + 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"); + 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) + { + 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 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.DocsWhat); + read.Options.Add(symbols.Json()); + 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()); + 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, 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) + { + 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 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 => + { + 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 && 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 + // 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); + + // 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) + { + 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."; + } + + 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 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 token) + { + string? best = null; + int bestDistance = int.MaxValue; + foreach (Command command in Root.Subcommands) + { + foreach (string name in command.Aliases.Prepend(command.Name)) + { + if (name == token) return null; + + int distance = Distance(token, 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 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) + { + 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; + } + + 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; + } + + /// 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; + + // 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 + { + "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; + 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); + return new Cli.Options + { + Command = command, + Argument = argument, + 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, + Seconds = command == "dwell" ? double.Parse(argument, CultureInfo.InvariantCulture) : 0, + 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, 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 => 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 + /// 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 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/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 { diff --git a/tools/verify-template.ps1 b/tools/verify-template.ps1 index f719e3a..945a43c 100644 --- a/tools/verify-template.ps1 +++ b/tools/verify-template.ps1 @@ -261,12 +261,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" } @@ -299,8 +303,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'