From 1e21fee4cf1553267289c11f68577783e16fc2d6 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 30 Aug 2026 14:53:02 +0200 Subject: [PATCH 01/27] Add the F# Interactive window on the JSON-RPC protocol Builds the Visual Studio side of the replacement for the legacy F# Interactive window against Microsoft.VisualStudio.InteractiveWindow, the REPL engine C# Interactive and Python Interactive already run on. InteractiveHost owns the fsi process and speaks the protocol to it. Because the handshake reports the process that actually evaluates code, the temporary-file dance the old window used to discover it under "dotnet fsi" is gone, and so is the line counting that hid the output that discovery produced. FSharpInteractiveEvaluator implements IInteractiveEvaluator. Diagnostics arrive as data and are rendered in the compiler's own layout. AbortExecution maps to the protocol's interrupt, which the session serves as it arrives; the equivalent in C# Interactive is still an empty method. SubmissionAnalysis decides whether Enter submits or adds a line. The window asks on every keystroke on the UI thread, so the judgement is lexical: an explicit ';;' always submits, and without one the submission goes when nothing is left visibly open. Brackets and terminators inside strings and comments do not count. FSharpVsInteractiveWindowProvider creates the tool window and gives its input buffer the F# content type and language service, so the ordinary editor features apply to what the user types. Session settings come from the SessionsProperties the existing Tools, Options page already writes, so no second options page is needed. FSharpVsInteractiveWindowPackage registers the window so Visual Studio can restore it from a persisted layout. Co-Authored-By: Claude Fable 5 --- VisualFSharp.slnx | 3 + .../ide/FSI-Modern-Interactive-Window-Plan.md | 299 ++++++++++++ docs/release-notes/.VisualStudio/18.vNext.md | 1 + eng/Packages.props | 8 + ...p.Compiler.Interactive.Server.Tests.fsproj | 6 + .../SubmissionAnalysisTests.fs | 92 ++++ .../FSharp.Interactive.Window.fsproj | 61 +++ .../FSharpInteractiveEvaluator.fs | 190 ++++++++ .../FSharpVsInteractiveWindowPackage.fs | 65 +++ .../FSharpVsInteractiveWindowProvider.fs | 114 +++++ .../InteractiveHost.fs | 459 ++++++++++++++++++ .../SubmissionAnalysis.fs | 184 +++++++ .../src/FSharp.VS.FSI/FSharp.VS.FSI.fsproj | 1 + 13 files changed, 1483 insertions(+) create mode 100644 docs/ide/FSI-Modern-Interactive-Window-Plan.md create mode 100644 tests/FSharp.Compiler.Interactive.Server.Tests/SubmissionAnalysisTests.fs create mode 100644 vsintegration/src/FSharp.Interactive.Window/FSharp.Interactive.Window.fsproj create mode 100644 vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveEvaluator.fs create mode 100644 vsintegration/src/FSharp.Interactive.Window/FSharpVsInteractiveWindowPackage.fs create mode 100644 vsintegration/src/FSharp.Interactive.Window/FSharpVsInteractiveWindowProvider.fs create mode 100644 vsintegration/src/FSharp.Interactive.Window/InteractiveHost.fs create mode 100644 vsintegration/src/FSharp.Interactive.Window/SubmissionAnalysis.fs diff --git a/VisualFSharp.slnx b/VisualFSharp.slnx index caa059a34ee..42f9b328796 100644 --- a/VisualFSharp.slnx +++ b/VisualFSharp.slnx @@ -108,6 +108,9 @@ + + + diff --git a/docs/ide/FSI-Modern-Interactive-Window-Plan.md b/docs/ide/FSI-Modern-Interactive-Window-Plan.md new file mode 100644 index 00000000000..2a819a4a75c --- /dev/null +++ b/docs/ide/FSI-Modern-Interactive-Window-Plan.md @@ -0,0 +1,299 @@ +под# Modernizing the F# Interactive Window: Plan + +Replace the legacy `FSharp.VS.FSI` tool window with the same REPL engine that powers **C# Interactive** and **Python Interactive** in Visual Studio: the `Microsoft.VisualStudio.InteractiveWindow` / `Microsoft.VisualStudio.VsInteractiveWindow` packages (source: [microsoft/vs-interactive-window](https://github.com/microsoft/vs-interactive-window)), with a structured JSON-RPC connection to the F# Interactive process instead of the current raw stdin/stdout text protocol. + +Reference implementation studied: C# Interactive in Roslyn (`src/Interactive/*`, `src/EditorFeatures/Core/Interactive/*`, `src/VisualStudio/*/Impl/Interactive/*`). + +--- + +## 0. Status + +**Phase 1 is implemented and tested.** F# Interactive has a JSON-RPC server mode +(`--fsi-server-jsonrpc:`) in `src/fsi/{interactiveProtocol,fsiserver}.fs`, speaking +StreamJsonRpc over a header-delimited stream — the same transport Roslyn's interactive host uses. +The protocol types in `interactiveProtocol.fs` are shared with the host by linking the source, so +the two ends cannot drift apart. §2.2 below documents the protocol as it exists rather than as it +was proposed. + +`tests/FSharp.Compiler.Interactive.Server.Tests` holds 35 tests: 23 drive a real fsi process over +the protocol and 12 cover the submission rule described in §2.4. The end-to-end ones are the point — +the handshake, the lifetime of the session, and the interaction between the control channel and the +output streams only exist across a process boundary. + +**Phases 0 and 2 are partly implemented.** `vsintegration/src/FSharp.Interactive.Window` compiles +and contains: + +- `InteractiveHost.fs` — the client that owns the fsi process and speaks the protocol; +- `FSharpInteractiveEvaluator.fs` — the `IInteractiveEvaluator` the window runs on; +- `SubmissionAnalysis.fs` — the rule deciding when Enter submits, tested; +- `FSharpVsInteractiveWindowProvider.fs` — the MEF component that creates the tool window through + `IVsInteractiveWindowFactory.Create`, calls `SetLanguage` with the F# content type and language + service so the input buffer is an F# editor buffer, sets the caption from the platform, and reads + its options from the `SessionsProperties` the existing Tools, Options page already writes. No new + options page is needed. + +Still to do before the window can be opened in Visual Studio: + +- registering the tool window with the shell. The existing F# Interactive window is registered by + `FSharpPackage` in `FSharp.Editor` rather than by a package of its own, and the same route is the + cheaper one here: a `ProvideInteractiveWindow` attribute and an `IVsToolWindowFactory` hook that + calls the provider, rather than a new package with its own GUID and pkgdef; +- the `Microsoft.VisualStudio.InteractiveWindow` prerequisite entry in the VSIX manifest, and the + project's place in the VSIX itself. It is already in `VisualFSharp.slnx`, so it builds; +- commands: open the window, `#reset `, and retargeting Alt+Enter at the new window; +- the debugger attach/detach commands ported from the existing window. The session reports its own + process id in the handshake, so the attach no longer has to guess which process to target. + +Everything from Phase 3 onwards (IntelliSense in the input buffer) is untouched. One protocol gap +belongs to that phase: an execution result reports the working directory but not the references and +opens the session has accumulated. F# can get further than C# without them, because the IDE resolves +script references itself through `GetProjectOptionsFromScript` rather than from a response file, but +a session that has run `#r` or `#I` will still drift from what the IDE believes until the result +carries them too. + +--- + +## 1. What we have today (and why it must go) + +`vsintegration/src/FSharp.VS.FSI` (~2,500 LOC, essentially unchanged since VS 2005-era IronPython sample code): + +| Area | Current implementation | Problem | +|---|---|---| +| Window | Hand-rolled `ToolWindowPane` over COM `IVsTextLines`/`IVsTextView` created via `ILocalRegistry.CreateInstance` (`fsiSessionToolWindow.fs`) | Pre-WPF-editor architecture; read-only region tracked by hand-managed markers; manual caret/scroll/undo management; every editor command (HOME, BACKSPACE, LEFT, RETURN, UP/DOWN) intercepted and reimplemented | +| Language service | MPF `LanguageService` subclass (`FsiLanguageService`) with an empty scanner, empty completions, empty tooltips, `EnableCodeSense <- false` | **Zero IntelliSense** in the REPL: no completion, no quick info, no colorization of input beyond one hardcoded "Keyword" colorable item | +| Process protocol | `dotnet fsi` / `fsiAnyCpu.exe` with redirected stdin/stdout/stderr; prompts detected by scraping the literal string `SERVER-PROMPT>`; a `# 1 "stdin"` line-directive hack per submission; `#silentCd`, `#interactiveprompt "hide"/"show"` magic strings | Fragile text-scraping; output/prompt/echo interleaving bugs; no structured results, no structured diagnostics | +| .NET Core support | First submission writes its own PID and TFM to a temp file (`File.WriteAllLines(pidfile, ...)`), IDE polls the file with `Thread.Sleep(200)` in a loop; the resulting `val it: unit = ()` junk output is skipped by counting lines | A hack on top of a hack; races on startup; breaks if the first output doesn't look as expected | +| Interrupt | Separate `CtrlBreakClient` channel + 1s `timeoutApp` wrapper spinning a threadpool thread with a `ManualResetEvent` | Works, but is a parallel bespoke IPC mechanism | +| Output pumping | 50 ms `System.Windows.Forms.Timer` batching stdout/stderr into the text buffer | WinForms timer in a WPF IDE; ordering between stdout and stderr only approximated | +| History | Hand-written `HistoryBuffer` (cmd.exe model) | The InteractiveWindow package provides history, multi-line submissions, prompt margins for free | +| Error reporting | `System.Windows.Forms.MessageBox.Show(...)` in ~10 places, including inside event handlers | | + +What the old window does have that C# Interactive does **not** (must be preserved): + +- **Debugging**: attach/detach the VS debugger to the FSI process, "Debug in Interactive" (`#dbgbreak`), debuggability check (`--debug+ --optimize-`) with a suppressible warning dialog. Roslyn's window has no debugging at all — this is an F# advantage to keep. +- Real script semantics: FSI executes actual `.fsx` interactions with `#load`/`#r`/`#i`, and `fsi` object, not a C#-script dialect. +- Platform choice already includes Arm64 (`fsiArm64.exe`). + +--- + +## 2. Target architecture + +Five layers, copied from Roslyn's proven separation. Execution state lives **only** in the FSI process; the IDE keeps a *parallel* type-checking model used purely for IntelliSense; the two are synchronized by structured data flowing back after init and after every submission. + +``` +┌─ VS shell ────────────────────────────────────────────────────────────┐ +│ FSharpVsInteractiveWindowPackage (AsyncPackage, IVsToolWindowFactory) │ +│ FSharpVsInteractiveWindowProvider (MEF singleton, owns the window) │ +│ Commands: Open F# Interactive, Send to Interactive, Debug Selection, │ +│ Initialize Interactive with Project │ +└──────────────────────────────┬────────────────────────────────────────┘ + │ IVsInteractiveWindowFactory.Create(guid, id, title, evaluator) + │ + IVsInteractiveWindow.SetLanguage(FSharpLangServiceGuid, "F#" content type) +┌──────────────────────────────▼────────────────────────────────────────┐ +│ REPL WINDOW UI — NOT OUR CODE │ +│ NuGet: Microsoft.VisualStudio.InteractiveWindow 4.x │ +│ Microsoft.VisualStudio.VsInteractiveWindow 4.x │ +│ (VS prerequisite component; same engine as C# and Python Interactive) │ +│ Gives us: projection buffer (prompts + scrollback + editable input), │ +│ history, multiline editing, #help/#cls, IsRunning/IsResetting states, │ +│ Enter-vs-newline dispatch via IInteractiveEvaluator.CanExecuteCode │ +└──────────────────────────────┬────────────────────────────────────────┘ + │ implements IInteractiveEvaluator +┌──────────────────────────────▼────────────────────────────────────────┐ +│ EVALUATOR + IDE-SIDE MODEL (in devenv) │ +│ FSharpInteractiveEvaluator : IInteractiveEvaluator │ +│ FSharpInteractiveSession — one work queue serializing everything │ +│ Submission documents in FSharp.Editor's workspace → full IntelliSense │ +└──────────────────────────────┬────────────────────────────────────────┘ + │ JSON-RPC 2.0 over a named pipe (control) + │ + redirected stdout/stderr (user output) +┌──────────────────────────────▼────────────────────────────────────────┐ +│ EXECUTION HOST = fsi itself, in a new server mode │ +│ dotnet fsi --fsi-server-jsonrpc: (.NET / SDK) │ +│ fsiAnyCpu.exe / fsiArm64.exe --fsi-server-jsonrpc:… (.NET Framework) │ +│ FsiEvaluationSession driven by RPC instead of the stdin ReadLine loop │ +└───────────────────────────────────────────────────────────────────────┘ +``` + +### 2.1 Why "fsi in server mode" instead of a separate InteractiveHost.exe + +Roslyn ships dedicated `InteractiveHost64/32.exe` binaries because C# scripting is a library (`Microsoft.CodeAnalysis.CSharp.Scripting`) with no standalone process. F# already **has** the process — `fsi` — with the evaluation engine (`FsiEvaluationSession` in `FSharp.Compiler.Interactive.Shell`), an event-loop concept (`fsi.EventLoop`, WinForms/WPF pumping), `#r`/`#load`/`#i` handling, and SDK-based deployment (`dotnet fsi` always matches the user's SDK). Adding a JSON-RPC front-end to fsi: + +- kills the `SERVER-PROMPT>` scraping, the PID-file hack, and the `# 1 "stdin"` directive juggling in one move; +- benefits every other fsi client (Ionide, VS Code, custom tooling) — the server mode is a compiler feature, not a VS-only one; +- keeps `dotnet fsi` as the .NET Core host (nothing new to deploy; the VSIX only carries the desktop `fsiAnyCpu`/`fsiArm64` it already ships). + +### 2.2 The RPC protocol (as implemented) + +JSON-RPC 2.0 with `Content-Length` framing — the Language Server Protocol wire format. Both ends use +`StreamJsonRpc` with its stock `HeaderDelimitedMessageHandler` and `JsonMessageFormatter`. Requests +are client→server only; the server registers no callbacks, as Roslyn's does not. + +| Method | Parameters | Result | +|---|---|---| +| `fsi/initialize` | `clientProcessId` | `processId`, `frameworkDescription`, `processArchitecture`, `fsiVersion`, `workingDirectory`, `supportsInterrupt` | +| `fsi/execute` | `code`, optional `sourcePath` and `startLine` | execution result | +| `fsi/executeFile` | `path` | execution result | +| `fsi/setPaths` | `includePaths`, `workingDirectory` | execution result | +| `fsi/interrupt` | — | `interrupted` | +| `fsi/shutdown` | — | ends the session | + +An execution result carries `success`, `cancelled`, `diagnostics` (severity, message, error number, +subcategory, file name, and a start/end line and column), `exception` (type, message, stack trace), +and `workingDirectory`. + +Three details matter more than the shape: + +- `processId` is the process **evaluating code**, which under `dotnet fsi` is not the process that + was launched. Reporting it in the handshake is what removes the temporary-PID-file dance, and it + is what a debugger must attach to. +- `sourcePath` and `startLine` make the session emit a line directive around the submission, so a + selection executed from an editor reports its errors against the user's own file and line. +- `exception` is omitted when the interaction merely failed to compile. The diagnostics already + describe that, and reporting the exception fsi raises to stop processing would say it twice. + +Requests before `fsi/initialize` are refused with a session-not-ready error. Interactions are queued +onto a single worker so that they run in the order they arrived, while `fsi/interrupt` is served as +it arrives — an interrupt that waited its turn behind the interaction it is meant to stop would +never arrive. + +User program output keeps flowing through the redirected standard output and error streams. The +prompt is suppressed in this mode, so nothing has to be filtered back out of that stream. + +User program output (`printfn`, `Console.*`) keeps flowing through redirected **stdout/stderr**, pumped by two reader threads into `IInteractiveWindow.OutputWriter`/`ErrorOutputWriter` (exactly Roslyn's split: RPC = control plane, std streams = data plane). Encoding pinned to UTF-8 as today (fsi already supports `--fsi-server-output-codepage`). + +Process lifecycle (copy Roslyn): + +- `LazyRemoteService`-style async-lazy start with cancellation; retry once on startup failure. +- Reset = kill + respawn (no graceful shutdown protocol needed); auto-restart with a "process exited with code N" message on unexpected death. +- Host watches client PID and exits when devenv dies. + +### 2.3 IntelliSense in the REPL buffer + +The single most valuable user-facing change. Mechanism (Roslyn's, adapted): + +1. `SetLanguage(FSharpLanguageServiceGuid, FSharpContentType)` makes every input buffer an F# editor buffer — classification, brace matching and the whole editor command chain light up via the existing `FSharp.Editor` MEF exports. +2. On `SubmissionBufferAdded`, the session creates a document `Submission{N}.fsx` in the Roslyn workspace that `FSharp.Editor` already populates (F# in VS runs on the Roslyn workspace model), opens it against the live `ITextBuffer` (`OpenDocument(docId, buffer.AsTextContainer())`), and gives the editor `ITextDocument` the same synthetic path for future LSP addressability. +3. **Submission chaining for the checker.** F# has no `isSubmission`/`previousSubmission` compilation model exposed in FCS, so phase it: + - **Phase A (concatenated prelude):** the checker sees one logical `.fsx` = concatenation of all *successfully executed* submissions + current input; diagnostics/completions positions are offset-mapped back to the input buffer. Semantics match fsi closely (shadowing works; it is "as if you retyped the whole session as one script"). Project options come from `GetProjectOptionsFromScript` seeded with the references/opens reported by `InitializationResult` and updated per `ExecutionResult`. Mitigate long-session cost with FCS incremental checking (only the tail changes) and an optional cap. + - **Phase B (real chaining, upstream FCS work):** expose fsi's own incremental typechecker state (`FsiDynamicCompiler` accumulates a tcState exactly like the IDE needs) through a first-class FCS API — "check this interaction against this accumulated state". This is the F# analogue of Roslyn's `isSubmission: true` + project-reference chain and removes the concatenation model. Design with FCS maintainers; not a blocker for shipping A. +4. Chain only from the last **successful** submission (failed input must not poison later IntelliSense). +5. Handle the init race: the window creates the first input buffer before the host finishes starting → queue pending buffers, drain on process-initialized (Roslyn's `_pendingBuffers`). +6. Freeze classification of executed submissions before reset clears the model (Roslyn's `InertClassifierProvider` trick: snapshot `IClassificationSpan`s into buffer properties, replay forever) so scrollback keeps its colors. +7. Interactive-specific service overrides (keyed on an interactive workspace kind, as Roslyn does): navigation projects spans into the window's surface buffer instead of opening files; rename/refactorings disabled; code fixes only on the active, idle buffer; global undo maps to the window's undo history. +8. REPL command completion: offer `#help`, `#cls`, `#reset`, plus F# directives (`#r`, `#load`, `#I`, `#time`, `#quit`) when the caret is at the start of an interaction. + +### 2.4 Submission semantics: `;;` and Enter + +`IInteractiveEvaluator.CanExecuteCode` decides Enter-submits vs Enter-inserts-newline: + +- Text ending in `;;` (outside strings/comments) → submit (traditional fsi muscle memory preserved). +- Otherwise → submit iff the text is a syntactically complete interaction (FCS parse with `ScriptParseInfo`; incomplete constructs — open `let`, unclosed paren/string — return false). This matches `dotnet fsi`'s modern multiline behavior and C#'s `SyntaxFactory.IsCompleteSubmission`. +- The evaluator appends `;;` before sending to the host if absent; prompts: `> ` primary, `. ` (or `- `) continuation via `GetPrompt()`. + +### 2.5 Platform selection + +Keep today's matrix, expressed the Roslyn way (`#reset` arguments + caption suffix + options page default): + +| Platform | Host | Notes | +|---|---|---| +| .NET (default) | `dotnet fsi` | SDK-resolved; caption "F# Interactive (.NET)" | +| .NET Framework x64 | `fsiAnyCpu.exe --fsi-server-jsonrpc:…` | shipped in VSIX | +| .NET Framework x86 | `fsi.exe` | shipped in VSIX | +| Arm64 | `fsiArm64.exe` | shipped in VSIX | + +`#reset core` / `#reset net472` / etc. exported as a specialized-content-type command that displaces the package's generic `#reset` (Roslyn's `GetApplicableCommands` name-replacement mechanism). + +### 2.6 Debugging (parity + improvement over C#) + +Preserved from the old window, re-hosted on the new one: + +- Attach/Detach debugger commands on the window toolbar; attach targets the host PID **which the RPC handshake now reports reliably** (no PID file, no 2-second polling). +- Debuggability check (`--debug+ --optimize-` arg inspection) + suppressible warning — port as-is. +- "Debug in Interactive" (`#dbgbreak` before the selection) — port as-is. + +--- + +## 3. Work plan + +### Phase 0 — Spike: new window shell (1–2 weeks) — partly done + +Goal: de-risk the InteractiveWindow dependency before touching the compiler. + +- Add `Microsoft.VisualStudio.InteractiveWindow` + `VsInteractiveWindow` package references; declare the `Microsoft.VisualStudio.InteractiveWindow` **Prerequisite** in `vsintegration/Vsix/VisualFSharpFull/Source.extension.vsixmanifest` (Roslyn: `[4.0.0.0,5.0.0.0)`). +- New project `vsintegration/src/FSharp.Interactive.Window/` (C# or F#; Roslyn's is C# — C# recommended for MEF attribute ergonomics and to crib code directly). +- Minimal `FSharpInteractiveEvaluator : IInteractiveEvaluator` that wraps the **existing** `Session.FsiSessions` stdin/stdout machinery: `ExecuteCodeAsync` → `SendInput`, output events → `CurrentWindow.OutputWriter`. +- `FSharpVsInteractiveWindowProvider` + `FSharpVsInteractiveWindowPackage` (copy Roslyn's `VsInteractiveWindowProvider`/`VsInteractiveWindowPackage` shape, incl. `IVsToolWindowFactory` for layout persistence). +- `SetLanguage` with the F# content type → confirm classification appears in the input buffer for free. +- Exit criterion: a working "F# Interactive (New)" window behind an experimental feature flag, coexisting with the old one. + +### Phase 1 — fsi JSON-RPC server mode (compiler side) — DONE + +- `src/Compiler/Interactive/` + `src/fsi/`: new `--fsi-server-jsonrpc:` mode. The dispatch loop runs on a background thread while the main thread drives the event loop; interactions are evaluated via `FsiEvaluationSession.EvalInteractionNonThrowing` marshalled through `EventLoopInvoke`, exactly as the standard input path already does, so GUI scripts behave as they do at the console. +- DTOs + protocol per §2.2; `StreamJsonRpc` dependency for fsi (or a minimal hand-rolled header-delimited JSON layer if adding the dependency to the compiler tree is contentious — decide early with upstream). +- Named pipe with proper ACLs (current-user only; Roslyn reuses its compiler-server `NamedPipeUtil` — fsi can do the same with `FSharp.Compiler`'s pipe helpers or a copy). +- Interrupt over RPC calling the existing ctrl-break machinery; retire `CtrlBreakClient` usage from the VS side. +- Orphan detection: watch client PID, exit on client death. +- Tests: end-to-end host tests modeled on Roslyn's `src/Interactive/HostTest` (init, execute, crash/restart, culture, interrupt, orphaning) — runnable without VS. + +### Phase 2 — Evaluator + session on the new protocol — partly done + +- `FSharpInteractiveSession`: single `AsyncBatchingWorkQueue`-style queue serializing init/execute/set-paths (reset preempts); `LazyRemoteService` lifecycle port; auto-restart; pending-buffer queue. +- Full `IInteractiveEvaluator`: `CanExecuteCode` (§2.4), `GetPrompt`, `ResetAsync` with platform args, `InitializeAsync`, `AbortExecution` → RPC `Interrupt` (note: this makes F# *better* than C# Interactive, whose `AbortExecution` is an unimplemented TODO). +- Structured diagnostics from `ExecutionResult` rendered as error-classified output. +- Delete the stdin/stdout path from the new window (old window untouched). + +### Phase 3 — IntelliSense (3–5 weeks, partially parallel with Phase 2) + +- Submission documents in the workspace + open-buffer wiring + synthetic `Submission{N}.fsx` paths (§2.3 items 1–2). +- Concatenated-prelude checking model with position offset mapping (§2.3 Phase A); completion/quick info/signature help/diagnostics in the input buffer. +- Init-race pending buffers; chain-from-last-successful; inert classification on reset. +- Interactive workspace-kind service overrides (navigation/rename/fixes/undo). +- REPL command + hash-directive completion. +- File an FCS design issue for §2.3 Phase B (exposing fsi's incremental tcState) — long-lead upstream conversation. + +### Phase 4 — Commands, options, project integration (2–3 weeks) + +- Rewire `MenusAndCommands.vsct` targets: Alt+Enter Send Selection/Line, "Execute in Interactive", "Debug in Interactive" → new window (`window.SubmitAsync`, preserving the no-selection→current-line + caret-advance behavior; consider Roslyn's syntax-aware selection expansion from `SendToInteractiveSubmissionProvider`). +- `AddReferences` (Solution Explorer "Send project references to F# Interactive") → `#r` submissions. +- Optional: "Initialize Interactive with Project" parity — build project, reset with platform inferred from TFM, `SetPaths`, `#r` output assembly + references, `open` default namespaces (Roslyn's `ResetInteractive` flow). +- Port `FsiPropertyPage` (Tools → Options → F# Tools → F# Interactive): args, platform default, shadow copy (`--shadowcopyreferences` still honored via args), langversion preview, debug mode. +- Window caption platform suffix; `#reset` platform args; F1 help keyword. + +### Phase 5 — Debugging parity (1–2 weeks) + +- Port attach/detach commands, debuggability check + registry-backed suppression, `#dbgbreak` flow onto the new window/host (PID now from handshake). + +### Phase 6 — Cutover and deletion (1–2 weeks) + +- Flip the feature flag default; one release of coexistence if desired. +- Delete `FSharp.VS.FSI` (all of `fsiSessionToolWindow.fs`, `fsiLanguageService.fs`, `fsiTextBufferStream.fs`, `sessions.fs` stdin machinery, MPF `Microsoft.VisualStudio.Package.LanguageService.15.0` dependency), the `SERVER-PROMPT` support code in fsi (after a deprecation window — external tools may scrape it), `ITestVFSI` (replace tests with InteractiveWindow-based test host, cf. Roslyn's `InteractiveWindowTestHost`). +- Localization: migrate `VFSIstrings` still in use; drop the rest. +- Update docs; announce the fsi server mode publicly (Ionide et al. will want it). + +Total: roughly 3–4 months of focused work; Phases 1 and 3 carry the technical risk. + +--- + +## 4. Risks and open questions + +1. **vs-interactive-window is in maintenance mode.** Acceptable: VS ships it as a prerequisite component and C#/Python depend on it; API surface is stable. Fallback exists (fork is MIT). +2. ~~**StreamJsonRpc in the compiler tree**~~ — **resolved: both ends use it.** The transport is + `StreamJsonRpc` over a `HeaderDelimitedMessageHandler`, the same combination Roslyn's interactive + host uses, at both ends. Nothing about JSON or its framing is written by hand. + + The version is pinned to 2.26.10, which is what Roslyn's packages already force into + `vsintegration`. Pinning it to anything else is what would turn a Roslyn bump into a downgrade + conflict, so the central version must follow Roslyn rather than lead it. + + The cost is real and worth stating: fsi's output gains eight assemblies, about 3 MB — + `StreamJsonRpc`, `Newtonsoft.Json`, `MessagePack` (and its annotations), `Nerdbank.MessagePack`, + `Nerdbank.Streams`, `Microsoft.VisualStudio.Threading` and `Microsoft.VisualStudio.Validation`. + In exchange, both ends of the protocol rest on one battle-tested implementation instead of on + framing and dispatch maintained here. On the Visual Studio side there is no cost at all: Visual + Studio already loads the library, so the reference is compile-time only. +3. **Concatenated-prelude checker cost** on very long sessions — measure; FCS caches aggressively for single-file edits, and the prelude prefix is immutable between submissions. Phase B removes the concern structurally. +4. **`it` and value printing**: keep fsi's stdout printing as the source of truth (don't reformat in the IDE) — avoids divergence with `fsi.PrintDepth`/formatters users set in scripts. +5. **`#quit`** must terminate cleanly in server mode (host exits → window prints exit message, next Enter restarts — matches current behavior). +6. **Upstreaming**: split PRs — (1) fsi server mode (compiler repo, no VS dependency, independently testable), (2) VS window (vsintegration). The plan intentionally keeps the seam clean. +7. **LSP future**: submission documents with real paths + workspace registration keep the door open for serving REPL buffers over LSP later (Roslyn already registers its interactive workspace with LSP), aligning with the ongoing LSP work in this fork. diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index e6034dca8df..0738603eea0 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -1,6 +1,7 @@ ### Added * Code-fixes for FS3888 (compiler-semantic attribute on the `.fs` but not the `.fsi`): copy the attribute into the `.fsi`, or remove it from the `.fs`. ([Issue #19560](https://github.com/dotnet/fsharp/issues/19560), [PR #19880](https://github.com/dotnet/fsharp/pull/19880)) +* The F# Interactive window is rebuilt on the Interactive Window package and talks to `dotnet fsi` over its JSON-RPC server mode: the input and output are coloured, Enter submits only a complete interaction, diagnostics carry positions in the submitted file, and the session starts in the solution folder, so the SDK that `global.json` names supplies the compiler. ([PR #20565](https://github.com/dotnet/fsharp/pull/20565)) * Expand `` in IDE tooltips, completion, and signature help, inheriting XML documentation from base classes, interfaces, overridden members, and constructors. ([Issue #19175](https://github.com/dotnet/fsharp/issues/19175), [PR #19188](https://github.com/dotnet/fsharp/pull/19188)) ### Fixed diff --git a/eng/Packages.props b/eng/Packages.props index eac871c2d58..b4cea769b29 100644 --- a/eng/Packages.props +++ b/eng/Packages.props @@ -70,6 +70,9 @@ + + + + + diff --git a/tests/FSharp.Compiler.Interactive.Server.Tests/FSharp.Compiler.Interactive.Server.Tests.fsproj b/tests/FSharp.Compiler.Interactive.Server.Tests/FSharp.Compiler.Interactive.Server.Tests.fsproj index 6c96a8f7e7d..35b79b81c56 100644 --- a/tests/FSharp.Compiler.Interactive.Server.Tests/FSharp.Compiler.Interactive.Server.Tests.fsproj +++ b/tests/FSharp.Compiler.Interactive.Server.Tests/FSharp.Compiler.Interactive.Server.Tests.fsproj @@ -24,6 +24,12 @@ interactiveProtocol.fs + + + SubmissionAnalysis.fs + + diff --git a/tests/FSharp.Compiler.Interactive.Server.Tests/SubmissionAnalysisTests.fs b/tests/FSharp.Compiler.Interactive.Server.Tests/SubmissionAnalysisTests.fs new file mode 100644 index 00000000000..4743d978ffb --- /dev/null +++ b/tests/FSharp.Compiler.Interactive.Server.Tests/SubmissionAnalysisTests.fs @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +/// Tests for the rule that decides whether Enter submits an interaction or adds another line. +/// +/// The rule is what a user feels on every keystroke, and it is easy to get subtly wrong: a bracket +/// inside a string, a terminator inside a comment, a line that merely looks finished. The analysis +/// is lexical so that it can run on the UI thread, which makes these cases worth pinning down. +module FSharp.Compiler.Interactive.Server.Tests.SubmissionAnalysisTests + +open Xunit + +open Microsoft.VisualStudio.FSharp.Interactive + +let private assertComplete text = + Assert.True(SubmissionAnalysis.isComplete text, sprintf "expected a complete submission: <<%s>>" text) + +let private assertIncomplete text = + Assert.False(SubmissionAnalysis.isComplete text, sprintf "expected an incomplete submission: <<%s>>" text) + +[] +let ``an explicit terminator always submits`` () = + assertComplete "1 + 1;;" + assertComplete "let f x =\n x + 1\n;;" + // Even mid-construct: the user asked for it. + assertComplete "let xs = [1; 2];;" + +[] +let ``a finished expression submits without a terminator`` () = + assertComplete "1 + 1" + assertComplete "printfn \"hi\"" + assertComplete "let x = 40" + assertComplete "let f a b =\n a + b" + +[] +let ``a line that visibly continues takes another line`` () = + assertIncomplete "let x =" + assertIncomplete "fun x ->" + assertIncomplete "1 +" + assertIncomplete "if true then" + +[] +let ``an unclosed bracket takes another line`` () = + assertIncomplete "printfn (\"a\"" + assertIncomplete "let xs = [1; 2" + assertIncomplete "let xs = [| 1; 2" + assertIncomplete "let r = {| A = 1" + +[] +let ``closed brackets do not hold the submission open`` () = + assertComplete "printfn (\"a\")" + assertComplete "let xs = [1; 2]" + assertComplete "let xs = [| 1; 2 |]" + +[] +let ``an unterminated string or comment takes another line`` () = + assertIncomplete "let s = \"abc" + assertIncomplete "(* comment" + +[] +let ``brackets and terminators inside a string do not count`` () = + // The contents of a literal are not code, so neither the brackets nor the ';;' in these change + // whether the submission is finished. + assertComplete "let s = \"([{\"" + assertComplete "let s = \"a;;b\"" + +[] +let ``a string literal can end a submission`` () = + // The last token here is the literal, not the '=' before it. + assertComplete "let greeting = \"hello\"" + +[] +let ``a terminator inside a string is not a terminator`` () = + assertIncomplete "let s = \";;" + +[] +let ``a comment does not change what the last token was`` () = + assertComplete "let x = 1 // ([\n" + assertComplete "(* c *) let x = 1" + assertIncomplete "let x = // nothing yet" + +[] +let ``an empty submission is allowed through`` () = + // The window uses an empty submission to start a session that has gone away. + assertComplete "" + assertComplete " \n " + +[] +let ``the terminator is added only when it is missing`` () = + Assert.Equal("1 + 1;;", SubmissionAnalysis.withTerminator "1 + 1;;") + Assert.Equal("1 + 1\n;;", SubmissionAnalysis.withTerminator "1 + 1") + // A ';;' that is only part of a string does not count as one. + Assert.Equal("let s = \"a;;b\"\n;;", SubmissionAnalysis.withTerminator "let s = \"a;;b\"") diff --git a/vsintegration/src/FSharp.Interactive.Window/FSharp.Interactive.Window.fsproj b/vsintegration/src/FSharp.Interactive.Window/FSharp.Interactive.Window.fsproj new file mode 100644 index 00000000000..5bcbbf233c8 --- /dev/null +++ b/vsintegration/src/FSharp.Interactive.Window/FSharp.Interactive.Window.fsproj @@ -0,0 +1,61 @@ + + + + + + Library + true + $(OtherFlags) --subsystemversion:6.00 + true + + + + + + interactiveProtocol.fs + + + + + + + + + + + + + + + + + + + + + + + + + + + + + FSharp.Interactive.Window + $(VSAssemblyVersion) + $PackageFolder$\FSharp.Interactive.Window.dll + + + + + + + + + + + diff --git a/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveEvaluator.fs b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveEvaluator.fs new file mode 100644 index 00000000000..59e9f819109 --- /dev/null +++ b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveEvaluator.fs @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Interactive + +open System +open System.Globalization +open System.Threading.Tasks + +open Microsoft.VisualStudio.InteractiveWindow + +module internal ResultRendering = + + let formatDiagnostic (diagnostic: FSharp.Compiler.Interactive.Protocol.DiagnosticInfo) = + sprintf + "%s(%d,%d): %s FS%04d: %s" + diagnostic.fileName + diagnostic.startLine + (diagnostic.startColumn + 1) + diagnostic.severity + diagnostic.errorNumber + diagnostic.message + +/// Connects the interactive window to an F# Interactive session. +[] +type internal FSharpInteractiveEvaluator + ( + host: InteractiveHostClient, + getOptions: unit -> InteractiveHostOptions, + onPlatformChanged: InteractiveHostPlatform -> unit + ) = + + let mutable currentWindow: IInteractiveWindow = null + let mutable outputSubscription: IDisposable = null + let mutable errorSubscription: IDisposable = null + let mutable exitedSubscription: IDisposable = null + let mutable disposed = false + let mutable requestedPlatform: InteractiveHostPlatform option = None + + // Output arrives on the threads pumping the session's console streams, so it goes through the + // window's writers rather than its editing operations, which belong to the UI thread. + let write (text: string) = + match currentWindow with + | null -> () + | window -> window.OutputWriter.Write text + + let writeError (text: string) = + match currentWindow with + | null -> () + | window -> window.ErrorOutputWriter.Write text + + let writeErrorLine (text: string) = + match currentWindow with + | null -> () + | window -> window.ErrorOutputWriter.WriteLine text + + let optionsForNextSession () = + let options = getOptions () + + match requestedPlatform with + | Some platform -> { options with Platform = platform } + | None -> options + + let reportDiagnostics (result: FSharp.Compiler.Interactive.Protocol.ExecutionResult) = + match result.diagnostics with + | null -> () + | diagnostics -> + for diagnostic in diagnostics do + writeErrorLine (ResultRendering.formatDiagnostic diagnostic) + + match box result.``exception`` with + | null -> () + | _ -> + writeErrorLine result.``exception``.message + + if not (String.IsNullOrWhiteSpace result.``exception``.stackTrace) then + writeErrorLine result.``exception``.stackTrace + + let ensureSessionAsync () = + task { + match! host.EnsureStartedAsync(optionsForNextSession ()) with + | Result.Ok _ -> return true + | Result.Error message -> + writeErrorLine message + return false + } + + let unsubscribe (subscription: IDisposable) = + match subscription with + | null -> () + | subscription -> subscription.Dispose() + + let reportSessionExit exitCode = + writeErrorLine ( + String.Format( + CultureInfo.CurrentCulture, + "{0} (exit code {1})", + VFSIstrings.SR.sessionTerminationDetected (), + exitCode + ) + ) + + member _.CurrentPlatform = + match requestedPlatform with + | Some platform -> platform + | None -> (getOptions ()).Platform + + member _.RequestPlatform platform = requestedPlatform <- Some platform + + member _.EvaluatingProcessId = host.EvaluatingProcessId + + member _.Host = host + + interface IInteractiveEvaluator with + + member _.CurrentWindow + with get () = currentWindow + and set window = + currentWindow <- window + + unsubscribe outputSubscription + unsubscribe errorSubscription + unsubscribe exitedSubscription + + if not (isNull window) then + outputSubscription <- host.OutputReceived.Subscribe write + errorSubscription <- host.ErrorOutputReceived.Subscribe writeError + exitedSubscription <- host.ProcessExited.Subscribe reportSessionExit + + member _.InitializeAsync() = + task { + let! started = ensureSessionAsync () + return ExecutionResult started + } + + // `initialize` distinguishes a reset that runs start-up work from one that does not. An F# + // session has none to vary, and the flag never means "do not start a replacement". + member _.ResetAsync(_initialize) = + task { + let options = optionsForNextSession () + onPlatformChanged options.Platform + + match! host.ResetAsync options with + | Result.Ok _ -> return ExecutionResult true + | Result.Error message -> + writeErrorLine message + return ExecutionResult false + } + + member _.CanExecuteCode(text) = SubmissionAnalysis.isComplete text + + member _.ExecuteCodeAsync(text) = + task { + let! started = ensureSessionAsync () + + if not started then + return ExecutionResult false + elif String.IsNullOrWhiteSpace text then + return ExecutionResult true + else + match! host.ExecuteAsync(SubmissionAnalysis.withTerminator text) with + | Result.Error message -> + writeErrorLine message + return ExecutionResult false + | Result.Ok result -> + reportDiagnostics result + return ExecutionResult result.success + } + + member _.AbortExecution() = host.InterruptAsync() |> ignore + + member _.FormatClipboard() = null + + member _.GetPrompt() = + if + not (isNull currentWindow) + && not (isNull currentWindow.CurrentLanguageBuffer) + && currentWindow.CurrentLanguageBuffer.CurrentSnapshot.LineCount > 1 + then + "- " + else + "> " + + interface IDisposable with + member _.Dispose() = + if not disposed then + disposed <- true + unsubscribe outputSubscription + unsubscribe errorSubscription + unsubscribe exitedSubscription + (host :> IDisposable).Dispose() diff --git a/vsintegration/src/FSharp.Interactive.Window/FSharpVsInteractiveWindowPackage.fs b/vsintegration/src/FSharp.Interactive.Window/FSharpVsInteractiveWindowPackage.fs new file mode 100644 index 00000000000..02a85af23c7 --- /dev/null +++ b/vsintegration/src/FSharp.Interactive.Window/FSharpVsInteractiveWindowPackage.fs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Interactive + +open System +open System.Runtime.InteropServices +open System.Threading +open System.Threading.Tasks + +open Microsoft.VisualStudio +open Microsoft.VisualStudio.ComponentModelHost +open Microsoft.VisualStudio.InteractiveWindow.Shell +open Microsoft.VisualStudio.Shell +open Microsoft.VisualStudio.Shell.Interop + +module internal InteractiveWindowPackageGuids = + + [] + let PackageIdString = "F5C1B3D2-8E47-4A96-9C0B-1D7E4A2F6B39" + + /// Docks the window with the Output window, where the existing one appears. + [] + let OutputWindowIdString = "34E76E81-EE4A-11D0-AE2E-00A0C90FFFC3" + +[] +[] +[] +type internal FSharpVsInteractiveWindowPackage() as this = + inherit AsyncPackage() + + let mutable provider: FSharpVsInteractiveWindowProvider option = None + + let getProvider () = + match provider with + | Some provider -> Some provider + | None -> + match this.GetService(typeof) with + | :? IComponentModel as components -> + let resolved = + components.DefaultExportProvider.GetExportedValue() + + provider <- Some resolved + Some resolved + | _ -> None + + member _.Provider = getProvider () + + override _.InitializeAsync(cancellationToken: CancellationToken, progress: IProgress) = + base.InitializeAsync(cancellationToken, progress) + + interface IVsToolWindowFactory with + + /// Called when Visual Studio restores the window from a persisted layout. + member _.CreateToolWindow(toolWindowType: byref, id: uint32) = + if toolWindowType = InteractiveWindowGuids.ToolWindowId then + match getProvider () with + | Some provider -> + provider.Create(int id) |> ignore + VSConstants.S_OK + | None -> VSConstants.E_FAIL + else + VSConstants.E_FAIL diff --git a/vsintegration/src/FSharp.Interactive.Window/FSharpVsInteractiveWindowProvider.fs b/vsintegration/src/FSharp.Interactive.Window/FSharpVsInteractiveWindowProvider.fs new file mode 100644 index 00000000000..53209384158 --- /dev/null +++ b/vsintegration/src/FSharp.Interactive.Window/FSharpVsInteractiveWindowProvider.fs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Interactive + +open System +open System.ComponentModel.Composition +open System.Diagnostics +open System.IO +open System.Runtime.InteropServices +open System.Threading + +open Microsoft.VisualStudio +open Microsoft.VisualStudio.InteractiveWindow.Shell +open Microsoft.VisualStudio.Shell +open Microsoft.VisualStudio.Shell.Interop +open Microsoft.VisualStudio.Utilities + +open Microsoft.VisualStudio.FSharp.Interactive.Session + +module internal InteractiveWindowGuids = + + /// Visual Studio persists the window's place in the layout under this, so it must not change + /// once shipped. + [] + let ToolWindowIdString = "6B0F0D9E-1B4A-4C4E-9E2D-6F3B2A5C7D18" + + let ToolWindowId = Guid ToolWindowIdString + + let FSharpLanguageServiceId = Guid "BC6DD5A5-D4D6-4dab-A00D-A51242DBAF1B" + + [] + let FSharpContentTypeName = "F#" + +/// Reads the session settings the existing Tools, Options page writes. +module internal InteractiveHostOptionsFactory = + + let private hostDirectory () = + match Path.GetDirectoryName(typeof.Assembly.Location) with + | null -> Environment.CurrentDirectory + | directory -> directory + + let currentPlatform () = + if SessionsProperties.fsiUseNetCore then NetCore + elif RuntimeInformation.ProcessArchitecture = Architecture.Arm64 then NetFrameworkArm64 + elif SessionsProperties.useAnyCpuVersion then NetFramework64 + else NetFramework32 + + let create platform = + { + Platform = platform + HostDirectory = hostDirectory () + InitialWorkingDirectory = Environment.GetFolderPath Environment.SpecialFolder.UserProfile + UserArguments = SessionsProperties.fsiArgs + ShadowCopyReferences = SessionsProperties.fsiShadowCopy + DebugMode = SessionsProperties.fsiDebugMode + LanguageVersionPreview = SessionsProperties.fsiPreview + UICultureLcid = Thread.CurrentThread.CurrentUICulture.LCID + } + +/// Creates and owns the F# Interactive tool window. +[)>] +[] +type internal FSharpVsInteractiveWindowProvider + [] + (windowFactory: IVsInteractiveWindowFactory, contentTypeRegistry: IContentTypeRegistryService) = + + let mutable window: IVsInteractiveWindow = null + let mutable evaluator: FSharpInteractiveEvaluator option = None + + let captionFor (platform: InteractiveHostPlatform) = + sprintf "%s (%s)" (VFSIstrings.SR.fsharpInteractive ()) platform.Description + + let setCaption platform = + match box window with + | :? ToolWindowPane as pane -> pane.Caption <- captionFor platform + | _ -> () + + let currentOptions () = + InteractiveHostOptionsFactory.create (InteractiveHostOptionsFactory.currentPlatform ()) + + member this.Create(instanceId: int) = + let host = new InteractiveHostClient(Process.GetCurrentProcess().Id) + let created = new FSharpInteractiveEvaluator(host, currentOptions, setCaption) + evaluator <- Some created + + window <- + windowFactory.Create( + InteractiveWindowGuids.ToolWindowId, + instanceId, + captionFor (InteractiveHostOptionsFactory.currentPlatform ()), + created, + __VSCREATETOOLWIN.CTW_fForceCreate + ) + + window.SetLanguage( + InteractiveWindowGuids.FSharpLanguageServiceId, + contentTypeRegistry.GetContentType InteractiveWindowGuids.FSharpContentTypeName + ) + + let interactiveWindow = window.InteractiveWindow + interactiveWindow.TextView.Closed.Add(fun _ -> (created :> IDisposable).Dispose()) + interactiveWindow.InitializeAsync() |> ignore + window + + member this.Open(instanceId: int, focus: bool) = + if isNull (box window) then + this.Create instanceId |> ignore + + window.Show focus + window + + member _.Window = window + + member _.Evaluator = evaluator diff --git a/vsintegration/src/FSharp.Interactive.Window/InteractiveHost.fs b/vsintegration/src/FSharp.Interactive.Window/InteractiveHost.fs new file mode 100644 index 00000000000..70b4583ed19 --- /dev/null +++ b/vsintegration/src/FSharp.Interactive.Window/InteractiveHost.fs @@ -0,0 +1,459 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Interactive + +open System +open System.Diagnostics +open System.IO +open System.IO.Pipes +open System.Text +open System.Threading +open System.Threading.Tasks + +open StreamJsonRpc + +open FSharp.Compiler.Interactive.Protocol + +type InteractiveHostPlatform = + | NetCore + | NetFramework64 + | NetFramework32 + | NetFrameworkArm64 + + member this.Description = + match this with + | NetCore -> ".NET" + | NetFramework64 -> ".NET Framework (64-bit)" + | NetFramework32 -> ".NET Framework (32-bit)" + | NetFrameworkArm64 -> ".NET Framework (Arm64)" + + member this.CommandLineName = + match this with + | NetCore -> "core" + | NetFramework64 -> "64" + | NetFramework32 -> "32" + | NetFrameworkArm64 -> "arm64" + + static member TryParse(name: string) = + match name.Trim().ToLowerInvariant() with + | "core" + | "net" -> Some NetCore + | "64" + | "framework64" -> Some NetFramework64 + | "32" + | "framework32" -> Some NetFramework32 + | "arm64" -> Some NetFrameworkArm64 + | _ -> None + +type InteractiveHostOptions = + { + Platform: InteractiveHostPlatform + + /// Directory holding the desktop fsi executables shipped in the extension. + HostDirectory: string + + InitialWorkingDirectory: string + + /// The user's own arguments, from Tools, Options. + UserArguments: string + + ShadowCopyReferences: bool + DebugMode: bool + LanguageVersionPreview: bool + UICultureLcid: int + } + +module internal FsiLocator = + + let private desktopExecutableName platform = + match platform with + | NetFramework32 -> "fsi.exe" + | NetFrameworkArm64 -> "fsiArm64.exe" + | _ -> "fsiAnyCpu.exe" + + let findDotnetHost () = + match Environment.GetEnvironmentVariable "DOTNET_HOST_PATH" with + | path when not (String.IsNullOrEmpty path) && File.Exists path -> path + | _ -> + + let programFiles = + match Environment.GetEnvironmentVariable "ProgramW6432" with + | path when not (String.IsNullOrEmpty path) -> path + | _ -> Environment.GetFolderPath Environment.SpecialFolder.ProgramFiles + + Path.Combine(programFiles, "dotnet", "dotnet.exe") + + let locate (options: InteractiveHostOptions) = + match options.Platform with + | NetCore -> + let host = findDotnetHost () + + if File.Exists host then + Result.Ok(host, [ "fsi" ]) + else + Result.Error(VFSIstrings.SR.couldNotFindFsiExe host) + + | platform -> + let candidate = Path.Combine(options.HostDirectory, desktopExecutableName platform) + + if File.Exists candidate then + Result.Ok(candidate, []) + else + Result.Error(VFSIstrings.SR.couldNotFindFsiExe candidate) + +/// One live F# Interactive process together with the control channel to it. +[] +type internal RemoteSession(session: Process, pipe: Stream, rpc: JsonRpc, initialization: InitializeResult) = + + member _.Process = session + member _.Rpc = rpc + member _.Initialization = initialization + + /// The process evaluating code, which under `dotnet fsi` is not the one that was launched. + member _.EvaluatingProcessId = initialization.processId + + member _.IsAlive = + try + not session.HasExited + with _ -> + false + + member _.Dispose() = + // Closing the channel is how the session learns its host is gone; killing covers one that + // is wedged and no longer reading. + try + rpc.Dispose() + with _ -> + () + + try + pipe.Dispose() + with _ -> + () + + try + if not session.HasExited then + session.Kill() + with _ -> + () + + try + session.Dispose() + with _ -> + () + +/// Owns the F# Interactive process behind the window. +[] +type internal InteractiveHostClient(clientProcessId: int) = + + let stateLock = obj () + let startGate = new SemaphoreSlim(1, 1) + let mutable current: RemoteSession option = None + let mutable disposed = false + + let outputReceived = Event() + let errorOutputReceived = Event() + let processExited = Event() + + // Read as characters rather than lines: a script prompting with `printf "name? "` writes no + // newline, and waiting for one would hide the prompt. + let pump (reader: StreamReader) (report: string -> unit) = + let thread = + Thread( + (fun () -> + let buffer = Array.zeroCreate 1024 + + try + let rec loop () = + let count = reader.Read(buffer, 0, buffer.Length) + + if count > 0 then + report (String(buffer, 0, count)) + loop () + + loop () + with _ -> + ()), + IsBackground = true + ) + + thread.Start() + + let quoteIfNeeded (argument: string) = + if argument.Contains " " && not (argument.StartsWith "\"") then + "\"" + argument + "\"" + else + argument + + let createStartInfo (options: InteractiveHostOptions) (pipeName: string) = + match FsiLocator.locate options with + | Result.Error message -> Result.Error message + | Result.Ok(executable, leadingArguments) -> + + let arguments = ResizeArray() + let addSwitch (switch: string) = arguments.Add(quoteIfNeeded switch) + + for argument in leadingArguments do + addSwitch argument + + addSwitch "--nologo" + addSwitch ("--fsi-server-jsonrpc:" + pipeName) + addSwitch (sprintf "--fsi-server-output-codepage:%d" Encoding.UTF8.CodePage) + addSwitch (sprintf "--fsi-server-input-codepage:%d" Encoding.UTF8.CodePage) + addSwitch (sprintf "--fsi-server-lcid:%d" options.UICultureLcid) + + // A command-line fragment holding any number of switches, so it goes on unquoted and before + // the switches the window insists on for debugging. + if not (String.IsNullOrWhiteSpace options.UserArguments) then + arguments.Add(options.UserArguments.Trim()) + + if options.Platform <> NetCore then + addSwitch ( + if options.ShadowCopyReferences then + "--shadowcopyreferences+" + else + "--shadowcopyreferences-" + ) + + if options.DebugMode then + addSwitch "--optimize-" + addSwitch "--debug+" + + if options.LanguageVersionPreview then + addSwitch "--langversion:preview" + + let startInfo = + ProcessStartInfo( + FileName = executable, + Arguments = String.Join(" ", arguments), + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8 + ) + + if Directory.Exists options.InitialWorkingDirectory then + startInfo.WorkingDirectory <- options.InitialWorkingDirectory + + Result.Ok startInfo + + let startAsync (options: InteractiveHostOptions) (cancellationToken: CancellationToken) = + task { + let pipeName = "FSharpInteractive." + Guid.NewGuid().ToString "N" + + match createStartInfo options pipeName with + | Result.Error message -> return Result.Error message + | Result.Ok startInfo -> + + let session = new Process(StartInfo = startInfo, EnableRaisingEvents = true) + + if not (session.Start()) then + return Result.Error(VFSIstrings.SR.couldNotFindFsiExe startInfo.FileName) + else + + pump session.StandardOutput outputReceived.Trigger + pump session.StandardError errorOutputReceived.Trigger + + // Without this a session that dies before the handshake leaves the connect below + // waiting out its whole timeout. + use exitedDuringConnect = new CancellationTokenSource() + + session.Exited.Add(fun _ -> + try + exitedDuringConnect.Cancel() + with _ -> + ()) + + use connectCancellation = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, exitedDuringConnect.Token) + + let pipe = + new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous) + + try + do! pipe.ConnectAsync connectCancellation.Token + + let rpc = new JsonRpc(new HeaderDelimitedMessageHandler(pipe, new JsonMessageFormatter())) + rpc.StartListening() + + let! handshake = + rpc.InvokeWithParameterObjectAsync( + Methods.Initialize, + { clientProcessId = clientProcessId }, + cancellationToken + ) + + let remote = RemoteSession(session, pipe, rpc, handshake) + + session.Exited.Add(fun _ -> + let wasCurrent = + lock stateLock (fun () -> + match current with + | Some running when obj.ReferenceEquals(running, remote) -> + current <- None + true + | _ -> false) + + if wasCurrent then + processExited.Trigger( + try + session.ExitCode + with _ -> + 0 + )) + + return Result.Ok remote + with e -> + pipe.Dispose() + + try + if not session.HasExited then + session.Kill() + with _ -> + () + + let detail = + if session.HasExited then + sprintf "%s (exit code %d)" e.Message session.ExitCode + else + e.Message + + return Result.Error detail + } + + member _.OutputReceived = outputReceived.Publish + + member _.ErrorOutputReceived = errorOutputReceived.Publish + + /// Raised when the session goes away without being asked to. + member _.ProcessExited = processExited.Publish + + member _.IsRunning = + lock stateLock (fun () -> current |> Option.exists (fun session -> session.IsAlive)) + + member _.EvaluatingProcessId = + lock stateLock (fun () -> current |> Option.map (fun session -> session.EvaluatingProcessId)) + + member _.Initialization = + lock stateLock (fun () -> current |> Option.map (fun session -> session.Initialization)) + + member private _.TryCurrent() = + lock stateLock (fun () -> current |> Option.filter (fun session -> session.IsAlive)) + + /// Starting is serialised: two callers arriving together would each launch an fsi, and one of + /// the two would be killed moments later having done nothing but start up. + member this.EnsureStartedAsync(options, ?cancellationToken) : Task> = + let cancellationToken = defaultArg cancellationToken CancellationToken.None + + task { + match this.TryCurrent() with + | Some running -> return Result.Ok running + | None -> + do! startGate.WaitAsync cancellationToken + + try + match this.TryCurrent() with + | Some running -> return Result.Ok running + | None -> + match! startAsync options cancellationToken with + | Result.Error message -> return Result.Error message + | Result.Ok started -> + let previous = + lock stateLock (fun () -> + if disposed then + None + else + let previous = current + current <- Some started + Some previous) + + match previous with + | None -> + started.Dispose() + return Result.Error "The interactive window was closed while the session was starting." + | Some previous -> + previous |> Option.iter (fun session -> session.Dispose()) + return Result.Ok started + finally + startGate.Release() |> ignore + } + + member this.ResetAsync(options, ?cancellationToken) = + let previous = + lock stateLock (fun () -> + let previous = current + current <- None + previous) + + previous |> Option.iter (fun session -> session.Dispose()) + + this.EnsureStartedAsync(options, ?cancellationToken = cancellationToken) + + member private this.InvokeAsync(method: string, parameters: obj, cancellationToken) = + task { + match this.TryCurrent() with + | None -> return Result.Error "No F# Interactive session is running." + | Some session -> + try + let! result = + session.Rpc.InvokeWithParameterObjectAsync(method, parameters, cancellationToken) + + return Result.Ok result + with e -> + return Result.Error e.Message + } + + /// `sourcePath` and `startLine` make the session report diagnostics against the user's own file + /// when the text came from an editor selection. + member this.ExecuteAsync(code: string, ?sourcePath: string, ?startLine: int, ?cancellationToken) = + let request = + { + code = code + sourcePath = Option.toObj sourcePath + startLine = + match startLine with + | Some line -> Nullable line + | None -> Nullable() + } + + this.InvokeAsync(Methods.Execute, request, defaultArg cancellationToken CancellationToken.None) + + member this.ExecuteFileAsync(path: string, ?cancellationToken) = + this.InvokeAsync(Methods.ExecuteFile, { path = path }, defaultArg cancellationToken CancellationToken.None) + + member this.SetPathsAsync(includePaths: string[], workingDirectory: string, ?cancellationToken) = + this.InvokeAsync( + Methods.SetPaths, + { + includePaths = includePaths + workingDirectory = workingDirectory + }, + defaultArg cancellationToken CancellationToken.None + ) + + member this.InterruptAsync() = + task { + match this.TryCurrent() with + | None -> return false + | Some session -> + try + let! result = session.Rpc.InvokeAsync(Methods.Interrupt) + return result.interrupted + with _ -> + return false + } + + interface IDisposable with + member _.Dispose() = + let previous = + lock stateLock (fun () -> + disposed <- true + let previous = current + current <- None + previous) + + previous |> Option.iter (fun session -> session.Dispose()) + startGate.Dispose() diff --git a/vsintegration/src/FSharp.Interactive.Window/SubmissionAnalysis.fs b/vsintegration/src/FSharp.Interactive.Window/SubmissionAnalysis.fs new file mode 100644 index 00000000000..908983cd3ab --- /dev/null +++ b/vsintegration/src/FSharp.Interactive.Window/SubmissionAnalysis.fs @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Interactive + +open System + +open FSharp.Compiler.Tokenization + +/// Decides whether pressing Enter submits what the user has typed or adds another line. +/// +/// The window asks on every Enter, on the UI thread, so the judgement is lexical rather than a +/// type check. +module internal SubmissionAnalysis = + + /// Tokens after which more input is always expected. + let private continuationTokens = + set + [ + "=" + "->" + "<-" + ":" + "," + ";" + "|" + "||" + "&&" + "+" + "-" + "*" + "/" + "%" + "**" + "@" + "^" + "|>" + "<|" + ">>" + "<<" + "then" + "else" + "elif" + "do" + "try" + "with" + "finally" + "function" + "fun" + "begin" + "match" + "if" + "let" + "use" + "and" + "or" + "in" + "when" + "as" + "of" + "new" + "static" + "member" + "override" + "abstract" + "type" + "module" + "namespace" + "open" + "rec" + "mutable" + "yield" + "return" + "->>" + ] + + let private opening = set [ "("; "["; "{"; "[|"; "[<"; "{|" ] + + let private closing = set [ ")"; "]"; "}"; "|]"; ">]"; "|}" ] + + let private tokenizer = FSharpSourceTokenizer([], Some "stdin.fsx", None) + + type private Scan = + { + /// Outside strings and comments. + OpenBrackets: int + InsideMultiLineConstruct: bool + LastToken: string option + EndsWithTerminator: bool + } + + [] + let private probeIdentifier = "__fsharp_interactive_probe__" + + let private scan (text: string) = + let lines = text.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n') + + let mutable state = FSharpTokenizerLexState.Initial + let mutable openBrackets = 0 + let mutable lastToken = None + + let scanLine (line: string) (record: bool) = + let lineTokenizer = tokenizer.CreateLineTokenizer line + let mutable firstColor = None + let mutable scanning = true + + while scanning do + match lineTokenizer.ScanToken state with + | Some token, nextState -> + state <- nextState + + if firstColor.IsNone then + firstColor <- Some token.ColorClass + + if record then + match token.ColorClass with + | FSharpTokenColorKind.Comment + | FSharpTokenColorKind.InactiveCode -> () + | FSharpTokenColorKind.String -> + // A literal ends a submission as a number would, but its contents are + // not code: brackets and terminators inside it must not count. + lastToken <- Some "\"\"" + | _ -> + let value = + if token.LeftColumn >= 0 && token.LeftColumn + token.FullMatchedLength <= line.Length then + line.Substring(token.LeftColumn, token.FullMatchedLength) + else + "" + + if not (String.IsNullOrWhiteSpace value) then + if opening.Contains value then + openBrackets <- openBrackets + 1 + elif closing.Contains value then + openBrackets <- openBrackets - 1 + + lastToken <- Some value + | None, nextState -> + state <- nextState + scanning <- false + + firstColor + + for line in lines do + scanLine line true |> ignore + + // The lexer state carries more than "inside a string or comment", so comparing it against + // the initial state says nothing. Tokenizing an identifier with the state the text left + // behind does: inside an unterminated string or comment the probe comes back coloured as + // part of that construct. + let insideMultiLineConstruct = + match scanLine probeIdentifier false with + | Some FSharpTokenColorKind.String + | Some FSharpTokenColorKind.Comment + | Some FSharpTokenColorKind.InactiveCode -> true + | _ -> false + + { + OpenBrackets = openBrackets + InsideMultiLineConstruct = insideMultiLineConstruct + LastToken = lastToken + EndsWithTerminator = (lastToken = Some ";;") + } + + let endsWithTerminator (text: string) = (scan text).EndsWithTerminator + + /// An explicit `;;` always submits; without one, the submission goes when nothing is visibly + /// left open. + let isComplete (text: string) = + if String.IsNullOrWhiteSpace text then + // The window submits an empty one to start a session. + true + else + let scanned = scan text + + if scanned.EndsWithTerminator then + true + elif scanned.InsideMultiLineConstruct || scanned.OpenBrackets > 0 then + false + else + match scanned.LastToken with + | Some token when continuationTokens.Contains token -> false + | _ -> true + + let withTerminator (text: string) = + if endsWithTerminator text then text else text + "\n;;" diff --git a/vsintegration/src/FSharp.VS.FSI/FSharp.VS.FSI.fsproj b/vsintegration/src/FSharp.VS.FSI/FSharp.VS.FSI.fsproj index 5827d12b71a..768b647869f 100644 --- a/vsintegration/src/FSharp.VS.FSI/FSharp.VS.FSI.fsproj +++ b/vsintegration/src/FSharp.VS.FSI/FSharp.VS.FSI.fsproj @@ -13,6 +13,7 @@ + From a276d367c9e4cd8a13cf1cf88d0a399fc11fbb1f Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 30 Aug 2026 14:53:22 +0200 Subject: [PATCH 02/27] Put the interactive window into the VSIX Its pkgdef, its MEF component, and the Interactive Window prerequisite that supplies the REPL engine the window runs on. Co-Authored-By: Claude Fable 5 --- .../Vsix/VisualFSharpFull/Source.extension.vsixmanifest | 4 ++++ .../Vsix/VisualFSharpFull/VisualFSharp.Core.targets | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/vsintegration/Vsix/VisualFSharpFull/Source.extension.vsixmanifest b/vsintegration/Vsix/VisualFSharpFull/Source.extension.vsixmanifest index f805558824b..528529c35a8 100644 --- a/vsintegration/Vsix/VisualFSharpFull/Source.extension.vsixmanifest +++ b/vsintegration/Vsix/VisualFSharpFull/Source.extension.vsixmanifest @@ -34,6 +34,7 @@ + @@ -48,10 +49,12 @@ + + @@ -63,6 +66,7 @@ + diff --git a/vsintegration/Vsix/VisualFSharpFull/VisualFSharp.Core.targets b/vsintegration/Vsix/VisualFSharpFull/VisualFSharp.Core.targets index db4b3097d66..cce7556255f 100644 --- a/vsintegration/Vsix/VisualFSharpFull/VisualFSharp.Core.targets +++ b/vsintegration/Vsix/VisualFSharpFull/VisualFSharp.Core.targets @@ -127,6 +127,15 @@ True + + FSharp.Interactive.Window + BuiltProjectOutputGroup%3bGetCopyToOutputDirectoryItems%3bPkgDefProjectOutputGroup%3bSatelliteDllsProjectOutputGroup%3b + DebugSymbolsProjectOutputGroup%3b + true + 2 + True + + {c4586a06-1402-48bc-8e35-a1b8642f895b} FSharp.UIResources From 39eb6c5ec75bc9269b89bde4e3b57d151ec6074d Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 30 Aug 2026 15:46:45 +0200 Subject: [PATCH 03/27] Point the editor's interactive commands at the new window "Send to Interactive" (Alt+Enter), "Send line" (Alt+') and the "F# Interactive" command now open and drive the window built on the interactive window package, not the legacy tool window. The command filter moves to the window's own project, where Roslyn also keeps this code, and reads the submission from the text view rather than through DTE: the selection, or the caret's line when there is none, in which case the caret advances so that repeated Alt+Enter walks down a script. Text sent from an editor carries its file and line to the session, so its diagnostics land on the user's own source. The interactive window submits text without saying where it came from, so the evaluator takes the origin from the command that is about to submit. The legacy filter keeps only "Debug in Interactive", which has not been ported yet, and the legacy window is no longer reachable from any command. Co-Authored-By: Claude Fable 5 --- .../Commands/FsiCommandService.fs | 29 +--- .../src/FSharp.Editor/FSharp.Editor.fsproj | 3 + .../LanguageService/LanguageService.fs | 13 +- .../FSharp.Interactive.Window.fsproj | 5 + .../FSharpInteractiveCommandFilter.fs | 141 ++++++++++++++++++ .../FSharpInteractiveEvaluator.fs | 23 ++- .../FSharpVsInteractiveWindowProvider.fs | 10 ++ 7 files changed, 195 insertions(+), 29 deletions(-) create mode 100644 vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveCommandFilter.fs diff --git a/vsintegration/src/FSharp.Editor/Commands/FsiCommandService.fs b/vsintegration/src/FSharp.Editor/Commands/FsiCommandService.fs index 0dd36a301d2..6a54e528dfc 100644 --- a/vsintegration/src/FSharp.Editor/Commands/FsiCommandService.fs +++ b/vsintegration/src/FSharp.Editor/Commands/FsiCommandService.fs @@ -37,19 +37,9 @@ type internal FsiCommandFilter(serviceProvider: System.IServiceProvider) = interface IOleCommandTarget with member x.Exec(pguidCmdGroup, nCmdId, nCmdexecopt, pvaIn, pvaOut) = + // Sending a selection or a line is handled by the interactive window; only debugging a + // selection still goes to the legacy one. if - pguidCmdGroup = VSConstants.VsStd11 - && nCmdId = uint32 VSConstants.VSStd11CmdID.ExecuteSelectionInInteractive - then - Hooks.OnMLSend fsiPackage.Value FsiEditorSendAction.ExecuteSelection null null - VSConstants.S_OK - elif - pguidCmdGroup = VSConstants.VsStd11 - && nCmdId = uint32 VSConstants.VSStd11CmdID.ExecuteLineInInteractive - then - Hooks.OnMLSend fsiPackage.Value FsiEditorSendAction.ExecuteLine null null - VSConstants.S_OK - elif pguidCmdGroup = Guids.guidInteractive && nCmdId = uint32 Guids.cmdIDDebugSelection then @@ -61,20 +51,7 @@ type internal FsiCommandFilter(serviceProvider: System.IServiceProvider) = VSConstants.E_FAIL member x.QueryStatus(pguidCmdGroup, cCmds, prgCmds, pCmdText) = - if pguidCmdGroup = VSConstants.VsStd11 then - for i = 0 to int cCmds - 1 do - if prgCmds.[i].cmdID = uint32 VSConstants.VSStd11CmdID.ExecuteSelectionInInteractive then - prgCmds.[i].cmdf <- uint32 (OLECMDF.OLECMDF_SUPPORTED ||| OLECMDF.OLECMDF_ENABLED) - elif prgCmds.[i].cmdID = uint32 VSConstants.VSStd11CmdID.ExecuteLineInInteractive then - prgCmds.[i].cmdf <- - uint32 ( - OLECMDF.OLECMDF_SUPPORTED - ||| OLECMDF.OLECMDF_ENABLED - ||| OLECMDF.OLECMDF_DEFHIDEONCTXTMENU - ) - - VSConstants.S_OK - elif pguidCmdGroup = Guids.guidInteractive then + if pguidCmdGroup = Guids.guidInteractive then for i = 0 to int cCmds - 1 do if prgCmds.[i].cmdID = uint32 Guids.cmdIDDebugSelection then let dbgState = Hooks.GetDebuggerState fsiPackage.Value diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj index 319bdd5a264..4b6eb45d41b 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj @@ -164,11 +164,14 @@ + + + diff --git a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs index 427baf0c6ab..ea29f4211ef 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs @@ -346,8 +346,17 @@ type internal FSharpPackage() as this = let! commandService = this.GetServiceAsync(typeof) let commandService = commandService :?> OleMenuCommandService - // FSI-LINKAGE-POINT: sited init - FSharp.Interactive.Hooks.fsiConsoleWindowPackageInitializeSited (this :> Package) commandService + // The "F# Interactive" command opens the window built on the interactive window + // package rather than the legacy tool window. + let interactiveWindow = + exportProvider.GetExport().Value + + let openInteractiveWindow = + CommandID(FSharp.Interactive.Guids.guidFsiPackageCmdSet, int FSharp.Interactive.Guids.cmdIDLaunchFsiToolWindow) + + commandService.AddCommand( + MenuCommand((fun _ _ -> interactiveWindow.Open(0, focus = true) |> ignore), openInteractiveWindow) + ) } |> CancellableTask.startAsTask cancellationToken) ) diff --git a/vsintegration/src/FSharp.Interactive.Window/FSharp.Interactive.Window.fsproj b/vsintegration/src/FSharp.Interactive.Window/FSharp.Interactive.Window.fsproj index 5bcbbf233c8..78b56b7b7f5 100644 --- a/vsintegration/src/FSharp.Interactive.Window/FSharp.Interactive.Window.fsproj +++ b/vsintegration/src/FSharp.Interactive.Window/FSharp.Interactive.Window.fsproj @@ -9,6 +9,10 @@ true + + + + $(ArtifactsDir)bin\fscAnyCpu\$(Configuration)\net472\ + + $(ArtifactsDir)bin\fsi\$(Configuration)\net472\fsi.exe true publish\ true diff --git a/vsintegration/Vsix/VisualFSharpFull/Properties/launchSettings.json b/vsintegration/Vsix/VisualFSharpFull/Properties/launchSettings.json index 7638dfe6832..f96bd2e9b42 100644 --- a/vsintegration/Vsix/VisualFSharpFull/Properties/launchSettings.json +++ b/vsintegration/Vsix/VisualFSharpFull/Properties/launchSettings.json @@ -12,7 +12,8 @@ "FSharpPreferAnyCpuTools": "true", "Fsc_NetFramework_ToolPath": "$(FSharpCompilerPathForDebuggingLocally)", "Fsc_NetFramework_AnyCpu_ToolExe": "fscAnyCpu.exe", - "FSHARP_OTEL_EXPORT": "" + "FSHARP_OTEL_EXPORT": "", + "FSHARP_INTERACTIVE_PATH": "$(FSharpInteractivePathForDebuggingLocally)" } }, "OTEL Export": { @@ -27,7 +28,8 @@ "FSharpPreferAnyCpuTools": "true", "Fsc_NetFramework_ToolPath": "$(FSharpCompilerPathForDebuggingLocally)", "Fsc_NetFramework_AnyCpu_ToolExe": "fscAnyCpu.exe", - "FSHARP_OTEL_EXPORT": "http://127.0.0.1:4317" + "FSHARP_OTEL_EXPORT": "http://127.0.0.1:4317", + "FSHARP_INTERACTIVE_PATH": "$(FSharpInteractivePathForDebuggingLocally)" } } } From 8aec25af102ffe8d9529be269b68f735034d8c15 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 30 Aug 2026 20:11:58 +0200 Subject: [PATCH 19/27] Colour the interactive window's input lexically The input buffer belongs to no project, so the editor's semantic classification never sees it, and the window was as plain as the legacy one. A tokenizer-based classifier fills the gap until Phase 3 makes submissions workspace documents. It serves only buffers carrying the interactive window property, which the window sets on each submission buffer it creates; a buffer belonging to a document keeps its project-driven colour. Confirmed against the package binary: AddLanguageBuffer stores the window on every submission buffer. Co-Authored-By: Claude Fable 5 --- .../ide/FSI-Modern-Interactive-Window-Plan.md | 5 +- .../FSharp.Interactive.Window.fsproj | 1 + .../FSharpInteractiveClassifier.fs | 102 ++++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveClassifier.fs diff --git a/docs/ide/FSI-Modern-Interactive-Window-Plan.md b/docs/ide/FSI-Modern-Interactive-Window-Plan.md index 2a819a4a75c..f2b5fae1e98 100644 --- a/docs/ide/FSI-Modern-Interactive-Window-Plan.md +++ b/docs/ide/FSI-Modern-Interactive-Window-Plan.md @@ -44,7 +44,10 @@ Still to do before the window can be opened in Visual Studio: - the debugger attach/detach commands ported from the existing window. The session reports its own process id in the handshake, so the attach no longer has to guess which process to target. -Everything from Phase 3 onwards (IntelliSense in the input buffer) is untouched. One protocol gap +Everything from Phase 3 onwards (IntelliSense in the input buffer) is untouched, with one +exception: the input buffer has lexical colour from a tokenizer-based classifier scoped to the +window's own buffers. Phase 3 replaces it with the editor's semantic classification when submissions +become workspace documents. One protocol gap belongs to that phase: an execution result reports the working directory but not the references and opens the session has accumulated. F# can get further than C# without them, because the IDE resolves script references itself through `GetProjectOptionsFromScript` rather than from a response file, but diff --git a/vsintegration/src/FSharp.Interactive.Window/FSharp.Interactive.Window.fsproj b/vsintegration/src/FSharp.Interactive.Window/FSharp.Interactive.Window.fsproj index 52ecbf6ed9e..f419eca163f 100644 --- a/vsintegration/src/FSharp.Interactive.Window/FSharp.Interactive.Window.fsproj +++ b/vsintegration/src/FSharp.Interactive.Window/FSharp.Interactive.Window.fsproj @@ -25,6 +25,7 @@ + diff --git a/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveClassifier.fs b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveClassifier.fs new file mode 100644 index 00000000000..f45fb61d5da --- /dev/null +++ b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveClassifier.fs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Interactive + +open System +open System.Collections.Generic +open System.ComponentModel.Composition + +open Microsoft.VisualStudio.InteractiveWindow +open Microsoft.VisualStudio.Text +open Microsoft.VisualStudio.Text.Classification +open Microsoft.VisualStudio.Utilities + +open FSharp.Compiler.Tokenization + +/// Lexical colour for the window's input, which is not part of any project the editor's semantic +/// classification could see. A submission is at most a screenful, so each request tokenizes the +/// buffer from the top rather than keeping token state. +type internal FSharpInteractiveClassifier(buffer: ITextBuffer, registry: IClassificationTypeRegistryService) = + + let tokenizer = FSharpSourceTokenizer([], Some "stdin.fsx", None) + + let keyword = registry.GetClassificationType "keyword" + let comment = registry.GetClassificationType "comment" + let string' = registry.GetClassificationType "string" + let number = registry.GetClassificationType "number" + let operator = registry.GetClassificationType "operator" + let identifier = registry.GetClassificationType "identifier" + let preprocessor = registry.GetClassificationType "preprocessor keyword" + let excluded = registry.GetClassificationType "excluded code" + + let classificationFor kind = + match kind with + | FSharpTokenColorKind.Keyword -> ValueSome keyword + | FSharpTokenColorKind.Comment -> ValueSome comment + | FSharpTokenColorKind.String -> ValueSome string' + | FSharpTokenColorKind.Number -> ValueSome number + | FSharpTokenColorKind.Operator -> ValueSome operator + | FSharpTokenColorKind.Identifier + | FSharpTokenColorKind.UpperIdentifier -> ValueSome identifier + | FSharpTokenColorKind.PreprocessorKeyword -> ValueSome preprocessor + | FSharpTokenColorKind.InactiveCode -> ValueSome excluded + | _ -> ValueNone + + let changed = Event, ClassificationChangedEventArgs>() + + // An edit can open or close a string or comment, changing the colour of everything after it. + do + buffer.Changed.Add(fun args -> + if args.Changes.Count > 0 then + let snapshot = args.After + let start = snapshot.GetLineFromPosition(args.Changes[0].NewPosition).Start + let invalidated = SnapshotSpan(start, SnapshotPoint(snapshot, snapshot.Length)) + changed.Trigger(null, ClassificationChangedEventArgs invalidated)) + + interface IClassifier with + + [] + member _.ClassificationChanged = changed.Publish + + member _.GetClassificationSpans(span: SnapshotSpan) = + let snapshot = span.Snapshot + let result = List() + let mutable state = FSharpTokenizerLexState.Initial + + for lineNumber in 0 .. snapshot.LineCount - 1 do + let line = snapshot.GetLineFromLineNumber lineNumber + let text = line.GetText() + let lineTokenizer = tokenizer.CreateLineTokenizer text + let mutable scanning = true + + while scanning do + match lineTokenizer.ScanToken state with + | Some token, nextState -> + state <- nextState + + if token.LeftColumn >= 0 && token.LeftColumn + token.FullMatchedLength <= text.Length then + let tokenSpan = SnapshotSpan(snapshot, line.Start.Position + token.LeftColumn, token.FullMatchedLength) + + if tokenSpan.IntersectsWith span then + match classificationFor token.ColorClass with + | ValueSome classification -> result.Add(ClassificationSpan(tokenSpan, classification)) + | ValueNone -> () + | None, nextState -> + state <- nextState + scanning <- false + + result :> IList<_> + +/// Serves the classifier for the interactive window's own buffers and no others: a buffer in a +/// document gets its colour from the project the document belongs to. +[)>] +[] +type internal FSharpInteractiveClassifierProvider [] (registry: IClassificationTypeRegistryService) = + + interface IClassifierProvider with + member _.GetClassifier(buffer: ITextBuffer) = + match InteractiveWindowExtensions.GetInteractiveWindow buffer with + | null -> null + | _ -> + buffer.Properties.GetOrCreateSingletonProperty(fun () -> FSharpInteractiveClassifier(buffer, registry)) + :> IClassifier | null From 9964204633b5fc2a1f1bdac4f228b2d2f9e8a275 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 30 Aug 2026 20:53:39 +0200 Subject: [PATCH 20/27] Colour the session's output too The value printer answers in F# signature syntax, so the same tokenizer serves. Output differs from input in shape rather than language: it grows for the life of the session and interleaves printed values with console writes, so each line is coloured on its own and an edit invalidates only the lines it touched. Both interactive content types are shared with every language the window package hosts, so the classifier now claims a buffer only when the window it belongs to evaluates F#. Co-Authored-By: Claude Fable 5 --- .../ide/FSI-Modern-Interactive-Window-Plan.md | 7 +- .../FSharpInteractiveClassifier.fs | 73 ++++++++++++++----- 2 files changed, 60 insertions(+), 20 deletions(-) diff --git a/docs/ide/FSI-Modern-Interactive-Window-Plan.md b/docs/ide/FSI-Modern-Interactive-Window-Plan.md index f2b5fae1e98..fd0326984eb 100644 --- a/docs/ide/FSI-Modern-Interactive-Window-Plan.md +++ b/docs/ide/FSI-Modern-Interactive-Window-Plan.md @@ -45,9 +45,10 @@ Still to do before the window can be opened in Visual Studio: process id in the handshake, so the attach no longer has to guess which process to target. Everything from Phase 3 onwards (IntelliSense in the input buffer) is untouched, with one -exception: the input buffer has lexical colour from a tokenizer-based classifier scoped to the -window's own buffers. Phase 3 replaces it with the editor's semantic classification when submissions -become workspace documents. One protocol gap +exception: the input and output buffers have lexical colour from a tokenizer-based classifier +scoped to the window's own buffers. Phase 3 replaces the input half with the editor's semantic +classification when submissions become workspace documents; the output half stays lexical, since +output is not a program. One protocol gap belongs to that phase: an execution result reports the working directory but not the references and opens the session has accumulated. F# can get further than C# without them, because the IDE resolves script references itself through `GetProjectOptionsFromScript` rather than from a response file, but diff --git a/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveClassifier.fs b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveClassifier.fs index f45fb61d5da..83f2d3fe50a 100644 --- a/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveClassifier.fs +++ b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveClassifier.fs @@ -13,10 +13,13 @@ open Microsoft.VisualStudio.Utilities open FSharp.Compiler.Tokenization -/// Lexical colour for the window's input, which is not part of any project the editor's semantic -/// classification could see. A submission is at most a screenful, so each request tokenizes the -/// buffer from the top rather than keeping token state. -type internal FSharpInteractiveClassifier(buffer: ITextBuffer, registry: IClassificationTypeRegistryService) = +/// Lexical colour for text the editor's semantic classification never sees: the window's input, +/// which belongs to no project, and its output, where the value printer speaks F# signature syntax. +/// +/// Input carries lexer state across lines, because a submission is one fragment of code and small. +/// Output is neither: it grows for the life of the session and interleaves printed values with +/// whatever the code wrote to the console, so each line is coloured on its own. +type internal FSharpInteractiveClassifier(buffer: ITextBuffer, registry: IClassificationTypeRegistryService, carriesStateAcrossLines: bool) = let tokenizer = FSharpSourceTokenizer([], Some "stdin.fsx", None) @@ -44,14 +47,21 @@ type internal FSharpInteractiveClassifier(buffer: ITextBuffer, registry: IClassi let changed = Event, ClassificationChangedEventArgs>() - // An edit can open or close a string or comment, changing the colour of everything after it. + // When lines are coloured independently an edit invalidates only the lines it touched; when + // state is carried, an edit can open or close a string or comment and recolour everything after. do buffer.Changed.Add(fun args -> if args.Changes.Count > 0 then let snapshot = args.After let start = snapshot.GetLineFromPosition(args.Changes[0].NewPosition).Start - let invalidated = SnapshotSpan(start, SnapshotPoint(snapshot, snapshot.Length)) - changed.Trigger(null, ClassificationChangedEventArgs invalidated)) + + let last = + if carriesStateAcrossLines then + SnapshotPoint(snapshot, snapshot.Length) + else + snapshot.GetLineFromPosition(min args.Changes[args.Changes.Count - 1].NewEnd snapshot.Length).End + + changed.Trigger(null, ClassificationChangedEventArgs(SnapshotSpan(start, last)))) interface IClassifier with @@ -61,9 +71,20 @@ type internal FSharpInteractiveClassifier(buffer: ITextBuffer, registry: IClassi member _.GetClassificationSpans(span: SnapshotSpan) = let snapshot = span.Snapshot let result = List() + + let firstLine = + if carriesStateAcrossLines then + 0 + else + snapshot.GetLineNumberFromPosition span.Start.Position + + let lastLine = snapshot.GetLineNumberFromPosition span.End.Position let mutable state = FSharpTokenizerLexState.Initial - for lineNumber in 0 .. snapshot.LineCount - 1 do + for lineNumber in firstLine..lastLine do + if not carriesStateAcrossLines then + state <- FSharpTokenizerLexState.Initial + let line = snapshot.GetLineFromLineNumber lineNumber let text = line.GetText() let lineTokenizer = tokenizer.CreateLineTokenizer text @@ -87,16 +108,34 @@ type internal FSharpInteractiveClassifier(buffer: ITextBuffer, registry: IClassi result :> IList<_> -/// Serves the classifier for the interactive window's own buffers and no others: a buffer in a -/// document gets its colour from the project the document belongs to. +module private OwnBuffer = + + /// Both content types are shared with every language hosted in an interactive window, so a + /// buffer counts as ours only when the window it belongs to evaluates F#. + let isOurs (buffer: ITextBuffer) = + match InteractiveWindowExtensions.GetInteractiveWindow buffer with + | null -> false + | window -> window.Evaluator :? FSharpInteractiveEvaluator + + let classifierFor buffer registry carriesStateAcrossLines : IClassifier | null = + if isOurs buffer then + buffer.Properties.GetOrCreateSingletonProperty(fun () -> + FSharpInteractiveClassifier(buffer, registry, carriesStateAcrossLines)) + else + null + [)>] [] -type internal FSharpInteractiveClassifierProvider [] (registry: IClassificationTypeRegistryService) = +type internal FSharpInteractiveInputClassifierProvider [] (registry: IClassificationTypeRegistryService) = + + interface IClassifierProvider with + member _.GetClassifier buffer = + OwnBuffer.classifierFor buffer registry true + +[)>] +[] +type internal FSharpInteractiveOutputClassifierProvider [] (registry: IClassificationTypeRegistryService) = interface IClassifierProvider with - member _.GetClassifier(buffer: ITextBuffer) = - match InteractiveWindowExtensions.GetInteractiveWindow buffer with - | null -> null - | _ -> - buffer.Properties.GetOrCreateSingletonProperty(fun () -> FSharpInteractiveClassifier(buffer, registry)) - :> IClassifier | null + member _.GetClassifier buffer = + OwnBuffer.classifierFor buffer registry false From 369cd05226f050e5b6b24f04efe955148683eb6e Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 30 Aug 2026 21:59:23 +0200 Subject: [PATCH 21/27] Recognise the output buffer as ours The window package records itself in the properties of the buffers it creates for input but not for output, so asking a buffer which window it belongs to could never answer for output. It also replaces the output buffer on reset, which rules out stamping one. The windows evaluating F# now keep a register, and an output buffer is ours when one of them currently owns it. Ownership is also settled later than before. The buffer reaches the provider before the window has finished claiming it, so deciding at that moment decided once and for all against us; it is now asked on each request until known. Co-Authored-By: Claude Fable 5 --- .../FSharpInteractiveClassifier.fs | 52 ++++++++++++------- .../FSharpInteractiveEvaluator.fs | 30 ++++++++++- 2 files changed, 61 insertions(+), 21 deletions(-) diff --git a/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveClassifier.fs b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveClassifier.fs index 83f2d3fe50a..442670e0bd4 100644 --- a/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveClassifier.fs +++ b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveClassifier.fs @@ -13,6 +13,13 @@ open Microsoft.VisualStudio.Utilities open FSharp.Compiler.Tokenization +[] +module private InteractiveContentTypes = + + /// The window package names its output buffers this, whatever language the session speaks. + [] + let OutputContentTypeName = "Interactive Output" + /// Lexical colour for text the editor's semantic classification never sees: the window's input, /// which belongs to no project, and its output, where the value printer speaks F# signature syntax. /// @@ -45,13 +52,30 @@ type internal FSharpInteractiveClassifier(buffer: ITextBuffer, registry: IClassi | FSharpTokenColorKind.InactiveCode -> ValueSome excluded | _ -> ValueNone + // Both interactive content types are shared with every language the window package hosts, and + // ownership cannot be settled when the classifier is built: the buffer is handed to us before + // the window has finished claiming it. So it is asked again on each request until it is known. + let mutable ours = ValueNone + + let isOurs () = + match ours with + | ValueSome known -> known + | ValueNone -> + let known = + match InteractiveWindowExtensions.GetInteractiveWindow buffer with + | null -> FSharpInteractiveWindows.ownsOutputBuffer buffer + | window -> window.Evaluator :? FSharpInteractiveEvaluator + + if known then ours <- ValueSome true + known + let changed = Event, ClassificationChangedEventArgs>() // When lines are coloured independently an edit invalidates only the lines it touched; when // state is carried, an edit can open or close a string or comment and recolour everything after. do buffer.Changed.Add(fun args -> - if args.Changes.Count > 0 then + if args.Changes.Count > 0 && isOurs () then let snapshot = args.After let start = snapshot.GetLineFromPosition(args.Changes[0].NewPosition).Start @@ -72,6 +96,10 @@ type internal FSharpInteractiveClassifier(buffer: ITextBuffer, registry: IClassi let snapshot = span.Snapshot let result = List() + if not (isOurs ()) then + result :> IList<_> + else + let firstLine = if carriesStateAcrossLines then 0 @@ -108,34 +136,18 @@ type internal FSharpInteractiveClassifier(buffer: ITextBuffer, registry: IClassi result :> IList<_> -module private OwnBuffer = - - /// Both content types are shared with every language hosted in an interactive window, so a - /// buffer counts as ours only when the window it belongs to evaluates F#. - let isOurs (buffer: ITextBuffer) = - match InteractiveWindowExtensions.GetInteractiveWindow buffer with - | null -> false - | window -> window.Evaluator :? FSharpInteractiveEvaluator - - let classifierFor buffer registry carriesStateAcrossLines : IClassifier | null = - if isOurs buffer then - buffer.Properties.GetOrCreateSingletonProperty(fun () -> - FSharpInteractiveClassifier(buffer, registry, carriesStateAcrossLines)) - else - null - [)>] [] type internal FSharpInteractiveInputClassifierProvider [] (registry: IClassificationTypeRegistryService) = interface IClassifierProvider with member _.GetClassifier buffer = - OwnBuffer.classifierFor buffer registry true + buffer.Properties.GetOrCreateSingletonProperty(fun () -> FSharpInteractiveClassifier(buffer, registry, true)) [)>] -[] +[] type internal FSharpInteractiveOutputClassifierProvider [] (registry: IClassificationTypeRegistryService) = interface IClassifierProvider with member _.GetClassifier buffer = - OwnBuffer.classifierFor buffer registry false + buffer.Properties.GetOrCreateSingletonProperty(fun () -> FSharpInteractiveClassifier(buffer, registry, false)) diff --git a/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveEvaluator.fs b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveEvaluator.fs index ff8b4e224ac..3dea27a6ff9 100644 --- a/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveEvaluator.fs +++ b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveEvaluator.fs @@ -6,6 +6,28 @@ open System open System.Threading.Tasks open Microsoft.VisualStudio.InteractiveWindow +open Microsoft.VisualStudio.Text + +/// The windows currently evaluating F#. +/// +/// The interactive window package records itself in the properties of the buffers it creates for +/// input, but not in those it creates for output, and it replaces the output buffer on every reset. +/// So an output buffer can only be recognised by asking the windows we know about which buffer is +/// theirs right now. +module internal FSharpInteractiveWindows = + + let private windows = ResizeArray() + + let add (window: IInteractiveWindow) = + lock windows (fun () -> + if not (windows.Contains window) then + windows.Add window) + + let remove (window: IInteractiveWindow) = + lock windows (fun () -> windows.Remove window |> ignore) + + let ownsOutputBuffer (buffer: ITextBuffer) = + lock windows (fun () -> windows |> Seq.exists (fun window -> obj.ReferenceEquals(window.OutputBuffer, buffer))) module internal ResultRendering = @@ -121,7 +143,8 @@ type internal FSharpInteractiveEvaluator match window with | null -> () - | _ -> + | window -> + FSharpInteractiveWindows.add window outputSubscription <- host.OutputReceived.Subscribe write errorSubscription <- host.ErrorOutputReceived.Subscribe writeError exitedSubscription <- host.ProcessExited.Subscribe reportSessionExit @@ -190,6 +213,11 @@ type internal FSharpInteractiveEvaluator member _.Dispose() = if not disposed then disposed <- true + + match currentWindow with + | null -> () + | window -> FSharpInteractiveWindows.remove window + unsubscribe outputSubscription unsubscribe errorSubscription unsubscribe exitedSubscription From 9f53afd5f15f439e27086ca34fc675cef8ce568a Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 20 Sep 2026 22:17:57 +0200 Subject: [PATCH 22/27] Name the host process on the session's command line The session used to learn its host from the handshake alone, so a Visual Studio that died between launching fsi and connecting left it waiting on the pipe for good. `--fsi-server-client-pid` names the process before the pipe opens; the handshake still carries it for a session started some other way. Co-Authored-By: Claude Fable 5.1 --- docs/ide/FSI-Modern-Interactive-Window-Plan.md | 2 +- .../src/FSharp.Interactive.Window/InteractiveHost.fs | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/ide/FSI-Modern-Interactive-Window-Plan.md b/docs/ide/FSI-Modern-Interactive-Window-Plan.md index fd0326984eb..810ca5d4aa7 100644 --- a/docs/ide/FSI-Modern-Interactive-Window-Plan.md +++ b/docs/ide/FSI-Modern-Interactive-Window-Plan.md @@ -135,7 +135,7 @@ are client→server only; the server registers no callbacks, as Roslyn's does no | Method | Parameters | Result | |---|---|---| -| `fsi/initialize` | `clientProcessId` | `processId`, `frameworkDescription`, `processArchitecture`, `fsiVersion`, `workingDirectory`, `supportsInterrupt` | +| `fsi/initialize` | — (the host names itself with `--fsi-server-client-pid`) | `processId`, `frameworkDescription`, `processArchitecture`, `fsiVersion`, `workingDirectory`, `supportsInterrupt` | | `fsi/execute` | `code`, optional `sourcePath` and `startLine` | execution result | | `fsi/executeFile` | `path` | execution result | | `fsi/setPaths` | `includePaths`, `workingDirectory` | execution result | diff --git a/vsintegration/src/FSharp.Interactive.Window/InteractiveHost.fs b/vsintegration/src/FSharp.Interactive.Window/InteractiveHost.fs index 8201af110a5..f73a046dd29 100644 --- a/vsintegration/src/FSharp.Interactive.Window/InteractiveHost.fs +++ b/vsintegration/src/FSharp.Interactive.Window/InteractiveHost.fs @@ -249,6 +249,9 @@ type internal InteractiveHostClient(clientProcessId: int) = addSwitch "--nologo" addSwitch $"{CommandLine.ServerOption}{pipeName}" + // How the host names itself: a session whose host dies, even before the handshake, exits instead of + // waiting on the pipe forever. + addSwitch $"--fsi-server-client-pid:{clientProcessId}" addSwitch $"--fsi-server-output-codepage:{Encoding.UTF8.CodePage}" addSwitch $"--fsi-server-input-codepage:{Encoding.UTF8.CodePage}" addSwitch $"--fsi-server-lcid:{options.UICultureLcid}" @@ -322,6 +325,9 @@ type internal InteractiveHostClient(clientProcessId: int) = use connectCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, exitedDuringConnect.Token) + // The session admits only its own user to the pipe. The .NET Framework client cannot ask + // for the same check of the server's identity (PipeOptions.CurrentUserOnly is .NET Core + // 2.1+), so the unguessable pipe name is what stands between the window and a squatter. let pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous) @@ -332,11 +338,7 @@ type internal InteractiveHostClient(clientProcessId: int) = rpc.StartListening() let! handshake = - rpc.InvokeWithParameterObjectAsync( - Methods.Initialize, - { clientProcessId = clientProcessId }, - cancellationToken - ) + rpc.InvokeWithCancellationAsync(Methods.Initialize, cancellationToken = cancellationToken) let remote = RemoteSession(session, pipe, rpc, handshake) From c60bd0687d731f34de8bbb44a4b259455d711d8a Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 20 Sep 2026 22:44:26 +0200 Subject: [PATCH 23/27] Start the window's session where `dotnet fsi` would, and only on .NET MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window picked its own fsi: `dotnet.exe` from Program Files, or one of the desktop executables the extension ships, chosen through the options page the old window reads. The desktop ones cannot serve the protocol at all — the server exists in the .NET fsi alone — and the machine-wide `dotnet` ignored the SDK a solution pins. The session now starts in the open solution's folder with the `dotnet` a shell there would run, so the host resolves the SDK from that folder's `global.json` exactly as `dotnet fsi` typed at a prompt does, and the window runs the same compiler bits as `dotnet build`. The platform choice, the desktop executables and the shadow-copy switch stay with the old window. When a session comes up the window prints which fsi answered, on what runtime and where, so a pinned SDK is seen rather than guessed at. Co-Authored-By: Claude Fable 5.1 --- .../ide/FSI-Modern-Interactive-Window-Plan.md | 37 +++--- .../FSharpInteractiveEvaluator.fs | 43 +++--- .../FSharpVsInteractiveWindowProvider.fs | 61 +++++---- .../InteractiveHost.fs | 125 +++++++----------- 4 files changed, 112 insertions(+), 154 deletions(-) diff --git a/docs/ide/FSI-Modern-Interactive-Window-Plan.md b/docs/ide/FSI-Modern-Interactive-Window-Plan.md index 810ca5d4aa7..f34006a5f4d 100644 --- a/docs/ide/FSI-Modern-Interactive-Window-Plan.md +++ b/docs/ide/FSI-Modern-Interactive-Window-Plan.md @@ -28,9 +28,10 @@ and contains: - `SubmissionAnalysis.fs` — the rule deciding when Enter submits, tested; - `FSharpVsInteractiveWindowProvider.fs` — the MEF component that creates the tool window through `IVsInteractiveWindowFactory.Create`, calls `SetLanguage` with the F# content type and language - service so the input buffer is an F# editor buffer, sets the caption from the platform, and reads - its options from the `SessionsProperties` the existing Tools, Options page already writes. No new - options page is needed. + service so the input buffer is an F# editor buffer, and reads its options from the + `SessionsProperties` the existing Tools, Options page already writes. No new options page is + needed. The session starts in the open solution's folder, so `dotnet fsi` runs the SDK that + folder's `global.json` resolves to. Still to do before the window can be opened in Visual Studio: @@ -40,7 +41,7 @@ Still to do before the window can be opened in Visual Studio: calls the provider, rather than a new package with its own GUID and pkgdef; - the `Microsoft.VisualStudio.InteractiveWindow` prerequisite entry in the VSIX manifest, and the project's place in the VSIX itself. It is already in `VisualFSharp.slnx`, so it builds; -- commands: open the window, `#reset `, and retargeting Alt+Enter at the new window; +- commands: open the window, and retargeting Alt+Enter at the new window; - the debugger attach/detach commands ported from the existing window. The session reports its own process id in the handshake, so the attach no longer has to guess which process to target. @@ -76,7 +77,7 @@ What the old window does have that C# Interactive does **not** (must be preserve - **Debugging**: attach/detach the VS debugger to the FSI process, "Debug in Interactive" (`#dbgbreak`), debuggability check (`--debug+ --optimize-`) with a suppressible warning dialog. Roslyn's window has no debugging at all — this is an F# advantage to keep. - Real script semantics: FSI executes actual `.fsx` interactions with `#load`/`#r`/`#i`, and `fsi` object, not a C#-script dialect. -- Platform choice already includes Arm64 (`fsiArm64.exe`). +- The .NET Framework hosts (`fsiAnyCpu.exe`, `fsi.exe`, `fsiArm64.exe`) stay with the old window; the new one is .NET-only, because the server mode exists only in the .NET fsi. --- @@ -113,8 +114,7 @@ Five layers, copied from Roslyn's proven separation. Execution state lives **onl │ + redirected stdout/stderr (user output) ┌──────────────────────────────▼────────────────────────────────────────┐ │ EXECUTION HOST = fsi itself, in a new server mode │ -│ dotnet fsi --fsi-server-jsonrpc: (.NET / SDK) │ -│ fsiAnyCpu.exe / fsiArm64.exe --fsi-server-jsonrpc:… (.NET Framework) │ +│ dotnet fsi --fsi-server-jsonrpc: (the SDK global.json resolves) │ │ FsiEvaluationSession driven by RPC instead of the stdin ReadLine loop │ └───────────────────────────────────────────────────────────────────────┘ ``` @@ -125,7 +125,7 @@ Roslyn ships dedicated `InteractiveHost64/32.exe` binaries because C# scripting - kills the `SERVER-PROMPT>` scraping, the PID-file hack, and the `# 1 "stdin"` directive juggling in one move; - benefits every other fsi client (Ionide, VS Code, custom tooling) — the server mode is a compiler feature, not a VS-only one; -- keeps `dotnet fsi` as the .NET Core host (nothing new to deploy; the VSIX only carries the desktop `fsiAnyCpu`/`fsiArm64` it already ships). +- keeps `dotnet fsi` as the host, resolved from the solution folder's `global.json`, so the window runs the same compiler bits as `dotnet build` there and Visual Studio and SDK versions are decoupled (nothing new to deploy; the desktop `fsiAnyCpu`/`fsiArm64` the VSIX ships stay with the old window). ### 2.2 The RPC protocol (as implemented) @@ -195,18 +195,11 @@ The single most valuable user-facing change. Mechanism (Roslyn's, adapted): - Otherwise → submit iff the text is a syntactically complete interaction (FCS parse with `ScriptParseInfo`; incomplete constructs — open `let`, unclosed paren/string — return false). This matches `dotnet fsi`'s modern multiline behavior and C#'s `SyntaxFactory.IsCompleteSubmission`. - The evaluator appends `;;` before sending to the host if absent; prompts: `> ` primary, `. ` (or `- `) continuation via `GetPrompt()`. -### 2.5 Platform selection +### 2.5 Host selection -Keep today's matrix, expressed the Roslyn way (`#reset` arguments + caption suffix + options page default): +The window is .NET-only. The server mode exists in the .NET fsi alone, and the desktop matrix (`fsiAnyCpu.exe`, `fsi.exe`, `fsiArm64.exe`, `#reset` platform arguments, the shadow-copy switch) stays with the old window until that is retired. -| Platform | Host | Notes | -|---|---|---| -| .NET (default) | `dotnet fsi` | SDK-resolved; caption "F# Interactive (.NET)" | -| .NET Framework x64 | `fsiAnyCpu.exe --fsi-server-jsonrpc:…` | shipped in VSIX | -| .NET Framework x86 | `fsi.exe` | shipped in VSIX | -| Arm64 | `fsiArm64.exe` | shipped in VSIX | - -`#reset core` / `#reset net472` / etc. exported as a specialized-content-type command that displaces the package's generic `#reset` (Roslyn's `GetApplicableCommands` name-replacement mechanism). +The session is started in the open solution's folder, with the `dotnet` a shell there would run (`DOTNET_HOST_PATH`, then `PATH`, then the machine-wide install). The host resolves the SDK from that folder's `global.json` exactly as `dotnet fsi` typed in a shell would, so the window runs the same compiler bits as `dotnet build`, and Visual Studio and SDK versions are decoupled. Nothing in the window re-implements SDK resolution. `FSHARP_INTERACTIVE_PATH` names another fsi for development, until an SDK ships the protocol; the window prints which fsi answered, on what runtime and in which directory, when a session comes up. ### 2.6 Debugging (parity + improvement over C#) @@ -243,7 +236,7 @@ Goal: de-risk the InteractiveWindow dependency before touching the compiler. ### Phase 2 — Evaluator + session on the new protocol — partly done - `FSharpInteractiveSession`: single `AsyncBatchingWorkQueue`-style queue serializing init/execute/set-paths (reset preempts); `LazyRemoteService` lifecycle port; auto-restart; pending-buffer queue. -- Full `IInteractiveEvaluator`: `CanExecuteCode` (§2.4), `GetPrompt`, `ResetAsync` with platform args, `InitializeAsync`, `AbortExecution` → RPC `Interrupt` (note: this makes F# *better* than C# Interactive, whose `AbortExecution` is an unimplemented TODO). +- Full `IInteractiveEvaluator`: `CanExecuteCode` (§2.4), `GetPrompt`, `ResetAsync`, `InitializeAsync`, `AbortExecution` → RPC `Interrupt` (note: this makes F# *better* than C# Interactive, whose `AbortExecution` is an unimplemented TODO). - Structured diagnostics from `ExecutionResult` rendered as error-classified output. - Delete the stdin/stdout path from the new window (old window untouched). @@ -260,9 +253,9 @@ Goal: de-risk the InteractiveWindow dependency before touching the compiler. - Rewire `MenusAndCommands.vsct` targets: Alt+Enter Send Selection/Line, "Execute in Interactive", "Debug in Interactive" → new window (`window.SubmitAsync`, preserving the no-selection→current-line + caret-advance behavior; consider Roslyn's syntax-aware selection expansion from `SendToInteractiveSubmissionProvider`). - `AddReferences` (Solution Explorer "Send project references to F# Interactive") → `#r` submissions. -- Optional: "Initialize Interactive with Project" parity — build project, reset with platform inferred from TFM, `SetPaths`, `#r` output assembly + references, `open` default namespaces (Roslyn's `ResetInteractive` flow). -- Port `FsiPropertyPage` (Tools → Options → F# Tools → F# Interactive): args, platform default, shadow copy (`--shadowcopyreferences` still honored via args), langversion preview, debug mode. -- Window caption platform suffix; `#reset` platform args; F1 help keyword. +- Optional: "Initialize Interactive with Project" parity — build project, reset, `SetPaths`, `#r` output assembly + references, `open` default namespaces (Roslyn's `ResetInteractive` flow). +- Port `FsiPropertyPage` (Tools → Options → F# Tools → F# Interactive): args, langversion preview, debug mode. The platform default and shadow copy belong to the desktop fsi and stay with the old window. +- F1 help keyword. ### Phase 5 — Debugging parity (1–2 weeks) diff --git a/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveEvaluator.fs b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveEvaluator.fs index 3dea27a6ff9..df83c0ac69e 100644 --- a/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveEvaluator.fs +++ b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveEvaluator.fs @@ -34,21 +34,20 @@ module internal ResultRendering = let formatDiagnostic (diagnostic: FSharp.Compiler.Interactive.Protocol.DiagnosticInfo) = $"{diagnostic.fileName}({diagnostic.startLine},{diagnostic.startColumn + 1}): {diagnostic.severity} FS%04d{diagnostic.errorNumber}: {diagnostic.message}" + /// The line the window shows when a session comes up: which fsi answered, on what, and where. + let formatSessionStart (session: FSharp.Compiler.Interactive.Protocol.InitializeResult) = + $"F# Interactive {session.fsiVersion} on {session.frameworkDescription}, in {session.workingDirectory}" + /// Connects the interactive window to an F# Interactive session. [] -type internal FSharpInteractiveEvaluator - ( - host: InteractiveHostClient, - getOptions: unit -> InteractiveHostOptions, - onPlatformChanged: InteractiveHostPlatform -> unit - ) = +type internal FSharpInteractiveEvaluator(host: InteractiveHostClient, getOptions: unit -> InteractiveHostOptions) = let mutable currentWindow: IInteractiveWindow | null = null let mutable outputSubscription: IDisposable | null = null let mutable errorSubscription: IDisposable | null = null let mutable exitedSubscription: IDisposable | null = null + let mutable startedSubscription: IDisposable | null = null let mutable disposed = false - let mutable requestedPlatform: InteractiveHostPlatform voption = ValueNone // The window submits text without saying where it came from, so an editor command records the // origin here for the submission it is about to make. Both run on the UI thread. @@ -61,6 +60,11 @@ type internal FSharpInteractiveEvaluator | null -> () | window -> window.OutputWriter.Write text + let writeLine (text: string) = + match currentWindow with + | null -> () + | window -> window.OutputWriter.WriteLine text + let writeError (text: string) = match currentWindow with | null -> () @@ -71,13 +75,6 @@ type internal FSharpInteractiveEvaluator | null -> () | window -> window.ErrorOutputWriter.WriteLine text - let optionsForNextSession () = - let options = getOptions () - - match requestedPlatform with - | ValueSome platform -> { options with Platform = platform } - | ValueNone -> options - let reportDiagnostics (result: FSharp.Compiler.Interactive.Protocol.ExecutionResult) = match result.diagnostics with | null -> () @@ -95,7 +92,7 @@ type internal FSharpInteractiveEvaluator let ensureSessionAsync () = task { - match! host.EnsureStartedAsync(optionsForNextSession ()) with + match! host.EnsureStartedAsync(getOptions ()) with | Result.Ok _ -> return true | Result.Error message -> writeErrorLine message @@ -110,12 +107,8 @@ type internal FSharpInteractiveEvaluator let reportSessionExit exitCode = writeErrorLine $"{VFSIstrings.SR.sessionTerminationDetected()} (exit code {exitCode})" - member _.CurrentPlatform = - match requestedPlatform with - | ValueSome platform -> platform - | ValueNone -> (getOptions ()).Platform - - member _.RequestPlatform platform = requestedPlatform <- ValueSome platform + let reportSessionStart session = + writeLine (ResultRendering.formatSessionStart session) /// Attribute the next submission to a file and line, so that its diagnostics land on the user's /// own source rather than on the submission. @@ -140,6 +133,7 @@ type internal FSharpInteractiveEvaluator unsubscribe outputSubscription unsubscribe errorSubscription unsubscribe exitedSubscription + unsubscribe startedSubscription match window with | null -> () @@ -148,6 +142,7 @@ type internal FSharpInteractiveEvaluator outputSubscription <- host.OutputReceived.Subscribe write errorSubscription <- host.ErrorOutputReceived.Subscribe writeError exitedSubscription <- host.ProcessExited.Subscribe reportSessionExit + startedSubscription <- host.SessionStarted.Subscribe reportSessionStart member _.InitializeAsync() = task { @@ -159,10 +154,7 @@ type internal FSharpInteractiveEvaluator // session has none to vary, and the flag never means "do not start a replacement". member _.ResetAsync(_initialize) = task { - let options = optionsForNextSession () - onPlatformChanged options.Platform - - match! host.ResetAsync options with + match! host.ResetAsync(getOptions ()) with | Result.Ok _ -> return ExecutionResult true | Result.Error message -> writeErrorLine message @@ -221,4 +213,5 @@ type internal FSharpInteractiveEvaluator unsubscribe outputSubscription unsubscribe errorSubscription unsubscribe exitedSubscription + unsubscribe startedSubscription (host :> IDisposable).Dispose() diff --git a/vsintegration/src/FSharp.Interactive.Window/FSharpVsInteractiveWindowProvider.fs b/vsintegration/src/FSharp.Interactive.Window/FSharpVsInteractiveWindowProvider.fs index d186c2ffaad..f3e04b8fa6d 100644 --- a/vsintegration/src/FSharp.Interactive.Window/FSharpVsInteractiveWindowProvider.fs +++ b/vsintegration/src/FSharp.Interactive.Window/FSharpVsInteractiveWindowProvider.fs @@ -6,7 +6,6 @@ open System open System.ComponentModel.Composition open System.Diagnostics open System.IO -open System.Runtime.InteropServices open System.Threading open Microsoft.VisualStudio @@ -38,25 +37,36 @@ module InteractiveWindowGuids = /// Reads the session settings the existing Tools, Options page writes. module internal InteractiveHostOptionsFactory = - let private hostDirectory () = - match Path.GetDirectoryName(typeof.Assembly.Location) with - | null -> Environment.CurrentDirectory - | directory -> directory - - let currentPlatform () = - match SessionsProperties.fsiUseNetCore, RuntimeInformation.ProcessArchitecture with - | true, _ -> NetCore - | _, Architecture.Arm64 -> NetFrameworkArm64 - | _ when SessionsProperties.useAnyCpuVersion -> NetFramework64 - | _ -> NetFramework32 - - let create platform = + /// The folder `dotnet fsi` is run from: the open solution's, whose `global.json` then picks the + /// SDK; the user's profile when nothing is open. Read on the UI thread, where the window's + /// evaluator calls in. + let private startDirectory () = + let solutionDirectory = + try + match ServiceProvider.GlobalProvider.GetService typeof with + | :? IVsSolution as solution -> + let hr, directory, _, _ = solution.GetSolutionInfo() + + if + ErrorHandler.Succeeded hr + && not (String.IsNullOrEmpty directory) + && Directory.Exists directory + then + ValueSome directory + else + ValueNone + | _ -> ValueNone + with _ -> + ValueNone + + match solutionDirectory with + | ValueSome directory -> directory + | ValueNone -> Environment.GetFolderPath Environment.SpecialFolder.UserProfile + + let create () = { - Platform = platform - HostDirectory = hostDirectory () - InitialWorkingDirectory = Environment.GetFolderPath Environment.SpecialFolder.UserProfile + InitialWorkingDirectory = startDirectory () UserArguments = SessionsProperties.fsiArgs - ShadowCopyReferences = SessionsProperties.fsiShadowCopy DebugMode = SessionsProperties.fsiDebugMode LanguageVersionPreview = SessionsProperties.fsiPreview UICultureLcid = Thread.CurrentThread.CurrentUICulture.LCID @@ -72,27 +82,16 @@ type internal FSharpVsInteractiveWindowProvider let mutable window: IVsInteractiveWindow | null = null let mutable evaluator: FSharpInteractiveEvaluator voption = ValueNone - let captionFor (platform: InteractiveHostPlatform) = - $"{VFSIstrings.SR.fsharpInteractive ()} ({platform.Description})" - - let setCaption platform = - match box window with - | :? ToolWindowPane as pane -> pane.Caption <- captionFor platform - | _ -> () - - let currentOptions () = - InteractiveHostOptionsFactory.create (InteractiveHostOptionsFactory.currentPlatform ()) - member this.Create(instanceId: int) = let host = new InteractiveHostClient(Process.GetCurrentProcess().Id) - let created = new FSharpInteractiveEvaluator(host, currentOptions, setCaption) + let created = new FSharpInteractiveEvaluator(host, InteractiveHostOptionsFactory.create) evaluator <- ValueSome created let toolWindow = windowFactory.Create( InteractiveWindowGuids.ToolWindowId, instanceId, - captionFor (InteractiveHostOptionsFactory.currentPlatform ()), + VFSIstrings.SR.fsharpInteractive (), created, __VSCREATETOOLWIN.CTW_fForceCreate ) diff --git a/vsintegration/src/FSharp.Interactive.Window/InteractiveHost.fs b/vsintegration/src/FSharp.Interactive.Window/InteractiveHost.fs index f73a046dd29..956611cf291 100644 --- a/vsintegration/src/FSharp.Interactive.Window/InteractiveHost.fs +++ b/vsintegration/src/FSharp.Interactive.Window/InteractiveHost.fs @@ -14,49 +14,15 @@ open StreamJsonRpc open FSharp.Compiler.Interactive.Protocol -type InteractiveHostPlatform = - | NetCore - | NetFramework64 - | NetFramework32 - | NetFrameworkArm64 - - member this.Description = - match this with - | NetCore -> ".NET" - | NetFramework64 -> ".NET Framework (64-bit)" - | NetFramework32 -> ".NET Framework (32-bit)" - | NetFrameworkArm64 -> ".NET Framework (Arm64)" - - member this.CommandLineName = - match this with - | NetCore -> "core" - | NetFramework64 -> "64" - | NetFramework32 -> "32" - | NetFrameworkArm64 -> "arm64" - - static member TryParse(name: string) = - let name = name.Trim() - let is candidate = String.Equals(name, candidate, StringComparison.OrdinalIgnoreCase) - - if is "core" || is "net" then ValueSome NetCore - elif is "64" || is "framework64" then ValueSome NetFramework64 - elif is "32" || is "framework32" then ValueSome NetFramework32 - elif is "arm64" then ValueSome NetFrameworkArm64 - else ValueNone - type InteractiveHostOptions = { - Platform: InteractiveHostPlatform - - /// Directory holding the desktop fsi executables shipped in the extension. - HostDirectory: string - + /// Where the session starts, and so which `global.json` names its SDK: the solution folder + /// while a solution is open. InitialWorkingDirectory: string /// The user's own arguments, from Tools, Options. UserArguments: string - ShadowCopyReferences: bool DebugMode: bool LanguageVersionPreview: bool UICultureLcid: int @@ -64,40 +30,55 @@ type InteractiveHostOptions = module internal FsiLocator = - let private desktopExecutableName platform = - match platform with - | NetFramework32 -> "fsi.exe" - | NetFrameworkArm64 -> "fsiArm64.exe" - | _ -> "fsiAnyCpu.exe" + let private hostExecutable = + if Environment.OSVersion.Platform = PlatformID.Win32NT then + "dotnet.exe" + else + "dotnet" + /// The `dotnet` a shell would run: the one Visual Studio was told about, else the first on + /// `PATH`, else the machine-wide install. let findDotnetHost () = match Environment.GetEnvironmentVariable "DOTNET_HOST_PATH" with | path when not (String.IsNullOrEmpty path) && File.Exists path -> path | _ -> + let onPath = + match Environment.GetEnvironmentVariable "PATH" with + | null -> None + | searchPath -> + searchPath.Split([| Path.PathSeparator |], StringSplitOptions.RemoveEmptyEntries) + |> Array.choose (fun directory -> + // An entry with characters a path cannot hold is somebody else's problem. + try + Some(Path.Combine(directory.Trim(' ', '"'), hostExecutable)) + with _ -> + None) + |> Array.tryFind File.Exists + + match onPath with + | Some host -> host + | None -> + let programFiles = match Environment.GetEnvironmentVariable "ProgramW6432" with | path when not (String.IsNullOrEmpty path) -> path | _ -> Environment.GetFolderPath Environment.SpecialFolder.ProgramFiles - Path.Combine(programFiles, "dotnet", "dotnet.exe") + Path.Combine(programFiles, "dotnet", hostExecutable) - /// Names an F# Interactive to run instead of the one the platform would resolve to. + /// Names an F# Interactive to run instead of the SDK's. /// - /// The protocol needs an fsi that understands `--fsi-server-jsonrpc`. The extension does not - /// carry one, and the fsi resolved from an installed SDK is only as new as that SDK, so a build - /// of fsi from this repository has to be named explicitly until the option ships. + /// The protocol needs an fsi that understands `--fsi-server-jsonrpc`, and the fsi an installed + /// SDK resolves to is only as new as that SDK, so a build from this repository has to be named + /// explicitly until the option ships. [] let OverrideVariable = "FSHARP_INTERACTIVE_PATH" /// A build of fsi from a repository runs on the .NET that repository provisions, which is often /// newer than any machine-wide install, so look for that host beside it before falling back. let private hostFor (fsiPath: string) = - let executable = - if Environment.OSVersion.Platform = PlatformID.Win32NT then - "dotnet.exe" - else - "dotnet" + let executable = hostExecutable let rec search (directory: DirectoryInfo | null) = match directory with @@ -126,27 +107,21 @@ module internal FsiLocator = ValueSome(Result.Ok(path, [])) | _ -> ValueNone - let locate (options: InteractiveHostOptions) = + /// What to start. Apart from the override this is `dotnet fsi`: the host resolves the SDK from + /// the `global.json` nearest the directory the session starts in, so a session started in the + /// solution folder runs the same compiler bits as `dotnet build` there. Nothing here + /// re-implements that resolution. + let locate () = match tryOverride () with | ValueSome result -> result | ValueNone -> - match options.Platform with - | NetCore -> - let host = findDotnetHost () + let host = findDotnetHost () - if File.Exists host then - Result.Ok(host, [ "fsi" ]) - else - Result.Error(VFSIstrings.SR.couldNotFindFsiExe host) - - | platform -> - let candidate = Path.Combine(options.HostDirectory, desktopExecutableName platform) - - if File.Exists candidate then - Result.Ok(candidate, []) - else - Result.Error(VFSIstrings.SR.couldNotFindFsiExe candidate) + if File.Exists host then + Result.Ok(host, [ "fsi" ]) + else + Result.Error(VFSIstrings.SR.couldNotFindFsiExe host) /// One live F# Interactive process together with the control channel to it. [] @@ -201,6 +176,7 @@ type internal InteractiveHostClient(clientProcessId: int) = let outputReceived = Event() let errorOutputReceived = Event() let processExited = Event() + let sessionStarted = Event() // Read as characters rather than lines: a script prompting with `printf "name? "` writes no // newline, and waiting for one would hide the prompt. @@ -237,7 +213,7 @@ type internal InteractiveHostClient(clientProcessId: int) = argument let createStartInfo (options: InteractiveHostOptions) (pipeName: string) = - match FsiLocator.locate options with + match FsiLocator.locate () with | Result.Error message -> Result.Error message | Result.Ok(executable, leadingArguments) -> @@ -261,14 +237,6 @@ type internal InteractiveHostClient(clientProcessId: int) = if not (String.IsNullOrWhiteSpace options.UserArguments) then arguments.Add(options.UserArguments.Trim()) - if options.Platform <> NetCore then - addSwitch ( - if options.ShadowCopyReferences then - "--shadowcopyreferences+" - else - "--shadowcopyreferences-" - ) - if options.DebugMode then addSwitch "--optimize-" addSwitch "--debug+" @@ -359,6 +327,7 @@ type internal InteractiveHostClient(clientProcessId: int) = 0 )) + sessionStarted.Trigger handshake return Result.Ok remote with e -> pipe.Dispose() @@ -387,6 +356,10 @@ type internal InteractiveHostClient(clientProcessId: int) = /// Raised when the session goes away without being asked to. member _.ProcessExited = processExited.Publish + /// Raised with the handshake of every session that comes up, so the window can say what it is + /// talking to. + member _.SessionStarted = sessionStarted.Publish + member _.IsRunning = lock stateLock (fun () -> current |> ValueOption.exists (fun session -> session.IsAlive)) From 626c697b50793421ec753a4881e4bf0ba0879803 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 21 Sep 2026 16:22:02 +0200 Subject: [PATCH 24/27] Put the window's server switches after the user's arguments For --fsi-server-jsonrpc and --fsi-server-client-pid the last occurrence wins, so the user's own arguments could name another pipe or another process as the session's owner, and the session would outlive the window. The window's switches now come last; a test pins the last-wins rule they rely on. Co-Authored-By: Claude Sonnet 5 --- .../FsiJsonRpcServerTests.fs | 18 ++++++++++++++++++ .../InteractiveHost.fs | 11 +++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/tests/FSharp.Compiler.Interactive.Server.Tests/FsiJsonRpcServerTests.fs b/tests/FSharp.Compiler.Interactive.Server.Tests/FsiJsonRpcServerTests.fs index 47476113f6f..b5552217346 100644 --- a/tests/FSharp.Compiler.Interactive.Server.Tests/FsiJsonRpcServerTests.fs +++ b/tests/FSharp.Compiler.Interactive.Server.Tests/FsiJsonRpcServerTests.fs @@ -667,6 +667,24 @@ let ``the session exits when its host process exits`` () = Assert.True(session.WaitForExit 30_000, "the session outlived its host process") +[] +let ``the last owner named on the command line is the one that counts`` () = + // A host puts its own switches after the user's arguments to keep them from naming another owner, + // which holds only if the last occurrence wins. + use earlier = new FsiServerHarness() + + use session = + new FsiServerHarness( + serverSwitches = fun pipeName -> [ $"--fsi-server-client-pid:{earlier.ProcessId}"; $"--fsi-server-jsonrpc:{pipeName}" ] + ) + + session.Initialize() |> ignore + + (earlier :> IDisposable).Dispose() + + Assert.False(session.WaitForExit 5_000, "the session followed the owner named first") + Assert.True(succeeded (session.Execute "1 + 1")) + //------------------------------------------------------------------------- // What ships //------------------------------------------------------------------------- diff --git a/vsintegration/src/FSharp.Interactive.Window/InteractiveHost.fs b/vsintegration/src/FSharp.Interactive.Window/InteractiveHost.fs index 956611cf291..14bd7405917 100644 --- a/vsintegration/src/FSharp.Interactive.Window/InteractiveHost.fs +++ b/vsintegration/src/FSharp.Interactive.Window/InteractiveHost.fs @@ -224,10 +224,6 @@ type internal InteractiveHostClient(clientProcessId: int) = addSwitch argument addSwitch "--nologo" - addSwitch $"{CommandLine.ServerOption}{pipeName}" - // How the host names itself: a session whose host dies, even before the handshake, exits instead of - // waiting on the pipe forever. - addSwitch $"--fsi-server-client-pid:{clientProcessId}" addSwitch $"--fsi-server-output-codepage:{Encoding.UTF8.CodePage}" addSwitch $"--fsi-server-input-codepage:{Encoding.UTF8.CodePage}" addSwitch $"--fsi-server-lcid:{options.UICultureLcid}" @@ -244,6 +240,13 @@ type internal InteractiveHostClient(clientProcessId: int) = if options.LanguageVersionPreview then addSwitch "--langversion:preview" + // Last, because for each of these the last occurrence wins: the user's own arguments must not + // be able to move the pipe or name another process as the owner, which would leave the session + // running after this one closes. The owner is named on the command line, so that a session + // whose host dies before the handshake exits instead of waiting on the pipe forever. + addSwitch $"{CommandLine.ServerOption}{pipeName}" + addSwitch $"--fsi-server-client-pid:{clientProcessId}" + let startInfo = ProcessStartInfo( FileName = executable, From b7e0650dd58ef5b5d4455338bc8caceb19284a99 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 21 Sep 2026 16:22:02 +0200 Subject: [PATCH 25/27] Leave the lexer to the editor: the window no longer compiles against the F# compiler The window used the compiler for one thing, its lexer, to colour input and output and to decide when Enter submits. It now asks an ILexicalScannerFactory, which FSharp.Editor, already running on the compiler Visual Studio ships, exports. The window drops its reference to FSharp.Compiler.Service, and DisableTransitiveProjectReferences keeps FSharp.VS.FSI from handing it back; the session's own compiler remains the SDK's, in the dotnet fsi process. The submission rule is tested with the editor's scanner. Co-Authored-By: Claude Sonnet 5 --- .../ide/FSI-Modern-Interactive-Window-Plan.md | 2 +- ...p.Compiler.Interactive.Server.Tests.fsproj | 11 +- .../SubmissionAnalysisTests.fs | 14 ++- .../Commands/InteractiveLexicalScanner.fs | 71 ++++++++++++ .../InteractiveLexicalScannerExport.fs | 12 ++ .../src/FSharp.Editor/FSharp.Editor.fsproj | 2 + .../FSharp.Interactive.Window.fsproj | 7 +- .../FSharpInteractiveClassifier.fs | 73 ++++++------- .../FSharpInteractiveEvaluator.fs | 11 +- .../FSharpVsInteractiveWindowProvider.fs | 8 +- .../LexicalScanning.fs | 41 +++++++ .../SubmissionAnalysis.fs | 103 ++++++------------ 12 files changed, 236 insertions(+), 119 deletions(-) create mode 100644 vsintegration/src/FSharp.Editor/Commands/InteractiveLexicalScanner.fs create mode 100644 vsintegration/src/FSharp.Editor/Commands/InteractiveLexicalScannerExport.fs create mode 100644 vsintegration/src/FSharp.Interactive.Window/LexicalScanning.fs diff --git a/docs/ide/FSI-Modern-Interactive-Window-Plan.md b/docs/ide/FSI-Modern-Interactive-Window-Plan.md index f34006a5f4d..b2c426c90c7 100644 --- a/docs/ide/FSI-Modern-Interactive-Window-Plan.md +++ b/docs/ide/FSI-Modern-Interactive-Window-Plan.md @@ -47,7 +47,7 @@ Still to do before the window can be opened in Visual Studio: Everything from Phase 3 onwards (IntelliSense in the input buffer) is untouched, with one exception: the input and output buffers have lexical colour from a tokenizer-based classifier -scoped to the window's own buffers. Phase 3 replaces the input half with the editor's semantic +scoped to the window's own buffers. The lexer comes from the editor (`ILexicalScannerFactory`), so the window has no compile-time dependency on the F# compiler. Phase 3 replaces the input half with the editor's semantic classification when submissions become workspace documents; the output half stays lexical, since output is not a program. One protocol gap belongs to that phase: an execution result reports the working directory but not the references and diff --git a/tests/FSharp.Compiler.Interactive.Server.Tests/FSharp.Compiler.Interactive.Server.Tests.fsproj b/tests/FSharp.Compiler.Interactive.Server.Tests/FSharp.Compiler.Interactive.Server.Tests.fsproj index 35b79b81c56..a95e790bc0c 100644 --- a/tests/FSharp.Compiler.Interactive.Server.Tests/FSharp.Compiler.Interactive.Server.Tests.fsproj +++ b/tests/FSharp.Compiler.Interactive.Server.Tests/FSharp.Compiler.Interactive.Server.Tests.fsproj @@ -24,8 +24,15 @@ interactiveProtocol.fs - + + + LexicalScanning.fs + + + InteractiveLexicalScanner.fs + SubmissionAnalysis.fs diff --git a/tests/FSharp.Compiler.Interactive.Server.Tests/SubmissionAnalysisTests.fs b/tests/FSharp.Compiler.Interactive.Server.Tests/SubmissionAnalysisTests.fs index ca70ded8a6d..83bdea6e717 100644 --- a/tests/FSharp.Compiler.Interactive.Server.Tests/SubmissionAnalysisTests.fs +++ b/tests/FSharp.Compiler.Interactive.Server.Tests/SubmissionAnalysisTests.fs @@ -9,13 +9,17 @@ module FSharp.Compiler.Interactive.Server.Tests.SubmissionAnalysisTests open Xunit +open Microsoft.VisualStudio.FSharp.Editor open Microsoft.VisualStudio.FSharp.Interactive +// The lexer the Visual Studio tooling runs on, which the window is handed rather than compiling against. +let private scanners = FSharpLexicalScannerFactory() :> ILexicalScannerFactory + let private assertComplete text = - Assert.True(SubmissionAnalysis.isComplete text, $"expected a complete submission: <<{text}>>") + Assert.True(SubmissionAnalysis.isComplete scanners text, $"expected a complete submission: <<{text}>>") let private assertIncomplete text = - Assert.False(SubmissionAnalysis.isComplete text, $"expected an incomplete submission: <<{text}>>") + Assert.False(SubmissionAnalysis.isComplete scanners text, $"expected an incomplete submission: <<{text}>>") [] let ``an explicit terminator always submits`` () = @@ -86,7 +90,7 @@ let ``an empty submission is allowed through`` () = [] let ``the terminator is added only when it is missing`` () = - Assert.Equal("1 + 1;;", SubmissionAnalysis.withTerminator "1 + 1;;") - Assert.Equal("1 + 1\n;;", SubmissionAnalysis.withTerminator "1 + 1") + Assert.Equal("1 + 1;;", SubmissionAnalysis.withTerminator scanners "1 + 1;;") + Assert.Equal("1 + 1\n;;", SubmissionAnalysis.withTerminator scanners "1 + 1") // A ';;' that is only part of a string does not count as one. - Assert.Equal("let s = \"a;;b\"\n;;", SubmissionAnalysis.withTerminator "let s = \"a;;b\"") + Assert.Equal("let s = \"a;;b\"\n;;", SubmissionAnalysis.withTerminator scanners "let s = \"a;;b\"") diff --git a/vsintegration/src/FSharp.Editor/Commands/InteractiveLexicalScanner.fs b/vsintegration/src/FSharp.Editor/Commands/InteractiveLexicalScanner.fs new file mode 100644 index 00000000000..0e3937cba3e --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Commands/InteractiveLexicalScanner.fs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Editor + +open FSharp.Compiler.Tokenization + +open Microsoft.VisualStudio.FSharp.Interactive + +/// The lexer the F# tooling in Visual Studio runs on, offered to the interactive window, which +/// colours its input and output and judges when a submission is complete without compiling against it. +type internal FSharpLexicalScanner() = + + static let tokenizer = FSharpSourceTokenizer([], Some "stdin.fsx", None) + + static let probeIdentifier = "__fsharp_interactive_probe__" + + let mutable state = FSharpTokenizerLexState.Initial + + static let kindOf color = + match color with + | FSharpTokenColorKind.Keyword -> LexicalKind.Keyword + | FSharpTokenColorKind.Comment -> LexicalKind.Comment + | FSharpTokenColorKind.String -> LexicalKind.String + | FSharpTokenColorKind.Number -> LexicalKind.Number + | FSharpTokenColorKind.Operator -> LexicalKind.Operator + | FSharpTokenColorKind.Identifier + | FSharpTokenColorKind.UpperIdentifier -> LexicalKind.Identifier + | FSharpTokenColorKind.PreprocessorKeyword -> LexicalKind.PreprocessorKeyword + | FSharpTokenColorKind.InactiveCode -> LexicalKind.InactiveCode + | _ -> LexicalKind.Other + + interface ILexicalScanner with + + member _.ScanLine(line, tokens) = + let lineTokenizer = tokenizer.CreateLineTokenizer line + let mutable scanning = true + + while scanning do + match lineTokenizer.ScanToken state with + | Some token, nextState -> + state <- nextState + + tokens.Add + { + Kind = kindOf token.ColorClass + Start = token.LeftColumn + Length = token.FullMatchedLength + } + | None, nextState -> + state <- nextState + scanning <- false + + // The lexer state carries more than "inside a string or comment", so comparing it against + // the initial state says nothing. Tokenizing an identifier with the state the text left + // behind does: inside an unterminated string or comment the probe comes back coloured as + // part of that construct. + member _.EndsInsideMultiLineConstruct = + match (tokenizer.CreateLineTokenizer probeIdentifier).ScanToken state with + | Some token, _ -> + match token.ColorClass with + | FSharpTokenColorKind.String + | FSharpTokenColorKind.Comment + | FSharpTokenColorKind.InactiveCode -> true + | _ -> false + | None, _ -> false + +type internal FSharpLexicalScannerFactory() = + + interface ILexicalScannerFactory with + member _.CreateScanner() = + FSharpLexicalScanner() :> ILexicalScanner diff --git a/vsintegration/src/FSharp.Editor/Commands/InteractiveLexicalScannerExport.fs b/vsintegration/src/FSharp.Editor/Commands/InteractiveLexicalScannerExport.fs new file mode 100644 index 00000000000..2e914b06c61 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Commands/InteractiveLexicalScannerExport.fs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Editor + +open System.ComponentModel.Composition + +open Microsoft.VisualStudio.FSharp.Interactive + +/// Hands the interactive window the lexer this assembly compiles against. +[)>] +type internal FSharpLexicalScannerFactoryExport() = + inherit FSharpLexicalScannerFactory() diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj index 4b6eb45d41b..c163a7bde64 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj @@ -99,6 +99,8 @@ + + diff --git a/vsintegration/src/FSharp.Interactive.Window/FSharp.Interactive.Window.fsproj b/vsintegration/src/FSharp.Interactive.Window/FSharp.Interactive.Window.fsproj index f419eca163f..8791381b93c 100644 --- a/vsintegration/src/FSharp.Interactive.Window/FSharp.Interactive.Window.fsproj +++ b/vsintegration/src/FSharp.Interactive.Window/FSharp.Interactive.Window.fsproj @@ -8,6 +8,11 @@ $(OtherFlags) --subsystemversion:6.00 true enable + + true @@ -22,6 +27,7 @@ interactiveProtocol.fs + @@ -32,7 +38,6 @@ - diff --git a/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveClassifier.fs b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveClassifier.fs index 442670e0bd4..e90d2f0fca2 100644 --- a/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveClassifier.fs +++ b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveClassifier.fs @@ -11,8 +11,6 @@ open Microsoft.VisualStudio.Text open Microsoft.VisualStudio.Text.Classification open Microsoft.VisualStudio.Utilities -open FSharp.Compiler.Tokenization - [] module private InteractiveContentTypes = @@ -26,9 +24,13 @@ module private InteractiveContentTypes = /// Input carries lexer state across lines, because a submission is one fragment of code and small. /// Output is neither: it grows for the life of the session and interleaves printed values with /// whatever the code wrote to the console, so each line is coloured on its own. -type internal FSharpInteractiveClassifier(buffer: ITextBuffer, registry: IClassificationTypeRegistryService, carriesStateAcrossLines: bool) = - - let tokenizer = FSharpSourceTokenizer([], Some "stdin.fsx", None) +type internal FSharpInteractiveClassifier + ( + buffer: ITextBuffer, + registry: IClassificationTypeRegistryService, + scanners: ILexicalScannerFactory, + carriesStateAcrossLines: bool + ) = let keyword = registry.GetClassificationType "keyword" let comment = registry.GetClassificationType "comment" @@ -41,16 +43,15 @@ type internal FSharpInteractiveClassifier(buffer: ITextBuffer, registry: IClassi let classificationFor kind = match kind with - | FSharpTokenColorKind.Keyword -> ValueSome keyword - | FSharpTokenColorKind.Comment -> ValueSome comment - | FSharpTokenColorKind.String -> ValueSome string' - | FSharpTokenColorKind.Number -> ValueSome number - | FSharpTokenColorKind.Operator -> ValueSome operator - | FSharpTokenColorKind.Identifier - | FSharpTokenColorKind.UpperIdentifier -> ValueSome identifier - | FSharpTokenColorKind.PreprocessorKeyword -> ValueSome preprocessor - | FSharpTokenColorKind.InactiveCode -> ValueSome excluded - | _ -> ValueNone + | LexicalKind.Keyword -> ValueSome keyword + | LexicalKind.Comment -> ValueSome comment + | LexicalKind.String -> ValueSome string' + | LexicalKind.Number -> ValueSome number + | LexicalKind.Operator -> ValueSome operator + | LexicalKind.Identifier -> ValueSome identifier + | LexicalKind.PreprocessorKeyword -> ValueSome preprocessor + | LexicalKind.InactiveCode -> ValueSome excluded + | LexicalKind.Other -> ValueNone // Both interactive content types are shared with every language the window package hosts, and // ownership cannot be settled when the classifier is built: the buffer is handed to us before @@ -107,47 +108,45 @@ type internal FSharpInteractiveClassifier(buffer: ITextBuffer, registry: IClassi snapshot.GetLineNumberFromPosition span.Start.Position let lastLine = snapshot.GetLineNumberFromPosition span.End.Position - let mutable state = FSharpTokenizerLexState.Initial + let tokens = ResizeArray() + let mutable scanner = scanners.CreateScanner() for lineNumber in firstLine..lastLine do if not carriesStateAcrossLines then - state <- FSharpTokenizerLexState.Initial + scanner <- scanners.CreateScanner() let line = snapshot.GetLineFromLineNumber lineNumber let text = line.GetText() - let lineTokenizer = tokenizer.CreateLineTokenizer text - let mutable scanning = true - - while scanning do - match lineTokenizer.ScanToken state with - | Some token, nextState -> - state <- nextState + tokens.Clear() + scanner.ScanLine(text, tokens) - if token.LeftColumn >= 0 && token.LeftColumn + token.FullMatchedLength <= text.Length then - let tokenSpan = SnapshotSpan(snapshot, line.Start.Position + token.LeftColumn, token.FullMatchedLength) + for token in tokens do + if token.Start >= 0 && token.Start + token.Length <= text.Length then + let tokenSpan = SnapshotSpan(snapshot, line.Start.Position + token.Start, token.Length) - if tokenSpan.IntersectsWith span then - match classificationFor token.ColorClass with - | ValueSome classification -> result.Add(ClassificationSpan(tokenSpan, classification)) - | ValueNone -> () - | None, nextState -> - state <- nextState - scanning <- false + if tokenSpan.IntersectsWith span then + match classificationFor token.Kind with + | ValueSome classification -> result.Add(ClassificationSpan(tokenSpan, classification)) + | ValueNone -> () result :> IList<_> [)>] [] -type internal FSharpInteractiveInputClassifierProvider [] (registry: IClassificationTypeRegistryService) = +type internal FSharpInteractiveInputClassifierProvider + [] + (registry: IClassificationTypeRegistryService, scanners: ILexicalScannerFactory) = interface IClassifierProvider with member _.GetClassifier buffer = - buffer.Properties.GetOrCreateSingletonProperty(fun () -> FSharpInteractiveClassifier(buffer, registry, true)) + buffer.Properties.GetOrCreateSingletonProperty(fun () -> FSharpInteractiveClassifier(buffer, registry, scanners, true)) [)>] [] -type internal FSharpInteractiveOutputClassifierProvider [] (registry: IClassificationTypeRegistryService) = +type internal FSharpInteractiveOutputClassifierProvider + [] + (registry: IClassificationTypeRegistryService, scanners: ILexicalScannerFactory) = interface IClassifierProvider with member _.GetClassifier buffer = - buffer.Properties.GetOrCreateSingletonProperty(fun () -> FSharpInteractiveClassifier(buffer, registry, false)) + buffer.Properties.GetOrCreateSingletonProperty(fun () -> FSharpInteractiveClassifier(buffer, registry, scanners, false)) diff --git a/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveEvaluator.fs b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveEvaluator.fs index df83c0ac69e..578bd2d1dc3 100644 --- a/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveEvaluator.fs +++ b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveEvaluator.fs @@ -40,7 +40,12 @@ module internal ResultRendering = /// Connects the interactive window to an F# Interactive session. [] -type internal FSharpInteractiveEvaluator(host: InteractiveHostClient, getOptions: unit -> InteractiveHostOptions) = +type internal FSharpInteractiveEvaluator + ( + host: InteractiveHostClient, + getOptions: unit -> InteractiveHostOptions, + scanners: ILexicalScannerFactory + ) = let mutable currentWindow: IInteractiveWindow | null = null let mutable outputSubscription: IDisposable | null = null @@ -161,7 +166,7 @@ type internal FSharpInteractiveEvaluator(host: InteractiveHostClient, getOptions return ExecutionResult false } - member _.CanExecuteCode(text) = SubmissionAnalysis.isComplete text + member _.CanExecuteCode(text) = SubmissionAnalysis.isComplete scanners text member _.ExecuteCodeAsync(text) = task { @@ -179,7 +184,7 @@ type internal FSharpInteractiveEvaluator(host: InteractiveHostClient, getOptions | ValueSome(struct (sourcePath, startLine)) -> host.ExecuteAsync(code, sourcePath, startLine) | ValueNone -> host.ExecuteAsync code - match! submit (SubmissionAnalysis.withTerminator text) with + match! submit (SubmissionAnalysis.withTerminator scanners text) with | Result.Error message -> writeErrorLine message return ExecutionResult false diff --git a/vsintegration/src/FSharp.Interactive.Window/FSharpVsInteractiveWindowProvider.fs b/vsintegration/src/FSharp.Interactive.Window/FSharpVsInteractiveWindowProvider.fs index f3e04b8fa6d..66236ecc330 100644 --- a/vsintegration/src/FSharp.Interactive.Window/FSharpVsInteractiveWindowProvider.fs +++ b/vsintegration/src/FSharp.Interactive.Window/FSharpVsInteractiveWindowProvider.fs @@ -77,14 +77,18 @@ module internal InteractiveHostOptionsFactory = [] type internal FSharpVsInteractiveWindowProvider [] - (windowFactory: IVsInteractiveWindowFactory, contentTypeRegistry: IContentTypeRegistryService) = + ( + windowFactory: IVsInteractiveWindowFactory, + contentTypeRegistry: IContentTypeRegistryService, + scanners: ILexicalScannerFactory + ) = let mutable window: IVsInteractiveWindow | null = null let mutable evaluator: FSharpInteractiveEvaluator voption = ValueNone member this.Create(instanceId: int) = let host = new InteractiveHostClient(Process.GetCurrentProcess().Id) - let created = new FSharpInteractiveEvaluator(host, InteractiveHostOptionsFactory.create) + let created = new FSharpInteractiveEvaluator(host, InteractiveHostOptionsFactory.create, scanners) evaluator <- ValueSome created let toolWindow = diff --git a/vsintegration/src/FSharp.Interactive.Window/LexicalScanning.fs b/vsintegration/src/FSharp.Interactive.Window/LexicalScanning.fs new file mode 100644 index 00000000000..72a148cc3e2 --- /dev/null +++ b/vsintegration/src/FSharp.Interactive.Window/LexicalScanning.fs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Interactive + +/// How the lexer classifies a token, reduced to what the window colours or reasons about. +[] +type internal LexicalKind = + | Keyword + | Comment + | String + | Number + | Operator + | Identifier + | PreprocessorKeyword + | InactiveCode + | Other + +/// A token on one line: where it starts and how many characters it spans. +[] +type internal LexicalToken = + { + Kind: LexicalKind + Start: int + Length: int + } + +/// Scans text a line at a time, carrying the lexer's state from one line to the next. +type internal ILexicalScanner = + + /// Appends the tokens of the next line to . + abstract ScanLine: line: string * tokens: ResizeArray -> unit + + /// Whether what has been scanned so far ends inside a string or comment that goes on into the + /// next line. + abstract EndsInsideMultiLineConstruct: bool + +/// The source of scanners. The window colours text and judges when a submission is complete, but +/// leaves the lexer to the host: the F# tooling in Visual Studio already runs on one, and the +/// session's own compiler is the SDK's, started as a separate process. +type internal ILexicalScannerFactory = + abstract CreateScanner: unit -> ILexicalScanner diff --git a/vsintegration/src/FSharp.Interactive.Window/SubmissionAnalysis.fs b/vsintegration/src/FSharp.Interactive.Window/SubmissionAnalysis.fs index 6b46f701275..36e88076d90 100644 --- a/vsintegration/src/FSharp.Interactive.Window/SubmissionAnalysis.fs +++ b/vsintegration/src/FSharp.Interactive.Window/SubmissionAnalysis.fs @@ -5,8 +5,6 @@ namespace Microsoft.VisualStudio.FSharp.Interactive open System open System.Collections.Generic -open FSharp.Compiler.Tokenization - /// Decides whether pressing Enter submits what the user has typed or adds another line. /// /// The window asks on every Enter, on the UI thread, so the judgement is lexical rather than a @@ -85,8 +83,6 @@ module internal SubmissionAnalysis = elif closing.Contains token then Closing else Ordinary - let private tokenizer = FSharpSourceTokenizer([], Some "stdin.fsx", None) - type private Scan = { /// Outside strings and comments. @@ -96,74 +92,44 @@ module internal SubmissionAnalysis = EndsWithTerminator: bool } - [] - let private probeIdentifier = "__fsharp_interactive_probe__" - - let private scan (text: string) = + let private scan (scanners: ILexicalScannerFactory) (text: string) = let lines = text.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n') - let mutable state = FSharpTokenizerLexState.Initial + let scanner = scanners.CreateScanner() + let tokens = ResizeArray() let mutable openBrackets = 0 let mutable lastToken = ValueNone - let scanLine (line: string) (record: bool) = - let lineTokenizer = tokenizer.CreateLineTokenizer line - let mutable firstColor = ValueNone - let mutable scanning = true - - while scanning do - match lineTokenizer.ScanToken state with - | Some token, nextState -> - state <- nextState - - if firstColor.IsNone then - firstColor <- ValueSome token.ColorClass - - if record then - match token.ColorClass with - | FSharpTokenColorKind.Comment - | FSharpTokenColorKind.InactiveCode -> () - | FSharpTokenColorKind.String -> - // A literal ends a submission as a number would, but its contents are - // not code: brackets and terminators inside it must not count. - lastToken <- ValueSome "\"\"" - | _ -> - let value = - if token.LeftColumn >= 0 && token.LeftColumn + token.FullMatchedLength <= line.Length then - line.Substring(token.LeftColumn, token.FullMatchedLength) - else - "" - - if not (String.IsNullOrWhiteSpace value) then - match value with - | Opening -> openBrackets <- openBrackets + 1 - | Closing -> openBrackets <- openBrackets - 1 - | Ordinary -> () - - lastToken <- ValueSome value - | None, nextState -> - state <- nextState - scanning <- false - - firstColor - for line in lines do - scanLine line true |> ignore - - // The lexer state carries more than "inside a string or comment", so comparing it against - // the initial state says nothing. Tokenizing an identifier with the state the text left - // behind does: inside an unterminated string or comment the probe comes back coloured as - // part of that construct. - let insideMultiLineConstruct = - match scanLine probeIdentifier false with - | ValueSome FSharpTokenColorKind.String - | ValueSome FSharpTokenColorKind.Comment - | ValueSome FSharpTokenColorKind.InactiveCode -> true - | _ -> false + tokens.Clear() + scanner.ScanLine(line, tokens) + + for token in tokens do + match token.Kind with + | LexicalKind.Comment + | LexicalKind.InactiveCode -> () + | LexicalKind.String -> + // A literal ends a submission as a number would, but its contents are + // not code: brackets and terminators inside it must not count. + lastToken <- ValueSome "\"\"" + | _ -> + let value = + if token.Start >= 0 && token.Start + token.Length <= line.Length then + line.Substring(token.Start, token.Length) + else + "" + + if not (String.IsNullOrWhiteSpace value) then + match value with + | Opening -> openBrackets <- openBrackets + 1 + | Closing -> openBrackets <- openBrackets - 1 + | Ordinary -> () + + lastToken <- ValueSome value { OpenBrackets = openBrackets - InsideMultiLineConstruct = insideMultiLineConstruct + InsideMultiLineConstruct = scanner.EndsInsideMultiLineConstruct LastToken = lastToken EndsWithTerminator = match lastToken with @@ -171,21 +137,22 @@ module internal SubmissionAnalysis = | ValueNone -> false } - let endsWithTerminator (text: string) = (scan text).EndsWithTerminator + let endsWithTerminator (scanners: ILexicalScannerFactory) (text: string) = + (scan scanners text).EndsWithTerminator /// An explicit `;;` always submits; without one, the submission goes when nothing is visibly /// left open. - let isComplete (text: string) = + let isComplete (scanners: ILexicalScannerFactory) (text: string) = if String.IsNullOrWhiteSpace text then // The window submits an empty one to start a session. true else - match scan text with + match scan scanners text with | { EndsWithTerminator = true } -> true | { InsideMultiLineConstruct = true } -> false | scanned when scanned.OpenBrackets > 0 -> false | { LastToken = ValueSome token } -> not (continuationTokens.Contains token) | _ -> true - let withTerminator (text: string) = - if endsWithTerminator text then text else $"{text}\n;;" + let withTerminator (scanners: ILexicalScannerFactory) (text: string) = + if endsWithTerminator scanners text then text else $"{text}\n;;" From b31b7dac9b5ea42732bdabbdb4d688acde2c37cd Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Thu, 24 Sep 2026 13:56:07 +0200 Subject: [PATCH 26/27] Install the .NET F# Interactive with Visual Studio The JSON-RPC server the interactive window drives first ships in SDK 11.0.200, and only the .NET build of fsi has it. Until that SDK is the floor, the Microsoft.FSharp.Compiler setup package also installs that build, in FSharp\Tools\Interactive: the assemblies of fsi's output folder and its runtime configuration, run with `dotnet exec`. The debug VSIX lays it out the same way, and the F5 profile points FSHARP_INTERACTIVE_PATH at the .NET fsi.dll instead of the desktop fsi.exe, which has no server. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Microsoft.FSharp.Compiler.MSBuild.csproj | 45 ++++++++++++++++++- vsintegration/Vsix/Directory.Build.props | 4 +- .../VisualFSharpFull/VisualFSharpDebug.csproj | 20 +++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/setup/Swix/Microsoft.FSharp.Compiler.MSBuild/Microsoft.FSharp.Compiler.MSBuild.csproj b/setup/Swix/Microsoft.FSharp.Compiler.MSBuild/Microsoft.FSharp.Compiler.MSBuild.csproj index a6cf0324ca9..8abc90843eb 100644 --- a/setup/Swix/Microsoft.FSharp.Compiler.MSBuild/Microsoft.FSharp.Compiler.MSBuild.csproj +++ b/setup/Swix/Microsoft.FSharp.Compiler.MSBuild/Microsoft.FSharp.Compiler.MSBuild.csproj @@ -9,6 +9,13 @@ + + @@ -40,8 +47,44 @@ + + <_InteractiveBinaries>$(BinariesFolder)fsi\$(Configuration)\$(FSharpNetCoreProductTargetFramework)\ + <_InteractiveInstallDir>InstallDir:Common7\IDE\CommonExtensions\Microsoft\FSharp\Tools\Interactive + <_SwrNewLine> + + + + + + <_InteractiveFile Include="$(_InteractiveBinaries)*.dll;$(_InteractiveBinaries)fsi.runtimeconfig.json" /> + + + <_InteractiveBlock>folder "$(_InteractiveInstallDir)"$(_SwrNewLine)@(_InteractiveFile->' file source="%(FullPath)"', '$(_SwrNewLine)')$(_SwrNewLine) + + + <_BuiltSwrLines Include="$(_InteractiveBlock)" /> + + + + + <_Language>%(_XlfLanguages.Identity) + + + <_InteractiveSatellite Remove="@(_InteractiveSatellite)" /> + <_InteractiveSatellite Include="$(_InteractiveBinaries)$(_Language)\*.resources.dll" /> + + + <_InteractiveSatelliteBlock>folder "$(_InteractiveInstallDir)\$(_Language)"$(_SwrNewLine)@(_InteractiveSatellite->' file source="%(FullPath)"', '$(_SwrNewLine)')$(_SwrNewLine) + + + <_BuiltSwrLines Include="$(_InteractiveSatelliteBlock)" Condition="'@(_InteractiveSatellite)' != ''" /> + <_Line> - + <_Lines> diff --git a/vsintegration/Vsix/Directory.Build.props b/vsintegration/Vsix/Directory.Build.props index da1189a4a90..afae409de5d 100644 --- a/vsintegration/Vsix/Directory.Build.props +++ b/vsintegration/Vsix/Directory.Build.props @@ -8,8 +8,8 @@ $(VSRootSuffix) $(ArtifactsDir)bin\fscAnyCpu\$(Configuration)\net472\ - - $(ArtifactsDir)bin\fsi\$(Configuration)\net472\fsi.exe + + $(ArtifactsDir)bin\fsi\$(Configuration)\$(FSharpNetCoreProductTargetFramework)\fsi.dll true publish\ true diff --git a/vsintegration/Vsix/VisualFSharpFull/VisualFSharpDebug.csproj b/vsintegration/Vsix/VisualFSharpFull/VisualFSharpDebug.csproj index 5a6671c4d24..b0578cacb19 100644 --- a/vsintegration/Vsix/VisualFSharpFull/VisualFSharpDebug.csproj +++ b/vsintegration/Vsix/VisualFSharpFull/VisualFSharpDebug.csproj @@ -82,6 +82,26 @@ Tools true + + + + Tools/Interactive + true + + + + Tools/Interactive/%(_XlfLanguages.Identity) + true + + + + + From 3287b0a6c8bdd8cb63dbf1cdb78e207a5a3c7641 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Thu, 24 Sep 2026 13:56:07 +0200 Subject: [PATCH 27/27] Fall back to the F# Interactive installed with Visual Studio The window tries `dotnet fsi` first, so the SDK that global.json names still supplies the compiler. An SDK that predates the server rejects the option and exits before the handshake; the window then starts the copy installed with Visual Studio. What the rejected attempt printed is held back and shown only if nothing else starts. The fsi that worked is remembered per folder, so a reset does not try the old SDK again, and the session banner says where the fsi came from. The exit handler is now attached before the process starts, so an early exit cannot leave the connect waiting. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ide/FSI-Modern-Interactive-Window-Plan.md | 4 +- docs/release-notes/.VisualStudio/18.vNext.md | 2 +- .../FSharpInteractiveEvaluator.fs | 5 +- .../InteractiveHost.fs | 243 ++++++++++++++---- 4 files changed, 207 insertions(+), 47 deletions(-) diff --git a/docs/ide/FSI-Modern-Interactive-Window-Plan.md b/docs/ide/FSI-Modern-Interactive-Window-Plan.md index b2c426c90c7..2d0286db4aa 100644 --- a/docs/ide/FSI-Modern-Interactive-Window-Plan.md +++ b/docs/ide/FSI-Modern-Interactive-Window-Plan.md @@ -199,7 +199,9 @@ The single most valuable user-facing change. Mechanism (Roslyn's, adapted): The window is .NET-only. The server mode exists in the .NET fsi alone, and the desktop matrix (`fsiAnyCpu.exe`, `fsi.exe`, `fsiArm64.exe`, `#reset` platform arguments, the shadow-copy switch) stays with the old window until that is retired. -The session is started in the open solution's folder, with the `dotnet` a shell there would run (`DOTNET_HOST_PATH`, then `PATH`, then the machine-wide install). The host resolves the SDK from that folder's `global.json` exactly as `dotnet fsi` typed in a shell would, so the window runs the same compiler bits as `dotnet build`, and Visual Studio and SDK versions are decoupled. Nothing in the window re-implements SDK resolution. `FSHARP_INTERACTIVE_PATH` names another fsi for development, until an SDK ships the protocol; the window prints which fsi answered, on what runtime and in which directory, when a session comes up. +The session is started in the open solution's folder, with the `dotnet` a shell there would run (`DOTNET_HOST_PATH`, then `PATH`, then the machine-wide install). The host resolves the SDK from that folder's `global.json` exactly as `dotnet fsi` typed in a shell would, so the window runs the same compiler bits as `dotnet build`, and Visual Studio and SDK versions are decoupled. Nothing in the window re-implements SDK resolution. + +The server first ships in SDK 11.0.200, so until that is the floor the Visual Studio insertion carries the .NET build of fsi as well, in `Common7\IDE\CommonExtensions\Microsoft\FSharp\Tools\Interactive` (the `Microsoft.FSharp.Compiler` setup package), run with `dotnet exec`. The window tries `dotnet fsi` first; an SDK that predates the server rejects the option and exits before the handshake, and the window then starts the copy installed with Visual Studio, without showing the rejected attempt. It remembers per folder which one worked, so a reset does not try the old SDK again. `FSHARP_INTERACTIVE_PATH` overrides both, for development. When a session comes up the window prints which fsi answered and where it came from, on what runtime, and in which directory. ### 2.6 Debugging (parity + improvement over C#) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 0738603eea0..c4fac26aded 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -1,7 +1,7 @@ ### Added * Code-fixes for FS3888 (compiler-semantic attribute on the `.fs` but not the `.fsi`): copy the attribute into the `.fsi`, or remove it from the `.fs`. ([Issue #19560](https://github.com/dotnet/fsharp/issues/19560), [PR #19880](https://github.com/dotnet/fsharp/pull/19880)) -* The F# Interactive window is rebuilt on the Interactive Window package and talks to `dotnet fsi` over its JSON-RPC server mode: the input and output are coloured, Enter submits only a complete interaction, diagnostics carry positions in the submitted file, and the session starts in the solution folder, so the SDK that `global.json` names supplies the compiler. ([PR #20565](https://github.com/dotnet/fsharp/pull/20565)) +* The F# Interactive window is rebuilt on the Interactive Window package and talks to `dotnet fsi` over its JSON-RPC server mode: the input and output are coloured, Enter submits only a complete interaction, diagnostics carry positions in the submitted file, and the session starts in the solution folder, so the SDK that `global.json` names supplies the compiler; until the .NET SDK includes the server (11.0.200), Visual Studio installs an F# Interactive of its own for the window to fall back to. ([PR #20565](https://github.com/dotnet/fsharp/pull/20565)) * Expand `` in IDE tooltips, completion, and signature help, inheriting XML documentation from base classes, interfaces, overridden members, and constructors. ([Issue #19175](https://github.com/dotnet/fsharp/issues/19175), [PR #19188](https://github.com/dotnet/fsharp/pull/19188)) ### Fixed diff --git a/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveEvaluator.fs b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveEvaluator.fs index 578bd2d1dc3..ccb93fa35f9 100644 --- a/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveEvaluator.fs +++ b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveEvaluator.fs @@ -35,8 +35,9 @@ module internal ResultRendering = $"{diagnostic.fileName}({diagnostic.startLine},{diagnostic.startColumn + 1}): {diagnostic.severity} FS%04d{diagnostic.errorNumber}: {diagnostic.message}" /// The line the window shows when a session comes up: which fsi answered, on what, and where. - let formatSessionStart (session: FSharp.Compiler.Interactive.Protocol.InitializeResult) = - $"F# Interactive {session.fsiVersion} on {session.frameworkDescription}, in {session.workingDirectory}" + let formatSessionStart (session: RemoteSession) = + let handshake = session.Initialization + $"F# Interactive {handshake.fsiVersion} from {session.Origin.Description} on {handshake.frameworkDescription}, in {handshake.workingDirectory}" /// Connects the interactive window to an F# Interactive session. [] diff --git a/vsintegration/src/FSharp.Interactive.Window/InteractiveHost.fs b/vsintegration/src/FSharp.Interactive.Window/InteractiveHost.fs index 14bd7405917..460c11d66c9 100644 --- a/vsintegration/src/FSharp.Interactive.Window/InteractiveHost.fs +++ b/vsintegration/src/FSharp.Interactive.Window/InteractiveHost.fs @@ -28,6 +28,30 @@ type InteractiveHostOptions = UICultureLcid: int } +/// Where the F# Interactive behind a session came from. +[] +type internal FsiOrigin = + /// Named by `FSHARP_INTERACTIVE_PATH`. + | Override + /// `dotnet fsi`, the SDK the working directory's `global.json` selects. + | Sdk + /// The .NET build of fsi installed with Visual Studio, for SDKs that predate its JSON-RPC server. + | VisualStudio + + member this.Description = + match this with + | Override -> "FSHARP_INTERACTIVE_PATH" + | Sdk -> ".NET SDK" + | VisualStudio -> "Visual Studio" + +/// One way to start F# Interactive. +type internal FsiCandidate = + { + Origin: FsiOrigin + Executable: string + LeadingArguments: string list + } + module internal FsiLocator = let private hostExecutable = @@ -67,14 +91,15 @@ module internal FsiLocator = Path.Combine(programFiles, "dotnet", hostExecutable) - /// Names an F# Interactive to run instead of the SDK's. - /// - /// The protocol needs an fsi that understands `--fsi-server-jsonrpc`, and the fsi an installed - /// SDK resolves to is only as new as that SDK, so a build from this repository has to be named - /// explicitly until the option ships. + /// Names the one F# Interactive to run, instead of the SDK's or the one installed with Visual + /// Studio: a build from a repository, for developing either end of the protocol. [] let OverrideVariable = "FSHARP_INTERACTIVE_PATH" + /// Where the Microsoft.FSharp.Compiler setup package puts the .NET build of fsi, relative to this + /// assembly's folder. + let private bundledRelativePath = Path.Combine("Tools", "Interactive", "fsi.dll") + /// A build of fsi from a repository runs on the .NET that repository provisions, which is often /// newer than any machine-wide install, so look for that host beside it before falling back. let private hostFor (fsiPath: string) = @@ -100,33 +125,73 @@ module internal FsiLocator = let host = hostFor path if File.Exists host then - ValueSome(Result.Ok(host, [ "exec"; path ])) + ValueSome( + Result.Ok + { + Origin = FsiOrigin.Override + Executable = host + LeadingArguments = [ "exec"; path ] + } + ) else ValueSome(Result.Error(VFSIstrings.SR.couldNotFindFsiExe host)) else - ValueSome(Result.Ok(path, [])) + ValueSome( + Result.Ok + { + Origin = FsiOrigin.Override + Executable = path + LeadingArguments = [] + } + ) | _ -> ValueNone - /// What to start. Apart from the override this is `dotnet fsi`: the host resolves the SDK from - /// the `global.json` nearest the directory the session starts in, so a session started in the - /// solution folder runs the same compiler bits as `dotnet build` there. Nothing here - /// re-implements that resolution. - let locate () = + let private tryBundled (host: string) = + match Path.GetDirectoryName(typeof.Assembly.Location) with + | null -> ValueNone + | directory -> + let fsi = Path.Combine(directory, bundledRelativePath) + + if File.Exists fsi then + ValueSome + { + Origin = FsiOrigin.VisualStudio + Executable = host + LeadingArguments = [ "exec"; fsi ] + } + else + ValueNone + + /// What to try, in order. Apart from the override this is `dotnet fsi` first: the host resolves the + /// SDK from the `global.json` nearest the directory the session starts in, so a session started in + /// the solution folder runs the same compiler bits as `dotnet build` there. An SDK too old for the + /// JSON-RPC server rejects the option and exits, and the fsi installed with Visual Studio is next. + let candidates () = match tryOverride () with - | ValueSome result -> result + | ValueSome(Result.Ok candidate) -> Result.Ok [ candidate ] + | ValueSome(Result.Error message) -> Result.Error message | ValueNone -> let host = findDotnetHost () if File.Exists host then - Result.Ok(host, [ "fsi" ]) + Result.Ok + [ + { + Origin = FsiOrigin.Sdk + Executable = host + LeadingArguments = [ "fsi" ] + } + yield! tryBundled host |> ValueOption.toList + ] else Result.Error(VFSIstrings.SR.couldNotFindFsiExe host) /// One live F# Interactive process together with the control channel to it. [] -type internal RemoteSession(session: Process, pipe: Stream, rpc: JsonRpc, initialization: InitializeResult) = +type internal RemoteSession(origin: FsiOrigin, session: Process, pipe: Stream, rpc: JsonRpc, initialization: InitializeResult) = + member _.Origin = origin member _.Process = session member _.Rpc = rpc member _.Initialization = initialization @@ -176,7 +241,13 @@ type internal InteractiveHostClient(clientProcessId: int) = let outputReceived = Event() let errorOutputReceived = Event() let processExited = Event() - let sessionStarted = Event() + let sessionStarted = Event() + + // The F# Interactive that last started in each working directory, tried first next time, so a + // reset does not pay again for an SDK that turned out to predate the JSON-RPC server. Paths on + // Windows are case-insensitive. + let workedIn = + System.Collections.Generic.Dictionary(StringComparer.OrdinalIgnoreCase) // Read as characters rather than lines: a script prompting with `printf "name? "` writes no // newline, and waiting for one would hide the prompt. @@ -212,15 +283,11 @@ type internal InteractiveHostClient(clientProcessId: int) = else argument - let createStartInfo (options: InteractiveHostOptions) (pipeName: string) = - match FsiLocator.locate () with - | Result.Error message -> Result.Error message - | Result.Ok(executable, leadingArguments) -> - + let createStartInfo (options: InteractiveHostOptions) (candidate: FsiCandidate) (pipeName: string) = let arguments = ResizeArray() let addSwitch (switch: string) = arguments.Add(quoteIfNeeded switch) - for argument in leadingArguments do + for argument in candidate.LeadingArguments do addSwitch argument addSwitch "--nologo" @@ -249,7 +316,7 @@ type internal InteractiveHostClient(clientProcessId: int) = let startInfo = ProcessStartInfo( - FileName = executable, + FileName = candidate.Executable, Arguments = String.Join(" ", arguments), UseShellExecute = false, CreateNoWindow = true, @@ -263,28 +330,52 @@ type internal InteractiveHostClient(clientProcessId: int) = if Directory.Exists options.InitialWorkingDirectory then startInfo.WorkingDirectory <- options.InitialWorkingDirectory - Result.Ok startInfo + startInfo - let startAsync (options: InteractiveHostOptions) (cancellationToken: CancellationToken) = + /// One attempt to start a session with one F# Interactive. What the process prints is held back + /// until the handshake succeeds: an SDK too old for the server prints an error the user need not + /// see when the next candidate starts fine. `Error(exitedEarly, message, release)` hands the + /// held output back to the caller, who shows it only when nothing else is left to try. + let startAttemptAsync (options: InteractiveHostOptions) (candidate: FsiCandidate) (cancellationToken: CancellationToken) = task { let sessionId = Guid.NewGuid().ToString "N" let pipeName = $"FSharpInteractive.{sessionId}" + let startInfo = createStartInfo options candidate pipeName - match createStartInfo options pipeName with - | Result.Error message -> return Result.Error message - | Result.Ok startInfo -> + let outputLock = obj () + let held = ResizeArray() + let mutable holding = true - let session = new Process(StartInfo = startInfo, EnableRaisingEvents = true) + let report isError (text: string) = + lock outputLock (fun () -> + if holding then + held.Add(struct (isError, text)) + ValueNone + else + ValueSome text) + |> ValueOption.iter (if isError then errorOutputReceived.Trigger else outputReceived.Trigger) + + let release () = + let pending = + lock outputLock (fun () -> + holding <- false + let pending = held.ToArray() + held.Clear() + pending) + + for struct (isError, text) in pending do + if isError then + errorOutputReceived.Trigger text + else + outputReceived.Trigger text - if not (session.Start()) then - return Result.Error(VFSIstrings.SR.couldNotFindFsiExe startInfo.FileName) - else + let heldText () = + lock outputLock (fun () -> String.Join("", held |> Seq.map (fun struct (_, text) -> text))) - pump session.StandardOutput outputReceived.Trigger - pump session.StandardError errorOutputReceived.Trigger + let session = new Process(StartInfo = startInfo, EnableRaisingEvents = true) // Without this a session that dies before the handshake leaves the connect below - // waiting out its whole timeout. + // waiting forever. Attached before the start, so an exit that comes first is not missed. use exitedDuringConnect = new CancellationTokenSource() session.Exited.Add(fun _ -> @@ -293,6 +384,19 @@ type internal InteractiveHostClient(clientProcessId: int) = with _ -> ()) + let started = + try + session.Start() + with _ -> + false + + if not started then + return Result.Error(false, VFSIstrings.SR.couldNotFindFsiExe candidate.Executable, ignore) + else + + pump session.StandardOutput (report false) + pump session.StandardError (report true) + use connectCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, exitedDuringConnect.Token) @@ -311,7 +415,7 @@ type internal InteractiveHostClient(clientProcessId: int) = let! handshake = rpc.InvokeWithCancellationAsync(Methods.Initialize, cancellationToken = cancellationToken) - let remote = RemoteSession(session, pipe, rpc, handshake) + let remote = RemoteSession(candidate.Origin, session, pipe, rpc, handshake) session.Exited.Add(fun _ -> let wasCurrent = @@ -330,7 +434,7 @@ type internal InteractiveHostClient(clientProcessId: int) = 0 )) - sessionStarted.Trigger handshake + release () return Result.Ok remote with e -> pipe.Dispose() @@ -341,15 +445,68 @@ type internal InteractiveHostClient(clientProcessId: int) = with _ -> () - // A session that exits before the handshake usually rejected the command line — - // most often an fsi too old to know the protocol option. + // A session that exits before the handshake usually rejected the command line: an + // fsi too old to know the protocol option, or a runtime that is not installed. + let exitedEarly = session.HasExited + + // The pumps may still be draining what the process wrote just before it exited. + if exitedEarly then + do! Task.Delay 200 + let detail = - if session.HasExited then - $"{startInfo.FileName} exited with code {session.ExitCode} before the session was established. If it does not support '--fsi-server-jsonrpc', set {FsiLocator.OverrideVariable} to an fsi that does." - else + if not exitedEarly then e.Message + elif heldText().IndexOf("install or update .NET", StringComparison.Ordinal) >= 0 then + $"The F# Interactive from {candidate.Origin.Description} needs a .NET runtime that is not installed; see the message above." + else + let commandLine = String.Join(" ", candidate.Executable :: candidate.LeadingArguments) + + $"The F# Interactive from {candidate.Origin.Description} ({commandLine}) exited with code {session.ExitCode} before the session was established." + + return Result.Error(exitedEarly, detail, release) + } - return Result.Error detail + /// Try each F# Interactive in turn, the one that last worked here first, and keep the first that + /// completes the handshake. Only a process that exits before the handshake moves on to the next: + /// any other failure is a problem with this session, not with the choice of fsi. + let startAsync (options: InteractiveHostOptions) (cancellationToken: CancellationToken) = + task { + match FsiLocator.candidates () with + | Result.Error message -> return Result.Error message + | Result.Ok candidates -> + + let key = options.InitialWorkingDirectory + + let ordered = + match lock workedIn (fun () -> workedIn.TryGetValue key) with + | true, origin -> + [ + yield! candidates |> List.filter (fun c -> c.Origin = origin) + yield! candidates |> List.filter (fun c -> c.Origin <> origin) + ] + | _ -> candidates + + let rec attempt remaining (failures: string list) = + task { + match remaining with + | [] -> return Result.Error(String.Join(Environment.NewLine, List.rev failures)) + | candidate :: rest -> + match! startAttemptAsync options candidate cancellationToken with + | Result.Ok remote -> + lock workedIn (fun () -> workedIn[key] <- candidate.Origin) + sessionStarted.Trigger remote + return Result.Ok remote + | Result.Error(exitedEarly, detail, release) -> + lock workedIn (fun () -> workedIn.Remove key |> ignore) + + if exitedEarly && not rest.IsEmpty then + return! attempt rest (detail :: failures) + else + release () + return Result.Error(String.Join(Environment.NewLine, List.rev (detail :: failures))) + } + + return! attempt ordered [] } member _.OutputReceived = outputReceived.Publish