From b83348a01b70e5b91750a0c6d0a00258aa94995e Mon Sep 17 00:00:00 2001 From: Will Fuqua Date: Sun, 28 Jun 2026 14:53:57 +0700 Subject: [PATCH 1/2] Allow non-interactive use for inspect command --- .claude/skills/csharp-eval/SKILL.md | 14 +- .claude/skills/csharprepl-inspect/SKILL.md | 95 +++++++++++ AGENTS.md | 4 +- ARCHITECTURE.md | 2 + .../Remote/RemoteValueRenderer.cs | 24 +++ CSharpRepl.Services/Roslyn/RoslynServices.cs | 4 + CSharpRepl/Program.cs | 51 +++--- .../Repls/InspectorCommandResultPrinter.cs | 59 +++++++ CSharpRepl/Repls/RemotePipedInputEvaluator.cs | 156 ++++++++++++++++++ CSharpRepl/Repls/RemoteReadEvalPrintLoop.cs | 45 +---- .../RemotePipedInputEvaluatorTests.cs | 110 ++++++++++++ .../RemoteValueRendererTests.cs | 17 ++ 12 files changed, 512 insertions(+), 69 deletions(-) create mode 100644 .claude/skills/csharprepl-inspect/SKILL.md create mode 100644 CSharpRepl/Repls/InspectorCommandResultPrinter.cs create mode 100644 CSharpRepl/Repls/RemotePipedInputEvaluator.cs create mode 100644 Tests/CSharpRepl.Tests/RemotePipedInputEvaluatorTests.cs diff --git a/.claude/skills/csharp-eval/SKILL.md b/.claude/skills/csharp-eval/SKILL.md index aeef67e2..19a60ecb 100644 --- a/.claude/skills/csharp-eval/SKILL.md +++ b/.claude/skills/csharp-eval/SKILL.md @@ -1,6 +1,6 @@ --- name: csharp-eval -description: Run / execute C# snippets non-interactively with the csharprepl CLI to observe real runtime behavior — return values, exceptions, serialized output — and to probe how a NuGet package actually behaves when called. The complement to dotnet-inspect; that tool inspects static API surface without executing; this one runs code. Use whenever you need to know what C# *does*, not just what an API *looks like*. +description: Run / execute C# snippets non-interactively with the csharprepl CLI to observe real runtime behavior — return values, exceptions, serialized output — and to probe how a NuGet package actually behaves when called. The complement to dotnet-inspect; that tool inspects static API surface without executing; this one runs code. Use whenever you need to know what C# *does*, not just what an API *looks like*. (To evaluate C# *inside* a running process and read its live state, see the csharprepl-inspect skill.) --- # csharp-eval @@ -72,10 +72,18 @@ csharprepl -e 'JsonConvert.SerializeObject(new[] { 1, 2, 3 })' -r 'nuget: Newton - The evaluation **result** is the last thing on stdout. The first time a package is referenced, NuGet prints a few restore-progress lines before it; cached runs print just the result. +## Evaluating inside a running process + +The same CLI can attach to a *separate, already-running* .NET process and evaluate C# **inside it**, against +its live state (`csharprepl inspect `). That's a distinct workflow (the target must be launched with the +inspector enabled, state persists across calls, and you can detour live methods) with its own safety caveats +— see the **csharprepl-inspect** skill. + ## Gotchas -- **No state across calls.** Each invocation is a fresh process — variables, `using`s, and references - do not carry over between runs. Make every snippet self-contained (include its own `#r` / `using`). +- **No state across calls.** Each invocation is a fresh process — variables, `using`s, and references do not + carry over between runs. Make every snippet self-contained (include its own `#r` / `using`). (Inspect mode + is the exception — see the csharprepl-inspect skill.) - **First restore is slow.** The first time a package is referenced it's downloaded; later runs are fast (cached under `~/.csharprepl/packages`). - **Errors go to stderr with a nonzero exit code.** Compilation and runtime errors are written to diff --git a/.claude/skills/csharprepl-inspect/SKILL.md b/.claude/skills/csharprepl-inspect/SKILL.md new file mode 100644 index 00000000..9e186d3d --- /dev/null +++ b/.claude/skills/csharprepl-inspect/SKILL.md @@ -0,0 +1,95 @@ +--- +name: csharprepl-inspect +description: Attach to a running, inspector-enabled .NET process with `csharprepl inspect ` and evaluate C# *inside* it — read and modify its live objects, statics, and DI services, and detour live methods (#replace/#wrap). Use to debug or probe a real running app's in-memory state, not static API surface (that's dotnet-inspect) or a throwaway snippet in a fresh process (that's csharp-eval). Dev/diagnostics only — code runs with the target's full privileges, never point it at production. +--- + +# csharprepl-inspect + +Evaluate C# **inside a separate, already-running .NET process** and see/modify its live state. `csharprepl` +injects a real Roslyn engine into a target you launched with the inspector enabled; you then send code +non-interactively (same flags and output as the local REPL) and it runs in that process against its actual +in-memory objects. + +## When to use this vs. csharp-eval vs. dotnet-inspect + +- **"What's the live state of my running app?"** / **"Change a method's behavior in the running process"** + → **this skill** (`csharprepl inspect `). Code runs *inside* the target. +- **"What does this code do?"** (a self-contained snippet, fresh throwaway process) → **csharp-eval**. +- **"What does this API look like?"** (signatures, members, docs — no execution) → **dotnet-inspect**. + +The eval mechanics here — `-e` / `--eval-file`, piped stdin, quoting, `-r "nuget: ..."`, the clean-stdout / +errors-to-stderr / nonzero-exit contract — are **identical to csharp-eval**; see that skill for those +details. This skill covers only what's different about attaching to a live process. + +## ⚠️ Safety + +Evaluated code runs with the **target process's full privileges** — it's RCE-equivalent for same-user code. +Only attach to a process **you control** for development/diagnostics. **Never enable the inspector on, or +attach to, a production process.** + +## 1. Enable the target (one-time, at launch) + +The target only accepts connections if it was *started* with the inspector hook — you cannot enable an +already-running process. `inspect init` prints the env vars to set in the shell that launches it: + +``` +csharprepl inspect init # prints DOTNET_STARTUP_HOOKS=... and ASPNETCORE_HOSTINGSTARTUPASSEMBLIES=... + # auto-detects your shell; override with --shell bash|pwsh|cmd|fish +``` + +Set those env vars in the launching shell only (not system- or user-wide), then start the app normally +(e.g. `dotnet run`). It's now attachable for the life of that process. + +## 2. Attach and evaluate + +``` +csharprepl inspect list # list inspector-enabled processes + their PIDs +csharprepl inspect -e 'System.Environment.ProcessId' # -> the target's PID; confirms code runs in the target +csharprepl inspect --eval-file probe.csx # multi-line, same as local +echo 'SomeApp.Program.SomeStatic' | csharprepl inspect # piped stdin works too +``` + +- **Reach the target's state** by fully-qualified name (`MyApp.Program.SomeStatic`), or, when its DI provider + was captured, via `services.GetRequiredService()` / `Get()` (the connect banner reports whether the + DI provider was captured). +- **State persists across calls** (unlike local `csharp-eval`, where each run is a fresh process): the target + holds the script-state chain, so a `var` declared in one `inspect -e` invocation is usable in the + next. This lets you build up state with one-shot calls. + +## 3. Live method replacement + +While attached you can detour a live method to a REPL-defined delegate, changing the running app's behavior +immediately: + +- `#replace with ` — swap the implementation. +- `#wrap with ` — keep the original, callable via an `orig` first parameter. +- `#patches` — list active patches; `#revert ` / `#revert all` — undo them. + +Instance methods take the instance as the first delegate parameter; a static method omits it. Define the +helper in one call, then `#replace` in the next — they share state across calls: + +``` +csharprepl inspect -e 'decimal Half(MyApp.OrderService svc, int qty, decimal unit) => qty * unit * 0.5m;' +csharprepl inspect -e '#replace MyApp.OrderService.CalculatePrice with Half' +csharprepl inspect -e '#patches' # list active patches +csharprepl inspect -e '#revert all' # undo them +``` + +- A command (`#replace`/`#wrap`/`#patches`/`#revert`) must be the **whole** submission — don't combine a + definition and a command in one `-e` or one piped block, since collected stdin is sent as a single C# + submission (so `#replace` would be compiled as invalid C#). To do it in one pipe, use `--streamPipedInput`, + which evaluates line by line. +- Patches **persist in the target until reverted** (or it exits) — they outlive your detach, so revert when + done. +- Not supported: generic methods, pointer params, and `#wrap` with by-ref parameters. + +## Gotchas + +- **Can't attach if not enabled.** `inspect ` fails unless the target was launched with the env vars + from `inspect init` (step 1). Use `inspect list` to see what's actually attachable. +- **Self-contained single-file targets are rejected** — their assemblies are bundled in memory with no + on-disk path, so the engine can't compile against them. Inspect a framework-dependent build instead. (A + *framework-dependent* single-file app connects but can only reach its own types via reflection.) +- **Detach leaves the target running.** `inspect` exits cleanly; the process keeps going and you can + reconnect — but any patches you applied stay in effect until reverted. +- Everything else (quoting, NuGet refs, errors→stderr, exit codes) works as in **csharp-eval**. diff --git a/AGENTS.md b/AGENTS.md index aaf1f612..e7ac86e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ The test runner is **Microsoft.Testing.Platform** with the **xUnit v3** runner ( - Heavy Roslyn/integration tests share `[Collection(nameof(RoslynServices))]` and run **serially** on purpose: the loader's `AssemblyLoadContext.Resolving` hooks (attached to the process-global Default ALC by `AssemblyLoadContextHook` and never detached) are process-global, not per-`RoslynServices`. The full suite is ~2 minutes. (`MSBuildLocator.RegisterDefaults()` is also process-global, but a `[ModuleInitializer]` in `TestAssemblyInitializer` runs it once at assembly load, so it's no longer the reason for the collection — and tests that only needed MSBuildLocator, like `NugetPackageInstallerTests`, can now run in isolation.) - Some tests spawn `dotnet build` / MSBuild subprocesses (solution/project references) and a few touch the network (NuGet install). These are the slow ones and can occasionally be flaky. -- The inspect-feature integration tests (`InspectorRoundTripTests`, `InspectorCancellationTests`, `RemoteEditorServicesTests`, `InspectorServerProtocolTests`, `RemoteReadEvalPrintLoopTests`) **launch a real hooked child process** — the interactive PrettyPrompt loop itself cannot be driven without a TTY, so `RemoteReadEvalPrintLoopTests` stubs `IPrompt` (like `ReadEvalPrintLoopTests`) and everything below it is real. Two more inspect suites run in-process without a child: `InspectorEngineTests` hosts the real engine + Roslyn inside the test process, and `InspectorTransportTests` exercises the real OS pipe/socket transport (note: the Windows pipe uses zero-byte buffers, so a write rendezvouses with the peer's read — keep the read pending while writing). +- The inspect-feature integration tests (`InspectorRoundTripTests`, `InspectorCancellationTests`, `RemoteEditorServicesTests`, `InspectorServerProtocolTests`, `RemoteReadEvalPrintLoopTests`, `RemotePipedInputEvaluatorTests`) **launch a real hooked child process** — the interactive PrettyPrompt loop itself cannot be driven without a TTY, so `RemoteReadEvalPrintLoopTests` stubs `IPrompt` (like `ReadEvalPrintLoopTests`) and everything below it is real. The **non-interactive** inspect path (`inspect --eval`/`--eval-file`/piped stdin) needs no TTY, so `RemotePipedInputEvaluatorTests` drives the real `RemotePipedInputEvaluator` against a real hooked child directly. Two more inspect suites run in-process without a child: `InspectorEngineTests` hosts the real engine + Roslyn inside the test process, and `InspectorTransportTests` exercises the real OS pipe/socket transport (note: the Windows pipe uses zero-byte buffers, so a write rendezvouses with the peer's read — keep the read pending while writing). ### Benchmarks @@ -90,6 +90,8 @@ This is the crux of the design — the target may already load its own Roslyn, s When attached, csharprepl is a thin **controller**: it compiles nothing for evaluation, sends code strings, and renders the returned `RemoteValue` through the *same* theme/formatting pipeline as local output (`RemoteValueRenderer`). The **scripting world lives in the target** (the engine), but the **workspace world (completion/highlighting) stays in the controller** against a second, remote-configured `RoslynServices` seeded with the target's assembly paths + `InspectorGlobals` — so editor features need no per-keystroke round-trip. The controller advances that remote workspace only when an `EvalResponse` reports `Committed == true`. +Inspect mode also runs **non-interactively** (so agents/scripts can use it without a TTY), mirroring the local REPL's `PipedInputEvaluator`: `inspect --eval`/`--eval-file` or piped stdin route to `RemotePipedInputEvaluator` instead of the interactive `RemoteReadEvalPrintLoop`. It evaluates against the same `RemoteSession`, auto-prints the final value as plain, uncolored, unwrapped text on stdout (errors to stderr, nonzero exit), and honors the same `#replace`/`#wrap`/`#patches`/`#revert` commands (the command-result wording is shared via `InspectorCommandResultPrinter`). It skips the editor-workspace seeding (completion/highlighting are interactive-only) and the connection chatter. Because the engine's state chain lives in the target, separate one-shot `--eval` invocations **accumulate state** across reconnects. + ### Wire protocol A single duplex connection (named pipe on Windows, Unix domain socket elsewhere, **current-user only**). Every frame is a 4-byte little-endian length prefix + UTF-8 JSON body; messages are a `System.Text.Json` **polymorphic** `WireMessage` hierarchy keyed on a `$kind` discriminator (`MessageChannel`). Security model mirrors the .NET diagnostic port — OS access control, no secret. An inspector-enabled process is RCE-equivalent for same-user code and must never run in production. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0aee2fd2..d6782f46 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -69,6 +69,8 @@ This mirrors the local REPL's "two Roslyn worlds kept in sync" idea (see *Roslyn - The **scripting world** (executing code, holding script state) lives in the **target**, inside the engine. This is what evaluation and result-rendering already use. - The **workspace world** (syntax highlighting, autocompletion, symbol lookup) stays in the **controller**, because those are metadata operations that need the target's *types* and prior submission text, not its live object values — so they run locally without a per-keystroke round-trip over the transport. On connect the controller asks the inspector for the target's loaded-assembly paths and constructs a second, remote-configured `RoslynServices` seeded with them plus the inspector globals (so `services`/`Get()` resolve); the ordinary prompt callbacks then drive that target-aware workspace. It advances only when the engine reports a submission committed — the cross-process analogue of how the local REPL keeps its scripting and workspace APIs consistent. +**Non-interactive inspect.** The interactive prompt above (`RemoteReadEvalPrintLoop`) needs a TTY, so for agents and scripts inspect mode also has a headless path that parallels the local REPL's `PipedInputEvaluator`. `inspect --eval`/`--eval-file` or piped stdin route to `RemotePipedInputEvaluator`, which drives the same `RemoteSession`, auto-prints the final value as plain/uncolored/unwrapped text on stdout (errors to stderr, nonzero exit), and honors the same `#replace`/`#wrap`/`#patches`/`#revert` commands (wording shared with the interactive loop via `InspectorCommandResultPrinter`). It skips the editor-workspace seeding above — completion/highlighting are interactive-only, so the reference round-trip would be wasted — and emits no connection chatter. Since the persisted script state lives in the target, separate one-shot `--eval` invocations accumulate state across reconnects. + ### Wire protocol and message flow Controller and inspector talk over a single duplex connection — a named pipe on Windows, a Unix domain socket elsewhere, scoped to the current user. Every message is a `WireMessage` framed by `MessageChannel` as a **4-byte little-endian length prefix followed by a UTF-8 JSON body**; the JSON is a `System.Text.Json` polymorphic hierarchy keyed on a `$kind` discriminator, so a single read returns the right concrete message. Frame lengths are bounded and malformed frames surface as a catchable exception rather than crashing either process. All of these types live in the shared `CSharpRepl.InjectedHook.Contracts` assembly, so they're type-identical on both sides. diff --git a/CSharpRepl.Services/Remote/RemoteValueRenderer.cs b/CSharpRepl.Services/Remote/RemoteValueRenderer.cs index 3b6bf8c2..573651ca 100644 --- a/CSharpRepl.Services/Remote/RemoteValueRenderer.cs +++ b/CSharpRepl.Services/Remote/RemoteValueRenderer.cs @@ -3,6 +3,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. using System; +using System.IO; using CSharpRepl.InjectedHook.Contracts; using CSharpRepl.Services.Roslyn.Formatting; using CSharpRepl.Services.SyntaxHighlighting; @@ -39,6 +40,29 @@ internal sealed class RemoteValueRenderer _ => Styled(value.DisplayText, value.Style), }; + /// + /// Renders a to plain, uncolored, unwrapped text for non-interactive output + /// (inspect <pid> --eval / piped input): the same layout as , with styling + /// and width-wrapping stripped so the value is safe to capture or pipe. + /// + public string RenderToPlainText(RemoteValue value, Level level) => ToPlainText(Render(value, level)); + + /// Renders a Spectre with colors off and wrapping effectively off (very + /// wide profile), so the output carries no ANSI sequences or word-wrap. + private static string ToPlainText(IRenderable renderable) + { + var writer = new StringWriter(); + var plainConsole = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(writer), + }); + plainConsole.Profile.Width = 100_000; + plainConsole.Write(renderable); + return writer.ToString().TrimEnd('\r', '\n'); + } + /// Renders a remote exception as a red-bordered panel, mirroring the local REPL's error rendering. public (IRenderable Renderable, string PlainText) RenderException(RemoteException exception, Level level) { diff --git a/CSharpRepl.Services/Roslyn/RoslynServices.cs b/CSharpRepl.Services/Roslyn/RoslynServices.cs index 6b97d2b4..ef0957cd 100644 --- a/CSharpRepl.Services/Roslyn/RoslynServices.cs +++ b/CSharpRepl.Services/Roslyn/RoslynServices.cs @@ -197,6 +197,10 @@ public async Task EvaluateAsync(string input, string[]? args = public IRenderable RenderRemoteValue(RemoteValue value, Level level) => (remoteValueRenderer ??= new RemoteValueRenderer(highlighter)).Render(value, level); + /// Plain-text analogue of for non-interactive inspect output. + public string RenderRemoteValueToPlainText(RemoteValue value, Level level) => + (remoteValueRenderer ??= new RemoteValueRenderer(highlighter)).RenderToPlainText(value, level); + /// Renders a remote exception as a red-bordered panel plus the plain text to use if error output is redirected. public (IRenderable Renderable, string PlainText) RenderRemoteException(RemoteException exception, Level level) => (remoteValueRenderer ??= new RemoteValueRenderer(highlighter)).RenderException(exception, level); diff --git a/CSharpRepl/Program.cs b/CSharpRepl/Program.cs index ea2a6362..4c4cda5b 100644 --- a/CSharpRepl/Program.cs +++ b/CSharpRepl/Program.cs @@ -31,21 +31,14 @@ internal static class Program internal static Task Main(string[] args) => RunAsync(args); /// - /// Core entry point. The and - /// parameters are testing seams: production calls supply neither, so a real - /// is constructed and the ambient is used. Tests inject a fake - /// console and an explicit redirected-input flag, which lets the piped-input path be exercised - /// deterministically without reading from (or blocking on) the real standard input handle. + /// Core entry point. The and parameters are only provided in testing scenarios. /// internal static async Task RunAsync(string[] args, IConsoleService? console = null, bool? inputRedirectedOverride = null) { - // Tracked as the concrete type for the interactive-prompt path, which hands the raw - // PrettyPromptConsole (protected on IConsoleEx) to the PrettyPrompt library. var systemConsole = console as ConsoleService; if (console is null) { - // Only mutate the process-wide console encoding for real runs. Setting Console.InputEncoding - // resets Console.In, which would discard any reader a test injected (e.g. via Console.SetIn). + // Actually running in a real console in non-test scenarios. Console.InputEncoding = Encoding.UTF8; Console.OutputEncoding = Encoding.UTF8; systemConsole = new ConsoleService(); @@ -63,8 +56,6 @@ internal static async Task RunAsync(string[] args, IConsoleService? console if (config.OutputForEarlyExit is { } earlyExit) { - // Help/version/usage render word-wrapped; machine-consumable output (e.g. `inspect init` exports) - // arrives as PlainText and is written verbatim. Either way, one branch. console.Write(earlyExit); console.WriteLine(); return ExitCodes.Success; @@ -73,20 +64,17 @@ internal static async Task RunAsync(string[] args, IConsoleService? console // initialize roslyn var logger = InitializeLogging(config.Trace); - // inspect mode: connect to the inspector in the target process and run the remote loop instead of - // the local evaluation paths below. It builds its own RoslynServices, seeded with the target's - // references + the inspector globals, so completion/highlighting are target-aware. + // inspect mode: connect to the inspector in the target process and run there instead of the local evaluation paths. if (config.InspectProcessId is { } inspectProcessId) { - return await RunInspectModeAsync(systemConsole, console, appStorage, logger, config, inspectProcessId) + var nonInteractive = config.EvaluateInput is not null || (inputRedirectedOverride ?? Console.IsInputRedirected); + return await RunInspectModeAsync(systemConsole, console, appStorage, logger, config, inspectProcessId, nonInteractive) .ConfigureAwait(false); } var roslyn = new RoslynServices(console, config, logger); - // --eval / --eval-file: evaluate the supplied code non-interactively and exit. Checked before - // the stdin-redirected branch below so it works whether or not stdin is a TTY, and never blocks - // trying to read empty redirected stdin. + // --eval / --eval-file: evaluate the supplied code non-interactively and exit. if (config.EvaluateInput is not null) { return await new PipedInputEvaluator(console, roslyn, config) @@ -108,8 +96,7 @@ internal static async Task RunAsync(string[] args, IConsoleService? console return ExitCodes.ErrorParseArguments; } - // we're being run interactively, start the prompt. This path is production-only — it needs the - // real system console to drive the PrettyPrompt library. + // we're being run interactively, start the prompt. var (prompt, exitCode) = InitializePrompt( systemConsole ?? throw new InvalidOperationException("The interactive prompt requires the real system console."), appStorage, roslyn, config); @@ -135,12 +122,16 @@ internal static async Task RunAsync(string[] args, IConsoleService? console /// interactive prompt as the local REPL is used; only evaluation and rendering are routed remotely. /// private static async Task RunInspectModeAsync( - ConsoleService? systemConsole, IConsoleService console, string appStorage, ITraceLogger logger, Configuration config, int processId) + ConsoleService? systemConsole, IConsoleService console, string appStorage, ITraceLogger logger, Configuration config, int processId, bool nonInteractive) { RemoteSession session; try { - console.WriteLine($"Connecting to the inspector in process {processId}..."); + // Suppress connection chatter in non-interactive mode so stdout carries only the evaluated value(s). + if (!nonInteractive) + { + console.WriteLine($"Connecting to the inspector in process {processId}..."); + } session = await RemoteSession .ConnectAsync(processId, TimeSpan.FromSeconds(10), CancellationToken.None) .ConfigureAwait(false); @@ -168,9 +159,23 @@ private static async Task RunInspectModeAsync( return ExitCodes.ErrorParseArguments; } + // Non-interactive (--eval/--eval-file or piped stdin): evaluate and exit, with no prompt and no editor. + if (nonInteractive) + { + var remoteRoslyn = new RoslynServices(console, config, logger); + var evaluator = new RemotePipedInputEvaluator(console, session, remoteRoslyn); + if (config.EvaluateInput is { } code) + { + return await evaluator.EvaluateStringAsync(code).ConfigureAwait(false); + } + return config.StreamPipedInput + ? await evaluator.EvaluateStreamingPipeInputAsync().ConfigureAwait(false) + : await evaluator.EvaluateCollectedPipeInputAsync().ConfigureAwait(false); + } + // Seed the controller-side editor services with the target's references + the inspector globals, so // completion and semantic highlighting see the target's own types and `services`/`Get()`. Editor - // services run here (not in the target), so there's no per-keystroke pipe hop. + // services run here (not in the target), so there's no per-keystroke communication. var remoteEditor = await BuildRemoteEditorContextAsync(session, console).ConfigureAwait(false); var roslyn = new RoslynServices(console, config, logger, remoteEditor); diff --git a/CSharpRepl/Repls/InspectorCommandResultPrinter.cs b/CSharpRepl/Repls/InspectorCommandResultPrinter.cs new file mode 100644 index 00000000..a8ebf162 --- /dev/null +++ b/CSharpRepl/Repls/InspectorCommandResultPrinter.cs @@ -0,0 +1,59 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +using System; +using CSharpRepl.InjectedHook.Contracts; +using CSharpRepl.Services.Remote.Commands; + +namespace CSharpRepl.Repls; + +/// +/// Renders the wording for an (#replace / #wrap / #patches / #revert), +/// shared by the interactive and the non-interactive +/// . The caller supplies the output functions. +/// +internal static class InspectorCommandResultPrinter +{ + public static void Print(InspectorCommandResult result, Action writeLine, Action writeErrorLine) + { + switch (result) + { + case InspectorCommandResult.UsageError usage: + writeErrorLine($"Usage: {usage.Command.Usage}"); + break; + + case InspectorCommandResult.Replaced { Response: var response } replaced when response.Ok: + var arrow = replaced.Mode == PatchMode.Wrap ? "wrapped" : "patched"; + writeLine($"{arrow} {response.ResolvedMethod ?? replaced.Target} ← {replaced.Replacement} (patch #{response.PatchId})"); + break; + + case InspectorCommandResult.Replaced replaced: + writeErrorLine($"{(replaced.Mode == PatchMode.Wrap ? InspectorCommands.Wrap.Token : InspectorCommands.Replace.Token)} failed: {replaced.Response.Error}"); + break; + + case InspectorCommandResult.Listed { Response.Patches: { Count: 0 } }: + writeLine("No active patches."); + break; + + case InspectorCommandResult.Listed listed: + foreach (var patch in listed.Response.Patches) + { + writeLine($" #{patch.Id} [{patch.Mode}] {patch.Method} ← {patch.Replacement}"); + } + break; + + case InspectorCommandResult.Reverted { All: true } reverted: + writeLine($"reverted {reverted.Response.RevertedCount} patch(es)."); + break; + + case InspectorCommandResult.Reverted { Response.Ok: true } reverted: + writeLine($"reverted patch #{reverted.RequestedId}."); + break; + + case InspectorCommandResult.Reverted reverted: + writeErrorLine(reverted.Response.Error ?? "revert failed."); + break; + } + } +} diff --git a/CSharpRepl/Repls/RemotePipedInputEvaluator.cs b/CSharpRepl/Repls/RemotePipedInputEvaluator.cs new file mode 100644 index 00000000..f10d6a73 --- /dev/null +++ b/CSharpRepl/Repls/RemotePipedInputEvaluator.cs @@ -0,0 +1,156 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using CSharpRepl.InjectedHook.Contracts; +using CSharpRepl.Services; +using CSharpRepl.Services.Remote; +using CSharpRepl.Services.Remote.Commands; +using CSharpRepl.Services.Roslyn; +using CSharpRepl.Services.Roslyn.Formatting; + +namespace CSharpRepl.Repls; + +/// +/// Non-interactive inspect evaluation: the remote twin of (and the headless +/// counterpart to ), for inspect <pid> --eval/--eval-file +/// and piped stdin. Auto-prints the final value as plain text on stdout (errors to stderr, nonzero exit) and +/// honors inspect commands via the same as the interactive loop. +/// +internal sealed class RemotePipedInputEvaluator +{ + private const int EvaluationErrorExitCode = 1; + + private readonly IConsoleService console; + private readonly RemoteSession session; + private readonly RoslynServices roslyn; + private readonly InspectorCommandProcessor commands; + + public RemotePipedInputEvaluator(IConsoleService console, RemoteSession session, RoslynServices roslyn) + { + this.console = console; + this.session = session; + this.roslyn = roslyn; + this.commands = new InspectorCommandProcessor(session); + } + + /// Evaluates a single submission (e.g. from --eval or --eval-file) in the target and exits. + public Task EvaluateStringAsync(string input) => EvaluateSubmissionAsync(input, CancellationToken.None); + + /// Reads all of stdin and evaluates it as a single submission. Could block forever if input never ends. + public async Task EvaluateCollectedPipeInputAsync() + { + var input = new StringBuilder(); + string? line; + while ((line = console.ReadLine()) is not null) + { + input.AppendLine(line); + } + + return await EvaluateSubmissionAsync(input.ToString(), CancellationToken.None).ConfigureAwait(false); + } + + /// + /// Evaluates piped stdin as it streams in, batching lines into complete statements (via the local Roslyn's + /// syntactic check, which needs no target references) and sending each batch to the target. + /// + public async Task EvaluateStreamingPipeInputAsync() + { + var statement = new StringBuilder(); + string? inputLine; + while ((inputLine = console.ReadLine()) is not null) + { + statement.AppendLine(inputLine); + var text = statement.ToString(); + + // Run commands before the completeness gate, which would never accept e.g. "#patches" as complete. + var commandResult = await TryRunCommandAsync(text.Trim(), CancellationToken.None).ConfigureAwait(false); + if (commandResult.Handled) + { + statement.Clear(); + if (commandResult.ExitCode != ExitCodes.Success) return commandResult.ExitCode; + continue; + } + + if (!await roslyn.IsTextCompleteStatementAsync(text).ConfigureAwait(false)) + { + continue; + } + statement.Clear(); + + var exitCode = await EvaluateAsync(text, CancellationToken.None).ConfigureAwait(false); + if (exitCode != ExitCodes.Success) return exitCode; + } + + return ExitCodes.Success; + } + + /// Runs a submission as a command if it is one, otherwise evaluates it. + private async Task EvaluateSubmissionAsync(string input, CancellationToken cancellationToken) + { + var commandResult = await TryRunCommandAsync(input.Trim(), cancellationToken).ConfigureAwait(false); + if (commandResult.Handled) return commandResult.ExitCode; + + return await EvaluateAsync(input, cancellationToken).ConfigureAwait(false); + } + + /// Runs as an inspect command. Returns Handled=false (no I/O) when + /// it isn't a command, so the caller falls through to evaluation. + private async Task<(bool Handled, int ExitCode)> TryRunCommandAsync(string commandText, CancellationToken cancellationToken) + { + InspectorCommandResult? result; + try + { + result = await commands.TryExecuteAsync(commandText, cancellationToken).ConfigureAwait(false); + } + catch (IOException ex) + { + console.WriteStandardErrorLine($"Lost the connection to the target process: {ex.Message}"); + return (true, EvaluationErrorExitCode); + } + + if (result is null) return (false, ExitCodes.Success); + + var failed = false; + InspectorCommandResultPrinter.Print( + result, + console.WriteStandardOutputLine, + message => { failed = true; console.WriteStandardErrorLine(message); }); + return (true, failed ? EvaluationErrorExitCode : ExitCodes.Success); + } + + private async Task EvaluateAsync(string code, CancellationToken cancellationToken) + { + EvalResponse result; + try + { + result = await session.EvalAsync(code, detailed: false, cancellationToken).ConfigureAwait(false); + } + catch (IOException ex) + { + console.WriteStandardErrorLine($"Lost the connection to the target process: {ex.Message}"); + return EvaluationErrorExitCode; + } + + switch (result.Kind) + { + case ResultKind.Value when result.Value is { } value: + console.WriteStandardOutputLine(roslyn.RenderRemoteValueToPlainText(value, Level.FirstSimple)); + return ExitCodes.Success; + + case ResultKind.Exception when result.Exception is { } exception: + var (_, plainText) = roslyn.RenderRemoteException(exception, Level.FirstSimple); + console.WriteStandardErrorLine(plainText); + return EvaluationErrorExitCode; + + case ResultKind.Void: + default: + return ExitCodes.Success; + } + } +} diff --git a/CSharpRepl/Repls/RemoteReadEvalPrintLoop.cs b/CSharpRepl/Repls/RemoteReadEvalPrintLoop.cs index 2aef631a..26a3f41c 100644 --- a/CSharpRepl/Repls/RemoteReadEvalPrintLoop.cs +++ b/CSharpRepl/Repls/RemoteReadEvalPrintLoop.cs @@ -132,48 +132,9 @@ public async Task RunAsync(Configuration config) } // loop! } - /// Renders a command result. All wording lives here; the processor only returns the raw responses. - private void PrintCommandResult(InspectorCommandResult result) - { - switch (result) - { - case InspectorCommandResult.UsageError usage: - console.WriteErrorLine($"Usage: {usage.Command.Usage}"); - break; - - case InspectorCommandResult.Replaced { Response: var response } replaced when response.Ok: - var arrow = replaced.Mode == PatchMode.Wrap ? "wrapped" : "patched"; - console.WriteLine($"{arrow} {response.ResolvedMethod ?? replaced.Target} ← {replaced.Replacement} (patch #{response.PatchId})"); - break; - - case InspectorCommandResult.Replaced replaced: - console.WriteErrorLine($"{(replaced.Mode == PatchMode.Wrap ? InspectorCommands.Wrap.Token : InspectorCommands.Replace.Token)} failed: {replaced.Response.Error}"); - break; - - case InspectorCommandResult.Listed { Response.Patches: { Count: 0 } }: - console.WriteLine("No active patches."); - break; - - case InspectorCommandResult.Listed listed: - foreach (var patch in listed.Response.Patches) - { - console.WriteLine($" #{patch.Id} [{patch.Mode}] {patch.Method} ← {patch.Replacement}"); - } - break; - - case InspectorCommandResult.Reverted { All: true } reverted: - console.WriteLine($"reverted {reverted.Response.RevertedCount} patch(es)."); - break; - - case InspectorCommandResult.Reverted { Response.Ok: true } reverted: - console.WriteLine($"reverted patch #{reverted.RequestedId}."); - break; - - case InspectorCommandResult.Reverted reverted: - console.WriteErrorLine(reverted.Response.Error ?? "revert failed."); - break; - } - } + /// Renders a command result through the controller's themed, width-wrapped console. + private void PrintCommandResult(InspectorCommandResult result) => + InspectorCommandResultPrinter.Print(result, console.WriteLine, console.WriteErrorLine); private void Print(EvalResponse result, Level level) { diff --git a/Tests/CSharpRepl.Tests/RemotePipedInputEvaluatorTests.cs b/Tests/CSharpRepl.Tests/RemotePipedInputEvaluatorTests.cs new file mode 100644 index 00000000..d0eb5023 --- /dev/null +++ b/Tests/CSharpRepl.Tests/RemotePipedInputEvaluatorTests.cs @@ -0,0 +1,110 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +using System; +using System.Threading.Tasks; +using CSharpRepl.Repls; +using CSharpRepl.Services; +using CSharpRepl.Services.Remote; +using CSharpRepl.Services.Roslyn; +using NSubstitute; +using Xunit; + +namespace CSharpRepl.Tests; + +/// +/// End-to-end test for the non-interactive inspect path: drives against a +/// real hooked child via a real . Verifies clean plain-text stdout, errors to stderr +/// with a nonzero exit, inspect commands, and that engine state persists across separate evaluations. +/// +public class RemotePipedInputEvaluatorTests +{ + private const string TargetType = "CSharpRepl.InjectedHook.TestTarget.Program"; + + [Fact(Timeout = 180_000)] + public async Task EvaluatesNonInteractively_AgainstAHookedProcess() + { + var cancellationToken = TestContext.Current.CancellationToken; + using var process = InspectorTestSupport.StartHookedTarget(); + var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); + + try + { + await using var session = await RemoteSession.ConnectAsync(process.Id, TimeSpan.FromSeconds(30), cancellationToken); + + // --- A value-returning expression auto-prints as plain text on stdout, exit 0 --- + { + var (console, stdout, stderr) = FakeConsole.CreateStubbedOutputAndError(); + console.PrettyPromptConsole.IsErrorRedirected = true; + var evaluator = NewEvaluator(console, session); + + var exitCode = await evaluator.EvaluateStringAsync("41 + 1"); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Equal("42", stdout.ToString().Trim()); + Assert.Equal("", stderr.ToString().Trim()); + // Output must be free of ANSI escape sequences so it's safe to capture/pipe. + Assert.DoesNotContain('\x1b', stdout.ToString()); + } + + // --- State persists across separate connections: declare in one call, read it back in the next --- + { + var (console1, _, _) = FakeConsole.CreateStubbedOutputAndError(); + Assert.Equal(ExitCodes.Success, + await NewEvaluator(console1, session).EvaluateStringAsync($"var probe = {TargetType}.Shared.Value;")); + + var (console2, stdout2, _) = FakeConsole.CreateStubbedOutputAndError(); + Assert.Equal(ExitCodes.Success, await NewEvaluator(console2, session).EvaluateStringAsync("probe")); + Assert.Equal("41", stdout2.ToString().Trim()); // Service.Value starts at 41 + } + + // --- A runtime exception goes to stderr with a nonzero exit and nothing on stdout --- + { + var (console, stdout, stderr) = FakeConsole.CreateStubbedOutputAndError(); + console.PrettyPromptConsole.IsErrorRedirected = true; + var evaluator = NewEvaluator(console, session); + + var exitCode = await evaluator.EvaluateStringAsync(@"throw new System.InvalidOperationException(""boom"");"); + + Assert.NotEqual(ExitCodes.Success, exitCode); + Assert.Equal("", stdout.ToString().Trim()); + Assert.Contains("boom", stderr.ToString()); + } + + // --- An inspect command (#patches) is honored non-interactively and renders to stdout --- + { + var (console, stdout, _) = FakeConsole.CreateStubbedOutputAndError(); + var evaluator = NewEvaluator(console, session); + + var exitCode = await evaluator.EvaluateStringAsync("#patches"); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Contains("No active patches.", stdout.ToString()); + } + + // --- Piped stdin: lines are collected and evaluated as a single submission --- + { + var (console, stdout, _) = FakeConsole.CreateStubbedOutputAndError(); + console.ReadLine().Returns("var x = 20;", "var y = 22;", "x + y", (string)null); + var evaluator = NewEvaluator(console, session); + + var exitCode = await evaluator.EvaluateCollectedPipeInputAsync(); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Equal("42", stdout.ToString().Trim()); + } + } + finally + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + try { await Task.WhenAll(stdoutTask, stderrTask); } catch { /* output drained on process exit */ } + } + } + + // Mirrors the non-interactive stack Program.cs builds: a reference-less RoslynServices, no prompt. + private static RemotePipedInputEvaluator NewEvaluator(FakeConsoleAbstract console, RemoteSession session) => + new(console, session, new RoslynServices(console, new Configuration(), new TestTraceLogger())); +} diff --git a/Tests/CSharpRepl.Tests/RemoteValueRendererTests.cs b/Tests/CSharpRepl.Tests/RemoteValueRendererTests.cs index 18ac31d4..2b43e1d5 100644 --- a/Tests/CSharpRepl.Tests/RemoteValueRendererTests.cs +++ b/Tests/CSharpRepl.Tests/RemoteValueRendererTests.cs @@ -81,6 +81,23 @@ public void Object_Detailed_RendersMembers() Assert.Contains("42", output); } + [Fact] + public void RenderToPlainText_Scalar_IsTheDisplayTextWithNoAnsi() + { + var value = new RemoteValue { Kind = RemoteValueKind.Scalar, TypeName = "int", DisplayText = "42", Style = RemoteValueStyle.Number }; + var output = renderer.RenderToPlainText(value, Level.FirstSimple); + Assert.Equal("42", output); + Assert.DoesNotContain('\x1b', output); // no ANSI escape sequences — safe to capture/pipe + } + + [Fact] + public void RenderToPlainText_Collection_RendersInlineSummary() + { + var value = Collection("int[]", 3, "10", "20", "30"); + var output = renderer.RenderToPlainText(value, Level.FirstSimple); + Assert.Equal("int[](3) { 10, 20, 30 }", output); + } + [Fact] public void Exception_PlainTextIsTheMessage() { From dab83fc88a2db44879b2bf414163a99f64accea8 Mon Sep 17 00:00:00 2001 From: Will Fuqua Date: Sun, 28 Jun 2026 15:27:25 +0700 Subject: [PATCH 2/2] Refactor common REPL into Common namespace --- CSharpRepl.Services/Roslyn/RoslynServices.cs | 5 +- .../InspectorCommandResultPrinter.cs | 2 +- CSharpRepl/Repls/Common/PipedInputReader.cs | 72 +++++++++++++++++++ CSharpRepl/Repls/PipedInputEvaluator.cs | 45 +++--------- CSharpRepl/Repls/RemotePipedInputEvaluator.cs | 54 +++----------- CSharpRepl/Repls/RemoteReadEvalPrintLoop.cs | 1 + .../RemotePipedInputEvaluatorTests.cs | 14 ++++ 7 files changed, 111 insertions(+), 82 deletions(-) rename CSharpRepl/Repls/{ => Common}/InspectorCommandResultPrinter.cs (98%) create mode 100644 CSharpRepl/Repls/Common/PipedInputReader.cs diff --git a/CSharpRepl.Services/Roslyn/RoslynServices.cs b/CSharpRepl.Services/Roslyn/RoslynServices.cs index ef0957cd..1c99670f 100644 --- a/CSharpRepl.Services/Roslyn/RoslynServices.cs +++ b/CSharpRepl.Services/Roslyn/RoslynServices.cs @@ -336,8 +336,9 @@ public async Task> SyntaxHighlightAsync(str public async Task IsTextCompleteStatementAsync(string text) { - if (!Initialization.IsCompleted) - return true; + // Unlike the per-keystroke editor fast-paths (CompleteAsync/SyntaxHighlightAsync return [] before init), + // this is reached only when submitting (Enter), so we can await initialization without a fast-path. + await Initialization.ConfigureAwait(false); var document = workspaceManager.CurrentDocument.WithText(SourceText.From(text)); var root = await document.GetSyntaxRootAsync().ConfigureAwait(false); diff --git a/CSharpRepl/Repls/InspectorCommandResultPrinter.cs b/CSharpRepl/Repls/Common/InspectorCommandResultPrinter.cs similarity index 98% rename from CSharpRepl/Repls/InspectorCommandResultPrinter.cs rename to CSharpRepl/Repls/Common/InspectorCommandResultPrinter.cs index a8ebf162..510af69e 100644 --- a/CSharpRepl/Repls/InspectorCommandResultPrinter.cs +++ b/CSharpRepl/Repls/Common/InspectorCommandResultPrinter.cs @@ -6,7 +6,7 @@ using CSharpRepl.InjectedHook.Contracts; using CSharpRepl.Services.Remote.Commands; -namespace CSharpRepl.Repls; +namespace CSharpRepl.Repls.Common; /// /// Renders the wording for an (#replace / #wrap / #patches / #revert), diff --git a/CSharpRepl/Repls/Common/PipedInputReader.cs b/CSharpRepl/Repls/Common/PipedInputReader.cs new file mode 100644 index 00000000..35fd85fc --- /dev/null +++ b/CSharpRepl/Repls/Common/PipedInputReader.cs @@ -0,0 +1,72 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +using System; +using System.Text; +using System.Threading.Tasks; +using CSharpRepl.Services; + +namespace CSharpRepl.Repls.Common; + +/// +/// Shared stdin-reading mechanics for the non-interactive evaluators ( and +/// ): collecting all input as one submission, or streaming it line by +/// line batched into complete statements. The per-submission action (local vs remote evaluation) is supplied +/// by the caller as a delegate. +/// +internal static class PipedInputReader +{ + /// Reads all of stdin into a single string. Could block forever if input never ends. + public static string ReadAll(IConsoleService console) + { + var input = new StringBuilder(); + while (console.ReadLine() is string line) + { + input.AppendLine(line); + } + return input.ToString(); + } + + /// + /// Reads stdin line by line, batching into complete statements (so a partial statement isn't evaluated + /// early) and invoking on each. Returns the first non-success exit code, else + /// . is called on each line for custom logic, + /// used by the remote evaluator for non-c# commands like "#replace" + /// + public static async Task StreamAsync( + IConsoleService console, + Func> isComplete, + Func> evaluate, + Func>? intercept = null) + { + var statement = new StringBuilder(); + while (console.ReadLine() is string line) + { + statement.AppendLine(line); + var text = statement.ToString(); + + if (intercept is not null) + { + var (handled, exitCode) = await intercept(text.Trim()).ConfigureAwait(false); + if (handled) + { + statement.Clear(); + if (exitCode != ExitCodes.Success) return exitCode; + continue; + } + } + + if (!await isComplete(text).ConfigureAwait(false)) + { + continue; + } + statement.Clear(); + + var result = await evaluate(text).ConfigureAwait(false); + if (result != ExitCodes.Success) return result; + } + + return ExitCodes.Success; + } +} diff --git a/CSharpRepl/Repls/PipedInputEvaluator.cs b/CSharpRepl/Repls/PipedInputEvaluator.cs index ce9cdcaf..c03fb6d1 100644 --- a/CSharpRepl/Repls/PipedInputEvaluator.cs +++ b/CSharpRepl/Repls/PipedInputEvaluator.cs @@ -4,8 +4,8 @@ using System; using System.Linq; -using System.Text; using System.Threading.Tasks; +using CSharpRepl.Repls.Common; using CSharpRepl.Services; using CSharpRepl.Services.Roslyn; using CSharpRepl.Services.Roslyn.Formatting; @@ -46,39 +46,19 @@ public async Task EvaluateStringAsync(string input) } /// - /// When we're receiving pipe input, evaluate the input as it streams in. + /// When we're receiving pipe input, evaluate the input as it streams in (line by line, batched into + /// complete statements) — input could be piped forever, so we don't read it all before evaluating. /// /// exit / error code public async Task EvaluateStreamingPipeInputAsync() { if (await PreloadAsync().ConfigureAwait(false) is int preloadError) return preloadError; - // input could be piped forever, so don't read all the input and then evaluate it in one go. - // instead, read the input line by line until we have a completed statement, then evaluate that. - - var statement = new StringBuilder(); - string? inputLine; - while ((inputLine = console.ReadLine()) is not null) - { - // batch input into a complete statement - statement.AppendLine(inputLine); - string input = statement.ToString(); - if (!await roslyn.IsTextCompleteStatementAsync(input)) - { - continue; - } - statement.Clear(); - - // evaluate complete statement. - var result = await roslyn.EvaluateAsync(input); - var exitCode = await ProcessResultAsync(result).ConfigureAwait(false); - if (exitCode != ExitCodes.Success) - { - return exitCode; - } - } - - return ExitCodes.Success; + return await PipedInputReader.StreamAsync( + console, + isComplete: roslyn.IsTextCompleteStatementAsync, + evaluate: async input => await ProcessResultAsync(await roslyn.EvaluateAsync(input)).ConfigureAwait(false)) + .ConfigureAwait(false); } /// @@ -90,14 +70,7 @@ public async Task EvaluateCollectedPipeInputAsync() { if (await PreloadAsync().ConfigureAwait(false) is int preloadError) return preloadError; - var input = new StringBuilder(); - string? line; - while ((line = console.ReadLine()) is not null) - { - input.AppendLine(line); - } - - var result = await roslyn.EvaluateAsync(input.ToString()); + var result = await roslyn.EvaluateAsync(PipedInputReader.ReadAll(console)); return await ProcessResultAsync(result).ConfigureAwait(false); } diff --git a/CSharpRepl/Repls/RemotePipedInputEvaluator.cs b/CSharpRepl/Repls/RemotePipedInputEvaluator.cs index f10d6a73..d016258d 100644 --- a/CSharpRepl/Repls/RemotePipedInputEvaluator.cs +++ b/CSharpRepl/Repls/RemotePipedInputEvaluator.cs @@ -4,10 +4,10 @@ using System; using System.IO; -using System.Text; using System.Threading; using System.Threading.Tasks; using CSharpRepl.InjectedHook.Contracts; +using CSharpRepl.Repls.Common; using CSharpRepl.Services; using CSharpRepl.Services.Remote; using CSharpRepl.Services.Remote.Commands; @@ -43,52 +43,20 @@ public RemotePipedInputEvaluator(IConsoleService console, RemoteSession session, public Task EvaluateStringAsync(string input) => EvaluateSubmissionAsync(input, CancellationToken.None); /// Reads all of stdin and evaluates it as a single submission. Could block forever if input never ends. - public async Task EvaluateCollectedPipeInputAsync() - { - var input = new StringBuilder(); - string? line; - while ((line = console.ReadLine()) is not null) - { - input.AppendLine(line); - } - - return await EvaluateSubmissionAsync(input.ToString(), CancellationToken.None).ConfigureAwait(false); - } + public Task EvaluateCollectedPipeInputAsync() => + EvaluateSubmissionAsync(PipedInputReader.ReadAll(console), CancellationToken.None); /// /// Evaluates piped stdin as it streams in, batching lines into complete statements (via the local Roslyn's - /// syntactic check, which needs no target references) and sending each batch to the target. + /// syntactic check, which needs no target references) and sending each batch to the target. Commands are + /// handled before the completeness gate, which would never accept e.g. "#patches" as complete. /// - public async Task EvaluateStreamingPipeInputAsync() - { - var statement = new StringBuilder(); - string? inputLine; - while ((inputLine = console.ReadLine()) is not null) - { - statement.AppendLine(inputLine); - var text = statement.ToString(); - - // Run commands before the completeness gate, which would never accept e.g. "#patches" as complete. - var commandResult = await TryRunCommandAsync(text.Trim(), CancellationToken.None).ConfigureAwait(false); - if (commandResult.Handled) - { - statement.Clear(); - if (commandResult.ExitCode != ExitCodes.Success) return commandResult.ExitCode; - continue; - } - - if (!await roslyn.IsTextCompleteStatementAsync(text).ConfigureAwait(false)) - { - continue; - } - statement.Clear(); - - var exitCode = await EvaluateAsync(text, CancellationToken.None).ConfigureAwait(false); - if (exitCode != ExitCodes.Success) return exitCode; - } - - return ExitCodes.Success; - } + public Task EvaluateStreamingPipeInputAsync() => + PipedInputReader.StreamAsync( + console, + isComplete: roslyn.IsTextCompleteStatementAsync, + evaluate: text => EvaluateAsync(text, CancellationToken.None), + intercept: text => TryRunCommandAsync(text, CancellationToken.None)); /// Runs a submission as a command if it is one, otherwise evaluates it. private async Task EvaluateSubmissionAsync(string input, CancellationToken cancellationToken) diff --git a/CSharpRepl/Repls/RemoteReadEvalPrintLoop.cs b/CSharpRepl/Repls/RemoteReadEvalPrintLoop.cs index 26a3f41c..e506dd85 100644 --- a/CSharpRepl/Repls/RemoteReadEvalPrintLoop.cs +++ b/CSharpRepl/Repls/RemoteReadEvalPrintLoop.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using CSharpRepl.InjectedHook.Contracts; using CSharpRepl.PrettyPromptConfig; +using CSharpRepl.Repls.Common; using CSharpRepl.Services; using CSharpRepl.Services.Remote; using CSharpRepl.Services.Remote.Commands; diff --git a/Tests/CSharpRepl.Tests/RemotePipedInputEvaluatorTests.cs b/Tests/CSharpRepl.Tests/RemotePipedInputEvaluatorTests.cs index d0eb5023..947fadaa 100644 --- a/Tests/CSharpRepl.Tests/RemotePipedInputEvaluatorTests.cs +++ b/Tests/CSharpRepl.Tests/RemotePipedInputEvaluatorTests.cs @@ -95,6 +95,20 @@ public async Task EvaluatesNonInteractively_AgainstAHookedProcess() Assert.Equal(ExitCodes.Success, exitCode); Assert.Equal("42", stdout.ToString().Trim()); } + + // --- Streaming piped input: a command is handled before the completeness gate, then each complete + // statement is evaluated in turn (covers the remote handleBeforeGate path of PipedInputReader) --- + { + var (console, stdout, _) = FakeConsole.CreateStubbedOutputAndError(); + console.ReadLine().Returns("#patches", "var z = 100;", "z + 1", (string)null); + var evaluator = NewEvaluator(console, session); + + var exitCode = await evaluator.EvaluateStreamingPipeInputAsync(); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Contains("No active patches.", stdout.ToString()); // the #patches command + Assert.Contains("101", stdout.ToString()); // z + 1 + } } finally {