diff --git a/README.md b/README.md index d67470c..1aa86f5 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,9 @@ rb install install a ruby binary package rb list list installed rubies rb use switch the active ruby rb uninstall remove an installed ruby +rb msvc run a command with the MSVC build env applied rb msvc enable [shell] print the MSVC build env to eval (cmd|powershell) -rb msvc exec run a command with the MSVC build env applied -rb msvc list list installed Visual Studio C++ toolchains +rb msvc --list list installed Visual Studio C++ toolchains ``` rb is a bare exe; `setup` copies it to @@ -39,12 +39,16 @@ ship. It also checks for the VC++ 2015-2022 redistributable the official mswin packages depend on, and offers to download and install it (signature-verified, elevated); `--yes` skips the consent prompt. -`msvc enable` and `msvc exec` activate an installed Visual Studio (or -Build Tools) MSVC toolchain for building C extension gems; see -[docs/msvc-enable.md](docs/msvc-enable.md). By default the newest +`msvc` activates an installed Visual Studio (or Build Tools) MSVC +toolchain for building C extension gems and runs the rest of the +command line under it, as in `rb msvc gem install nokogiri`; +`msvc enable` prints the same environment for a shell to eval instead. +See [docs/msvc-enable.md](docs/msvc-enable.md). By default the newest install wins; `--vsver <2017|2019|2022|2026|latest>` (or the `RBMANAGER_VSVER` environment variable) pins a specific Visual Studio -version, and `msvc list` shows what is installed. +version, and `msvc --list` shows what is installed. Apart from +`enable`, every word after `msvc` is the command to run, so future +`msvc` operations are spelled as flags. The official mswin packages deliberately do not bundle vcruntime140.dll (https://bugs.ruby-lang.org/issues/22180) and expect diff --git a/docs/msvc-enable.md b/docs/msvc-enable.md index ecdcad8..b0a9085 100644 --- a/docs/msvc-enable.md +++ b/docs/msvc-enable.md @@ -4,12 +4,10 @@ The official Ruby mswin binary (`x64-mswin64_140`, MSVC, distributed as the relocatable `ruby-X.Y.Z--mswinNN_MMM.zip` that rbmanager -installs) ships no devkit. Unlike RubyInstaller (mingw), which bundles -MSYS2 and exposes `ridk enable` to put a compiler on PATH, the mswin -package assumes a compiler is already present. When none is, -`gem install ` dies inside mkmf with the cryptic message "The -compiler failed to generate an executable file. You have to install -development tools first." +installs) ships no compiler and assumes one is already present on the +machine. When none is, `gem install ` dies inside mkmf with the +cryptic message "The compiler failed to generate an executable file. You +have to install development tools first." rbmanager's job here is narrow: make it possible to build native gems from source on the end-user machine by activating an already-installed @@ -20,8 +18,9 @@ already solved (see the trust hook in `operating_system.rb`) and is also out of scope. Python's pymanager is not a precedent: Python sidesteps the compiler via -prebuilt wheels, so pymanager does nothing about toolchains. The only -real model is `ridk`. +prebuilt wheels, so pymanager does nothing about toolchains. The model +that applies is Visual Studio's own Developer Command Prompt, whose +`VsDevCmd.bat` is exactly the activation step needed here. ## Feasibility: proven on a real machine @@ -35,30 +34,29 @@ Studio. - `VsDevCmd.bat -arch=amd64 -host_arch=amd64` puts `cl`/`nmake`/`link` on PATH and sets `INCLUDE`/`LIB`/`LIBPATH`/`VCToolsRedistDir` (exit 0). -- Under the prototype `rb msvc exec`, mkmf's `find_executable('cl')` +- Under the prototype passthrough, mkmf's `find_executable('cl')` succeeds, and a trivial C extension compiles, links, and loads: `extconf.rb` -> `nmake` -> `require './hello.so'` returns a value from native code. - `rb msvc enable powershell | Invoke-Expression` puts `cl` on the current session's PATH. -The conclusion is that a compiler-only `rb msvc enable`/`rb msvc exec` is fully -feasible and small. The interesting decisions are the command surface -and how far to go on third-party dependency headers. +The conclusion is that a compiler-only `rb msvc` is fully feasible and +small. The interesting decisions are the command surface and how far to +go on third-party dependency headers. -## Recommended command surface +## Command surface A bare `rb.exe` child process cannot mutate its parent cmd/PowerShell -environment. `ridk enable` only works because it is a shell function -whose output is eval'd into the current shell. Any activation feature -must work around this, and the two useful shapes are: +environment, so an activation feature must either be eval'd by the +caller or own the child process it starts. The two useful shapes are: -1. **`rb msvc exec ` (primary).** Spawns a child process - with the toolchain and active ruby already applied. No parent - mutation, so nothing to eval and nothing to get wrong. `rb msvc exec - gem install nokogiri` just works. This is the recommended path for - the common case (one build command) and for scripts/CI, and it is - the surface that is bulletproof by construction. +1. **`rb msvc ` (primary).** Spawns a child process with + the toolchain and active ruby already applied. No parent mutation, + so nothing to eval and nothing to get wrong. `rb msvc gem install + nokogiri` just works. This is the recommended path for the common + case (one build command) and for scripts/CI, and it is the surface + that is bulletproof by construction. 2. **`rb msvc enable [cmd|powershell|pwsh]` (shell activation).** Prints environment assignments for the user to eval into the current shell, @@ -75,17 +73,69 @@ must work around this, and the two useful shapes are: This is the escape hatch for users who want a persistently activated shell rather than a per-command wrapper. -Recommend shipping both. `rb msvc exec` is the headline; `rb msvc +Recommend shipping both. The passthrough is the headline; `rb msvc enable` covers the interactive workflow. A third option, writing a dot-sourced activation script into `%LOCALAPPDATA%\Ruby`, adds a file to manage and a staleness problem (the resolved VS path is baked in) for no gain over `rb msvc enable`, so it is not recommended. -The parent-shell-mutation constraint is handled cleanly: `rb msvc exec` +The parent-shell-mutation constraint is handled cleanly: the passthrough sidesteps it entirely by owning the child's environment; `rb msvc enable` respects it by making the caller responsible for the eval. +### Why the passthrough has no verb + +A top-level `rb exec` was rejected because it connotes "run under the +selected ruby", the way `rbenv exec` and `mise exec` do; that is why +these commands live under `msvc`. Dropping the `exec` word entirely is +the next step: `rb msvc gem install nokogiri` reads as "under MSVC, run +this", removes a word, and invents no vocabulary. The precedent for the +shape is Apple's `xcrun clang ...`, which applies a toolchain +environment and passes the rest through. + +The price is the bare-word space after `msvc`: every executable on PATH +now names itself there. `enable` is the only reserved word and has to +stay the only one, so **any future `msvc` operation is added as a flag +(like `--list`), never as a bare word**, because a bare word silently +steals the name of a real executable from the passthrough space. + +That split follows cargo, which enumerates with `cargo --list` rather +than `cargo list` precisely because its bare-word space is given over to +subcommands (`cargo foo` runs `cargo-foo` from PATH, with builtins +winning). Cargo also demonstrates the failure mode: `cargo add` was an +external cargo-edit command until Cargo 1.62 made it a builtin, silently +changing what the same command meant. rbmanager has to be more +conservative than cargo here, because the space `rb msvc ` +gives away is every executable on PATH, not a `cargo-` prefixed subset. + +The rule that falls out: queries are flags, actions are words. `--list` +takes no argument and reports instead of acting, so it is a flag. +`enable` takes an argument and acts, and an `--enable` spelling would +additionally drag in the autoconf `--enable-shared` connotation of a +build-time boolean, so it stays a word. The accepted cost is the +asymmetry with the top level, where `rb list` lists rubies as a word +while `rb msvc --list` lists toolchains as a flag: the top level has no +passthrough, so words are free there. + +### Parsing + +After the `msvc` token, `--vsver `, `--vsver=` and `--list` +are read as leading options. `--list` is terminal, so a command after it +is a usage error. The first non-option token then decides: exactly +`enable` selects the enable subcommand, which takes `--vsver` in any +position (`rb msvc enable --vsver 2022 powershell` and `rb msvc --vsver +2022 enable powershell` are the same request); anything else is the +user's command line, taken verbatim. `--` ends option reading, so +`rb msvc -- enable` runs a program named `enable`. A bare `rb msvc` +prints usage and exits 2. + +`rb msvc exec` is deliberately not kept as a compatibility alias: it +would restore a second reserved word and defeat the point. No release +had shipped when the spelling changed, so there was nothing to migrate, +and `rb msvc exec ...` now tries to run a program named `exec` and fails +through cmd. + ### Shell selection for `rb msvc enable` The prototype takes the shell as an explicit argument and defaults to @@ -107,12 +157,12 @@ each year maps to a fixed `vswhere -version` range (`2019 = [16.0,17.0)`), so selection is one extra argument on the existing query and `-latest` still picks the newest within the range. The explicit `latest` value exists to override the env var per -invocation. `rb msvc list` shows the installed toolchains, newest +invocation. `rb msvc --list` shows the installed toolchains, newest first, with a `*` on the one default resolution would pick (same notation as `rb list`). -For `exec`, options are recognized only before the command and `--` -ends option parsing, so the user command is never reinterpreted. When +For the passthrough, options are recognized only before the command and +`--` ends option parsing, so the user command is never reinterpreted. When the requested year is not installed, the warning names it, lists the years that are, and suggests the matching `Microsoft.VisualStudio..BuildTools` winget package. @@ -126,15 +176,16 @@ organization standard, not the normal path. One trap for future readers: the year cannot be taken from vswhere's `catalog.productLineVersion`. The Dev18 series reports `18` there even though its displayName says "Visual Studio Build Tools 2026", so the -year shown by `rb msvc list` (and matched by `--vsver`) is derived +year shown by `rb msvc --list` (and matched by `--vsver`) is derived from the `installationVersion` major instead. `--vsver` deliberately does not cover VsDevCmd's `-vcvars_ver` (the toolset-within-an-install axis); a future `--toolset` can add that -without touching this interface. A persistent `rb msvc use ` is -also deliberately absent: rbmanager has no config file, and a -persistent pin would go silently stale when VS installs change, the -same staleness argument that rejected caching below. +without touching this interface. A persistent pin is also deliberately +absent: rbmanager has no config file, and a stored pin would go silently +stale when VS installs change, the same staleness argument that rejected +caching below. Were it ever wanted, it would have to be flag-shaped +(`rb msvc --pin `), since `use` is a bare word. ## VS discovery @@ -187,12 +238,12 @@ cmd /s /c "call "\Common7\Tools\VsDevCmd.bat" \ child inherits the calling shell's environment, so diffing the captured `set` output against rb's own environment yields exactly the variables VsDevCmd added or changed (PATH, INCLUDE, LIB, LIBPATH, -VCToolsRedistDir, and the VSCMD bookkeeping vars). `rb msvc exec` +VCToolsRedistDir, and the VSCMD bookkeeping vars). `rb msvc ` applies that delta to the child it spawns; `rb msvc enable` prints it as `set "K=V"` (cmd) or `$env:K = '...'` (PowerShell, single-quoted literal with `'` doubled). -`rb msvc exec` routes the user command through `cmd /s /c` so that `.cmd` +The passthrough routes the user command through `cmd /s /c` so that `.cmd` shims (`gem`, `bundle`) and PATHEXT resolve the way they would if the user had typed the command directly; a bare `CreateProcess` would not find `gem` (it is `gem.cmd`). @@ -206,7 +257,7 @@ name and the loader refuses to find it in the current directory, which surfaces as the same cryptic "install development tools first" error even though the compiler is present. -Both surfaces clear it for the activated environment: `rb msvc exec` +Both surfaces clear it for the activated environment: the passthrough removes the variable from the child's environment block, and `rb msvc enable` emits the unset (`set "NoDefault...="` for cmd, `Remove-Item Env:\NoDefault...` for PowerShell). This is cheap insurance against a confusing failure and @@ -234,7 +285,7 @@ and no opt-dir pointing at any such tree on the destination machine. ### Recommendation: phase 1 is compiler-only -Ship `rb msvc exec`/`rb msvc enable` as compiler-only first, and document the +Ship `rb msvc`/`rb msvc enable` as compiler-only first, and document the dependency-linking limitation. This unblocks the large class of pure-C gems immediately, is small and low-risk, and does not commit rbmanager to shipping or versioning a pile of vcpkg dev files whose provenance and @@ -281,20 +332,20 @@ should not depend on it.) ## Prototype -`src/rbmanager/Msvc.cs` implements both subcommands, wired into -`Program.cs`'s dispatch switch as `rb msvc enable [shell]` and -`rb msvc exec `. It is ~180 lines, marked as a prototype, and -covers VS discovery, VsDevCmd activation with env-diffing, the +`src/rbmanager/Msvc.cs` implements the whole surface, including its own +argument parser; `Program.cs`'s dispatch switch hands it everything +after the `msvc` token. It is marked as a prototype and covers VS +discovery, VsDevCmd activation with env-diffing, the `NoDefaultCurrentDirectoryInExePath` clearing, and the per-shell output. It is compiler-only (phase 1). What was exercised: - `rb msvc enable powershell|cmd` prints correct assignments; the PowerShell form activates a live session via `| Invoke-Expression`. -- `rb msvc exec ruby -rmkmf -e "find_executable('cl')"` finds the compiler. -- `rb msvc exec` drives a full `extconf.rb` -> `nmake` -> load of a native - extension. +- `rb msvc ruby -rmkmf -e "find_executable('cl')"` finds the compiler. +- The passthrough drives a full `extconf.rb` -> `nmake` -> load of a + native extension. -A `gem install msgpack` under `rb msvc exec` compiled several files (proving +A `gem install msgpack` under the passthrough compiled several files (proving the toolchain is live) before failing on an `RBIMPL_UNREACHABLE_RETURN`/`C2059` macro error in msgpack 1.8.3 against Ruby 4.0's headers. That is an upstream gem/source incompatibility, not @@ -305,7 +356,7 @@ appearing on the compile line. 1. Auto-detect the parent shell for `rb msvc enable`, or keep the explicit argument with a default? (Prototype: explicit, default PowerShell.) -2. Should `rb msvc exec`/`rb msvc enable` also guarantee the active ruby's +2. Should `rb msvc`/`rb msvc enable` also guarantee the active ruby's `current\bin` is on PATH, or continue to rely on `install` having put it there? (Prototype relies on install.) 3. Phase 2 trigger: is dependency-linking demand high enough to justify diff --git a/docs/test-plan.md b/docs/test-plan.md index 17fd7ec..4876dd6 100644 --- a/docs/test-plan.md +++ b/docs/test-plan.md @@ -34,9 +34,9 @@ Command surface and contracts: | `rb list` | Installed names sorted, active one starred | 0 | | `rb use ` | Resolve query (exact or case-insensitive substring; must be unambiguous), recreate the `current` junction, ensure PATH | 0 / 1 | | `rb uninstall ` | Resolve; if active, delete the junction first and print a hint; delete the install dir recursively | 0 / 1 | -| `rb msvc enable [--vsver ] [shell]` | Locate VsDevCmd via vswhere (narrowed to the requested VS product year, if any; `--vsver` > `RBMANAGER_VSVER` > newest), compute the env delta of activation, print per-shell assignments plus an unset of `NoDefaultCurrentDirectoryInExePath`. Shell defaults to PowerShell; `cmd`/`bat` selects cmd syntax. No toolchain: actionable warning on stderr | 0 / 1 | -| `rb msvc exec [--vsver ] [--] ` | Same delta applied to a `cmd /s /c` child (so `.cmd` shims resolve); removes `NoDefaultCurrentDirectoryInExePath`; propagates the child's exit code. Options are leading-only; `--` ends option parsing | child / 1 | -| `rb msvc list` | All installs carrying the MSVC toolset, newest first, `*` on the one default resolution would pick. No toolchain: same warning as enable/exec | 0 / 1 | +| `rb msvc [--vsver ] [--] ` | Locate VsDevCmd via vswhere (narrowed to the requested VS product year, if any; `--vsver` > `RBMANAGER_VSVER` > newest), compute the env delta of activation and apply it to a `cmd /s /c` child (so `.cmd` shims resolve); removes `NoDefaultCurrentDirectoryInExePath`; propagates the child's exit code. Options are leading-only; `--` ends option parsing. `enable` is the only reserved word after `msvc` | child / 1 | +| `rb msvc enable [--vsver ] [shell]` | Same delta printed as per-shell assignments plus an unset of `NoDefaultCurrentDirectoryInExePath`. Shell defaults to PowerShell; `cmd`/`bat` selects cmd syntax. `--vsver` parses on either side of `enable`. No toolchain: actionable warning on stderr | 0 / 1 | +| `rb msvc --list` | All installs carrying the MSVC toolset, newest first, `*` on the one default resolution would pick. Terminal: nothing may follow it. No toolchain: same warning as the other two | 0 / 1 / 2 | | anything else | Usage text | 2 | Any thrown exception is caught in `Main`, printed as `rb: ` to @@ -210,10 +210,10 @@ Drive the exe built by `dotnet build` (see section 5 for AOT). 34. No args → usage on stdout, exit 2. 35. Unknown command → usage, exit 2. 36. `install` with no argument, `use` with no argument, `msvc` with no - subcommand, `msvc exec` with no command → usage, exit 2 (the - `msvc exec` pattern requires a non-empty command). Malformed msvc - options too: `msvc exec --vsver` (no value), `msvc exec --vsver - 2022` (no command), `msvc enable --vsver` (no value), `msvc enable` + command → usage, exit 2 (the `msvc` parser requires a non-empty + command). Malformed msvc options too: `msvc --vsver` (no value), + `msvc --vsver 2022` (no command), `msvc --list cl` (a command after + the terminal query), `msvc enable --vsver` (no value), `msvc enable` with two shells. 37. Failing command (e.g. `use nosuch`) → stderr starts with `rb: `, exit 1, stdout empty. @@ -298,6 +298,19 @@ already taken by the AOT publish smoke): 78. `VsVerRanges` maps exactly 2017/2019/2022/2026 to the `[15.0,16.0)`-style installationVersion ranges. +Added when `msvc exec ` became `msvc ` and `msvc list` +became `msvc --list`: + +86. `Parse` (everything after the `msvc` token): a bare command passes + through verbatim, with or without a leading `--vsver`; `enable` + reaches the enable path and takes `--vsver` on either side of it; + `--` makes even `enable` a command; `--list` is terminal, so a + command (or the word `enable`) after it → null, while a year before + it is read and unused; option recognition stops at the first command + token, so `ruby --list` is a two-token command; bare `msvc`, a + missing or empty `--vsver` value, an unknown leading option, `--` + alone, and two shells after `enable` → null. + ### 4.9 Msvc: activation with a stub VsDevCmd — Integration Stub `.bat` fixture written per test, e.g. sets `RB_TEST_NEW=hello`, @@ -325,9 +338,9 @@ contains `=` and one containing non-ASCII, and `exit /b 0`. child sees the stub's variables and does not see `NoDefaultCurrentDirectoryInExePath` (set it in the test process first). -69. `Exec` exit-code propagation: `rb msvc exec cmd /c exit 7` → 7. +69. `Exec` exit-code propagation: `rb msvc cmd /c exit 7` → 7. 70. `Exec` resolves `.cmd` shims: put a `hello.cmd` on the stub-added - PATH dir, `msvc exec hello` → runs it (proves the `cmd /s /c` routing + PATH dir, `msvc hello` → runs it (proves the `cmd /s /c` routing and PATHEXT behavior). 71. `Exec` argument quoting: an argument with spaces survives to the child (child echoes `%1`-style or a tiny script writes its argv to @@ -343,9 +356,15 @@ Added with `--vsver`: 81. `Enable`/`Exec` with a requested year and `VsWhere` nonexistent → stderr names the year and suggests the matching `Microsoft.VisualStudio..BuildTools` winget id, exit 1. -82. `List` with `VsWhere` nonexistent → same warning as enable/exec, +82. `List` with `VsWhere` nonexistent → same warning as the other two, exit 1, stdout empty. +Added with the command-surface change: + +87. `Dispatch` routes a parsed request to the operation it names: + `enable cmd` prints the stub's assignments, and `cmd /c exit 7` + runs as a command and propagates 7. + ### 4.10 Msvc against real Visual Studio — RequiresVS (opt-in) Skipped unless vswhere resolves an install (use a runtime skip, e.g. @@ -353,15 +372,15 @@ Skipped unless vswhere resolves an install (use a runtime skip, e.g. 72. `LocateVsDevCmd` returns an existing `VsDevCmd.bat`. 73. `ActivatedDelta` includes `INCLUDE`, `LIB`, and a `PATH` change. -74. `rb msvc exec cl` (E2E) exits 0 with cl's banner on stderr. +74. `rb msvc cl` (E2E) exits 0 with cl's banner on stderr. Added with `--vsver`: 83. `LocateVsDevCmd()` for every installed year resolves a `VsDevCmd.bat` under that year's install path. -84. `rb msvc list` (E2E) prints one line per install, newest first, +84. `rb msvc --list` (E2E) prints one line per install, newest first, `*` on the first, install path on each line. -85. `rb msvc exec --vsver cl` (E2E) runs cl. +85. `rb msvc --vsver cl` (E2E) runs cl. ### 4.11 AOT publish smoke — E2E (opt-in, slow) @@ -429,6 +448,6 @@ a comment. Each is a product decision to make separately. 5. Dangling `current` (target deleted out of band) has unpinned semantics in `CurrentTarget`/`Uninstall` (case 30 pins it). 6. `ParseShell` is case-sensitive (`PowerShell` is rejected). -7. `QuoteArg` does not escape embedded quotes; `rb msvc exec` with an - argument containing `"` produces a broken cmd line (case 60 pins - the helper's output only). +7. `QuoteArg` does not escape embedded quotes; `rb msvc ` + with an argument containing `"` produces a broken cmd line (case 60 + pins the helper's output only). diff --git a/src/rbmanager/Msvc.cs b/src/rbmanager/Msvc.cs index 0bf0b10..aa2d89e 100644 --- a/src/rbmanager/Msvc.cs +++ b/src/rbmanager/Msvc.cs @@ -13,14 +13,14 @@ namespace RbManager; // Studio's Developer Command Prompt), and exposes that environment two // ways: // -// rb msvc enable [--vsver ] [cmd|powershell|pwsh] -// print env assignments to eval -// in the current shell -// rb msvc exec [--vsver ] [--] +// rb msvc [--vsver ] [--] // run one command with the // toolchain already applied // (no shell mutation) -// rb msvc list list installed VS C++ toolchains +// rb msvc enable [--vsver ] [cmd|powershell|pwsh] +// print env assignments to eval +// in the current shell +// rb msvc --list list installed VS C++ toolchains // // The VS version is picked as --vsver flag > RBMANAGER_VSVER > newest // installed. See docs/msvc-enable.md for the design rationale. @@ -78,6 +78,61 @@ private static readonly (string Year, int Major)[] VsProducts = return v; } + // What `rb msvc ` resolves to. `enable` is the only word + // reserved after `msvc`; every other bare word is the user's command, + // so a future operation has to be spelled as a flag (like --list) + // rather than as a word that would shadow a real executable. + internal enum Op { Run, Enable, List } + + private const string EnableWord = "enable"; + + // Arguments after the `msvc` token: the leading options (--list, + // --vsver , --vsver=) followed by either the `enable` + // subcommand or the command to run. Options are recognized only + // before the first non-option token, and `--` ends option reading, + // so the user command is never reinterpreted. Returns null when the + // arguments do not parse (caller prints usage). + internal static (Op Op, string? Shell, string[] Command, string? VsVer)? Parse(string[] args) + { + bool list = false; + int i = 0; + while (i < args.Length) + { + string a = args[i]; + if (a == "--list") { list = true; i++; } + else if (a == "--vsver") + { + if (i + 1 >= args.Length) return null; + i += 2; + } + else if (a.StartsWith("--vsver=", StringComparison.Ordinal)) i++; + else break; + } + string[] rest = args[i..]; + + // --list reports instead of acting, so it is terminal: nothing + // may follow it, and a year (which only narrows what enable and + // the passthrough activate) does not apply to it. + if (list) return rest.Length == 0 ? (Op.List, null, [], null) : null; + + // The `enable` token is dropped and everything else handed to + // EnableArgs, so --vsver parses on either side of it. + if (rest is [EnableWord, ..]) + return EnableArgs([.. args[..i], .. rest[1..]]) is { } en + ? (Op.Enable, en.Shell, [], en.VsVer) + : null; + + return ExecArgs(args) is { } ex ? (Op.Run, null, ex.Command, ex.VsVer) : null; + } + + public static int Dispatch((Op Op, string? Shell, string[] Command, string? VsVer) request) => + request.Op switch + { + Op.List => List(), + Op.Enable => Enable(request.Shell, request.VsVer), + _ => Exec(request.Command, request.VsVer), + }; + // enable arguments: [--vsver ] [shell], in either order. // Returns null when the arguments do not parse (caller prints usage). internal static (string? Shell, string? VsVer)? EnableArgs(string[] args) @@ -102,8 +157,8 @@ internal static (string? Shell, string? VsVer)? EnableArgs(string[] args) return (shell, vsver); } - // exec arguments: [--vsver ] [--] . Options are - // recognized only before the command, so the user command is never + // passthrough arguments: [--vsver ] [--] . Options + // are recognized only before the command, so the user command is never // reinterpreted; `--` ends option parsing for commands that start // with a dash. Returns null when the arguments do not parse or no // command remains (caller prints usage). @@ -306,7 +361,7 @@ internal static List Installs() .ToList(); } - // `rb msvc list`: one line per install, `*` marking what the current + // `rb msvc --list`: one line per install, `*` marking what the current // default resolution (--vsver unset, so RBMANAGER_VSVER or newest) // would pick, in the same style as `rb list`. public static int List() diff --git a/src/rbmanager/Program.cs b/src/rbmanager/Program.cs index 8f8a220..1db3c54 100644 --- a/src/rbmanager/Program.cs +++ b/src/rbmanager/Program.cs @@ -29,11 +29,11 @@ private static async Task Main(string[] args) ["list"] => List(), ["use", var name] => Use(name), ["uninstall", var name] => Uninstall(name), - ["msvc", "enable", .. var rest] => - Msvc.EnableArgs(rest) is { } en ? Msvc.Enable(en.Shell, en.VsVer) : Usage(), - ["msvc", "exec", .. var rest] => - Msvc.ExecArgs(rest) is { } ex ? Msvc.Exec(ex.Command, ex.VsVer) : Usage(), - ["msvc", "list"] => Msvc.List(), + // Everything after `msvc` belongs to Msvc's own parser: it + // owns one reserved word (`enable`) and passes the rest + // through as the user's command line. + ["msvc", .. var rest] => + Msvc.Parse(rest) is { } msvc ? Msvc.Dispatch(msvc) : Usage(), _ => Usage(), }; } @@ -54,10 +54,10 @@ setup [--yes] copy rb onto PATH and set up the VC++ runtime list list installed rubies use switch the active ruby uninstall remove an installed ruby + msvc run a command with the MSVC build env applied msvc enable [shell] print the MSVC build env to eval (cmd|powershell) - msvc exec run a command with the MSVC build env applied - (both accept --vsver to pick a VS version) - msvc list list installed Visual Studio C++ toolchains + msvc --list list installed Visual Studio C++ toolchains + (msvc and msvc enable accept --vsver ) """); return 2; } diff --git a/tests/rbmanager.Tests/CliE2eTests.cs b/tests/rbmanager.Tests/CliE2eTests.cs index 3bc0e41..f548ede 100644 --- a/tests/rbmanager.Tests/CliE2eTests.cs +++ b/tests/rbmanager.Tests/CliE2eTests.cs @@ -35,9 +35,9 @@ public void UnknownCommand_Usage_Exit2() [InlineData("install")] [InlineData("use")] [InlineData("msvc")] - [InlineData("msvc", "exec")] - [InlineData("msvc", "exec", "--vsver")] - [InlineData("msvc", "exec", "--vsver", "2022")] + [InlineData("msvc", "--vsver")] + [InlineData("msvc", "--vsver", "2022")] + [InlineData("msvc", "--list", "cl")] [InlineData("msvc", "enable", "--vsver")] [InlineData("msvc", "enable", "cmd", "pwsh")] public void MissingRequiredArgument_Usage_Exit2(params string[] command) diff --git a/tests/rbmanager.Tests/MsvcActivationTests.cs b/tests/rbmanager.Tests/MsvcActivationTests.cs index d1a804a..2b3a1da 100644 --- a/tests/rbmanager.Tests/MsvcActivationTests.cs +++ b/tests/rbmanager.Tests/MsvcActivationTests.cs @@ -267,6 +267,22 @@ public void List_NoToolchain_WarnOnStderr() Assert.Contains("no Visual Studio C++ toolchain found", cap.Err); } + [Fact] // case 87: Dispatch routes a parsed request to the right operation + public void Dispatch_RoutesEnableAndCommand() + { + using var tmp = new TempDir(); + using var env = new EnvScope(); + env.Set("RBMANAGER_VSDEVCMD", Bat(tmp, "set RB_TEST_NEW=hello")); + + using (var cap = new ConsoleCapture()) + { + Assert.Equal(0, Msvc.Dispatch(Msvc.Parse(["enable", "cmd"])!.Value)); + Assert.Contains("set \"RB_TEST_NEW=hello\"", cap.OutLines); + } + + Assert.Equal(7, Msvc.Dispatch(Msvc.Parse(["cmd", "/c", "exit", "7"])!.Value)); + } + [Fact] // case 71: an argument with spaces survives as one argument public void Exec_QuotesArgumentWithSpaces() { diff --git a/tests/rbmanager.Tests/MsvcHelperTests.cs b/tests/rbmanager.Tests/MsvcHelperTests.cs index 3ca4635..241e50d 100644 --- a/tests/rbmanager.Tests/MsvcHelperTests.cs +++ b/tests/rbmanager.Tests/MsvcHelperTests.cs @@ -134,6 +134,81 @@ public void ExecArgs_Malformed_Null() Assert.Null(Msvc.ExecArgs(["--"])); // separator alone } + private static void AssertParsedRun(string[] command, string? vsver, params string[] args) + { + var parsed = Msvc.Parse(args); + Assert.NotNull(parsed); + Assert.Equal(Msvc.Op.Run, parsed.Value.Op); + Assert.Equal(command, parsed.Value.Command); + Assert.Equal(vsver, parsed.Value.VsVer); + } + + private static void AssertParsedEnable(string? shell, string? vsver, params string[] args) + { + var parsed = Msvc.Parse(args); + Assert.NotNull(parsed); + Assert.Equal(Msvc.Op.Enable, parsed.Value.Op); + Assert.Equal(shell, parsed.Value.Shell); + Assert.Equal(vsver, parsed.Value.VsVer); + } + + [Fact] // case 86: a bare command is the user's, passed through verbatim + public void Parse_Command_PassesThrough() + { + AssertParsedRun(["gem", "install", "nokogiri"], null, "gem", "install", "nokogiri"); + AssertParsedRun(["gem", "install", "nokogiri"], "2022", + "--vsver", "2022", "gem", "install", "nokogiri"); + AssertParsedRun(["cl"], "2022", "--vsver=2022", "cl"); + } + + [Fact] // case 86: `enable` is the one reserved word, --vsver on either side + public void Parse_Enable_ReachesEnable() + { + AssertParsedEnable(null, null, "enable"); + AssertParsedEnable("cmd", null, "enable", "cmd"); + AssertParsedEnable("powershell", "2022", "enable", "--vsver", "2022", "powershell"); + AssertParsedEnable("powershell", "2022", "--vsver", "2022", "enable", "powershell"); + } + + [Fact] // case 86: `--` makes even the reserved word a command + public void Parse_DoubleDash_EnableIsCommand() + { + AssertParsedRun(["enable"], null, "--", "enable"); + AssertParsedRun(["enable", "cmd"], "2022", "--vsver", "2022", "--", "enable", "cmd"); + } + + [Fact] // case 86: --list is terminal + public void Parse_List() + { + var parsed = Msvc.Parse(["--list"]); + Assert.NotNull(parsed); + Assert.Equal(Msvc.Op.List, parsed.Value.Op); + // a year does not apply to a query, so it is read but unused + Assert.Equal(Msvc.Op.List, Msvc.Parse(["--vsver", "2022", "--list"])!.Value.Op); + + Assert.Null(Msvc.Parse(["--list", "cl"])); // command after the query + Assert.Null(Msvc.Parse(["--list", "enable"])); // reserved word too + } + + [Fact] // case 86: option recognition stops at the first command token + public void Parse_OptionsAreLeadingOnly() + { + AssertParsedRun(["ruby", "--list"], null, "ruby", "--list"); + AssertParsedRun(["ruby", "--vsver", "2022"], null, "ruby", "--vsver", "2022"); + } + + [Fact] // case 86 + public void Parse_Malformed_Null() + { + Assert.Null(Msvc.Parse([])); // bare `rb msvc` + Assert.Null(Msvc.Parse(["--vsver"])); // missing value + Assert.Null(Msvc.Parse(["--vsver", "2022"])); // option but no command + Assert.Null(Msvc.Parse(["--vsver=", "cl"])); // empty value + Assert.Null(Msvc.Parse(["--bogus", "cl"])); // unknown leading option + Assert.Null(Msvc.Parse(["--"])); // separator alone + Assert.Null(Msvc.Parse(["enable", "cmd", "pwsh"])); // two shells + } + [Fact] // case 78: the year map covers exactly the VsDevCmd-era products public void VsVerRanges_YearToInstallationVersionRange() { diff --git a/tests/rbmanager.Tests/MsvcVsTests.cs b/tests/rbmanager.Tests/MsvcVsTests.cs index f55ccd0..6e21592 100644 --- a/tests/rbmanager.Tests/MsvcVsTests.cs +++ b/tests/rbmanager.Tests/MsvcVsTests.cs @@ -38,7 +38,7 @@ public void Exec_Cl_RunsCompiler() Skip.If(RealVsDevCmd() is null, "no Visual Studio C++ toolchain installed"); using var sb = new E2eSandbox(); - RbResult r = sb.Run("msvc", "exec", "cl"); + RbResult r = sb.Run("msvc", "cl"); // cl with no input files prints its version banner to stderr. Assert.Contains("Microsoft", r.Err); @@ -67,7 +67,7 @@ public void List_MarksDefaultAndPrintsYears() Skip.If(installs.Count == 0, "no Visual Studio C++ toolchain installed"); using var sb = new E2eSandbox(); - RbResult r = sb.Run("msvc", "list"); + RbResult r = sb.Run("msvc", "--list"); Assert.Equal(0, r.ExitCode); string[] lines = r.Out.Replace("\r\n", "\n").TrimEnd('\n').Split('\n'); @@ -78,7 +78,7 @@ public void List_MarksDefaultAndPrintsYears() Assert.Contains(install.Path, line); } - [SkippableFact] // case 85: exec with an installed year still finds cl + [SkippableFact] // case 85: the passthrough with an installed year still finds cl public void Exec_WithVsVer_RunsCompiler() { var installs = Msvc.Installs(); @@ -87,7 +87,7 @@ public void Exec_WithVsVer_RunsCompiler() Skip.If(!Msvc.VsVerRanges.ContainsKey(year), $"unmapped product year {year}"); using var sb = new E2eSandbox(); - RbResult r = sb.Run("msvc", "exec", "--vsver", year, "cl"); + RbResult r = sb.Run("msvc", "--vsver", year, "cl"); Assert.Contains("Microsoft", r.Err); }