diff --git a/.claude/rules/CcrOptout.md b/.claude/rules/CcrOptout.md
new file mode 100644
index 00000000000..a4e911e2dca
--- /dev/null
+++ b/.claude/rules/CcrOptout.md
@@ -0,0 +1,6 @@
+---
+paths:
+ - "**"
+---
+
+@../../.github/instructions/CcrOptout.instructions.md
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..2d0286db4aa
--- /dev/null
+++ b/docs/ide/FSI-Modern-Interactive-Window-Plan.md
@@ -0,0 +1,298 @@
+под# 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, 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:
+
+- 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, 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, with one
+exception: the input and output buffers have lexical colour from a tokenizer-based classifier
+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
+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.
+- 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.
+
+---
+
+## 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: (the SDK global.json resolves) │
+│ 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 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)
+
+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` | — (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 |
+| `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 Host selection
+
+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.
+
+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#)
+
+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`, `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, `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)
+
+- 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..c4fac26aded 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; 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/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/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..84c01c1b0e7 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/src/fsi/interactiveProtocol.fs b/src/fsi/interactiveProtocol.fs
index ebc4aa050c9..cdbf8e790a7 100644
--- a/src/fsi/interactiveProtocol.fs
+++ b/src/fsi/interactiveProtocol.fs
@@ -38,6 +38,11 @@ module Methods =
[]
let Shutdown = "fsi/shutdown"
+/// How a host asks for this protocol, at the point where there is no protocol yet to ask over.
+module CommandLine =
+ []
+ let ServerOption = "--fsi-server-jsonrpc:"
+
[]
type InitializeResult =
{
@@ -67,7 +72,7 @@ type ExecuteRequest =
/// with startLine this makes diagnostics point at the user's own source rather than
/// at a position within the submission.
///
- sourcePath: string
+ sourcePath: string | null
startLine: System.Nullable
}
@@ -78,7 +83,7 @@ type ExecuteFileRequest = { path: string }
[]
type SetPathsRequest =
{
- includePaths: string[]
+ includePaths: string[] | null
workingDirectory: string
}
@@ -123,8 +128,8 @@ type ExecutionResult =
success: bool
cancelled: bool
- diagnostics: DiagnosticInfo[]
- ``exception``: ExceptionInfo
+ diagnostics: DiagnosticInfo[] | null
+ ``exception``: ExceptionInfo | null
values: ValueInfo[]
/// Reported after every interaction so that the host can keep its own view of the session
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..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,6 +24,19 @@
interactiveProtocol.fs
+
+
+ LexicalScanning.fs
+
+
+ InteractiveLexicalScanner.fs
+
+
+ SubmissionAnalysis.fs
+
+
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/tests/FSharp.Compiler.Interactive.Server.Tests/FsiServerHarness.fs b/tests/FSharp.Compiler.Interactive.Server.Tests/FsiServerHarness.fs
index 5763454c980..1acf0809b66 100644
--- a/tests/FSharp.Compiler.Interactive.Server.Tests/FsiServerHarness.fs
+++ b/tests/FSharp.Compiler.Interactive.Server.Tests/FsiServerHarness.fs
@@ -111,7 +111,7 @@ type FsiServerHarness
locateFsi (defaultArg fsiDirectory (fsiOutputDirectory ()))
let serverSwitches =
- defaultArg serverSwitches (fun pipeName -> [ $"--fsi-server-jsonrpc:{pipeName}" ])
+ defaultArg serverSwitches (fun pipeName -> [ $"{CommandLine.ServerOption}{pipeName}" ])
let arguments =
[
@@ -229,7 +229,7 @@ type FsiServerHarness
let deadline = DateTime.UtcNow + defaultArg timeout (TimeSpan.FromSeconds 30.0)
let rec wait () =
- if this.StandardOutput.Contains text then true
+ if this.StandardOutput.Contains(text, StringComparison.Ordinal) then true
elif DateTime.UtcNow > deadline then false
else
Thread.Sleep 50
@@ -331,10 +331,10 @@ let diagnostics (result: ExecutionResult) =
| items -> items
let errors result =
- diagnostics result |> Array.filter (fun d -> d.severity = "error")
+ diagnostics result |> Array.filter (fun d -> String.Equals(d.severity, "error", StringComparison.Ordinal))
let warnings result =
- diagnostics result |> Array.filter (fun d -> d.severity = "warning")
+ diagnostics result |> Array.filter (fun d -> String.Equals(d.severity, "warning", StringComparison.Ordinal))
let succeeded (result: ExecutionResult) = result.success
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..83bdea6e717
--- /dev/null
+++ b/tests/FSharp.Compiler.Interactive.Server.Tests/SubmissionAnalysisTests.fs
@@ -0,0 +1,96 @@
+// 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.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 scanners text, $"expected a complete submission: <<{text}>>")
+
+let private assertIncomplete text =
+ Assert.False(SubmissionAnalysis.isComplete scanners text, $"expected an incomplete submission: <<{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 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 scanners "let s = \"a;;b\"")
diff --git a/vsintegration/Vsix/Directory.Build.props b/vsintegration/Vsix/Directory.Build.props
index d81bbf2613f..afae409de5d 100644
--- a/vsintegration/Vsix/Directory.Build.props
+++ b/vsintegration/Vsix/Directory.Build.props
@@ -8,6 +8,8 @@
$(VSRootSuffix)$(ArtifactsDir)bin\fscAnyCpu\$(Configuration)\net472\
+
+ $(ArtifactsDir)bin\fsi\$(Configuration)\$(FSharpNetCoreProductTargetFramework)\fsi.dlltruepublish\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)"
}
}
}
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
diff --git a/vsintegration/Vsix/VisualFSharpFull/VisualFSharpDebug.csproj b/vsintegration/Vsix/VisualFSharpFull/VisualFSharpDebug.csproj
index 5a6671c4d24..4e205bdba32 100644
--- a/vsintegration/Vsix/VisualFSharpFull/VisualFSharpDebug.csproj
+++ b/vsintegration/Vsix/VisualFSharpFull/VisualFSharpDebug.csproj
@@ -82,6 +82,26 @@
Toolstrue
+
+
+
+ Tools/Interactive
+ true
+
+
+
+ Tools/Interactive/%(_XlfLanguages.Identity)
+ true
+
+
+
+
+
diff --git a/vsintegration/src/FSharp.Editor/Commands/FsiCommandService.fs b/vsintegration/src/FSharp.Editor/Commands/FsiCommandService.fs
index 0dd36a301d2..00e06f36fd2 100644
--- a/vsintegration/src/FSharp.Editor/Commands/FsiCommandService.fs
+++ b/vsintegration/src/FSharp.Editor/Commands/FsiCommandService.fs
@@ -1,4 +1,4 @@
-// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
+// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
namespace Microsoft.VisualStudio.FSharp.Editor
@@ -37,44 +37,21 @@ 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
Hooks.OnMLSend fsiPackage.Value FsiEditorSendAction.DebugSelection null null
VSConstants.S_OK
- elif not (isNull nextTarget) then
- nextTarget.Exec(&pguidCmdGroup, nCmdId, nCmdexecopt, pvaIn, pvaOut)
else
- VSConstants.E_FAIL
+ match nextTarget with
+ | null -> VSConstants.E_FAIL
+ | target -> target.Exec(&pguidCmdGroup, nCmdId, nCmdexecopt, pvaIn, pvaOut)
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
@@ -85,10 +62,10 @@ type internal FsiCommandFilter(serviceProvider: System.IServiceProvider) =
prgCmds.[i].cmdf <- uint32 (OLECMDF.OLECMDF_SUPPORTED ||| OLECMDF.OLECMDF_ENABLED)
VSConstants.S_OK
- elif not (isNull nextTarget) then
- nextTarget.QueryStatus(&pguidCmdGroup, cCmds, prgCmds, pCmdText)
else
- VSConstants.E_FAIL
+ match nextTarget with
+ | null -> VSConstants.E_FAIL
+ | target -> target.QueryStatus(&pguidCmdGroup, cCmds, prgCmds, pCmdText)
[)>]
[]
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 319bdd5a264..c163a7bde64 100644
--- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
+++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
@@ -99,6 +99,8 @@
+
+
@@ -164,11 +166,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
new file mode 100644
index 00000000000..8791381b93c
--- /dev/null
+++ b/vsintegration/src/FSharp.Interactive.Window/FSharp.Interactive.Window.fsproj
@@ -0,0 +1,73 @@
+
+
+
+
+
+ Library
+ true
+ $(OtherFlags) --subsystemversion:6.00
+ true
+ enable
+
+ true
+
+
+
+
+
+
+
+
+
+ interactiveProtocol.fs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ FSharp.Interactive.Window
+ $(VSAssemblyVersion)
+ $PackageFolder$\FSharp.Interactive.Window.dll
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveClassifier.fs b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveClassifier.fs
new file mode 100644
index 00000000000..e90d2f0fca2
--- /dev/null
+++ b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveClassifier.fs
@@ -0,0 +1,152 @@
+// 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
+
+[]
+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.
+///
+/// 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,
+ scanners: ILexicalScannerFactory,
+ carriesStateAcrossLines: bool
+ ) =
+
+ 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
+ | 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
+ // 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 && isOurs () then
+ let snapshot = args.After
+ let start = snapshot.GetLineFromPosition(args.Changes[0].NewPosition).Start
+
+ 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
+
+ []
+ member _.ClassificationChanged = changed.Publish
+
+ member _.GetClassificationSpans(span: SnapshotSpan) =
+ let snapshot = span.Snapshot
+ let result = List()
+
+ if not (isOurs ()) then
+ result :> IList<_>
+ else
+
+ let firstLine =
+ if carriesStateAcrossLines then
+ 0
+ else
+ snapshot.GetLineNumberFromPosition span.Start.Position
+
+ let lastLine = snapshot.GetLineNumberFromPosition span.End.Position
+ let tokens = ResizeArray()
+ let mutable scanner = scanners.CreateScanner()
+
+ for lineNumber in firstLine..lastLine do
+ if not carriesStateAcrossLines then
+ scanner <- scanners.CreateScanner()
+
+ let line = snapshot.GetLineFromLineNumber lineNumber
+ let text = line.GetText()
+ tokens.Clear()
+ scanner.ScanLine(text, tokens)
+
+ 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.Kind with
+ | ValueSome classification -> result.Add(ClassificationSpan(tokenSpan, classification))
+ | ValueNone -> ()
+
+ result :> IList<_>
+
+[)>]
+[]
+type internal FSharpInteractiveInputClassifierProvider
+ []
+ (registry: IClassificationTypeRegistryService, scanners: ILexicalScannerFactory) =
+
+ interface IClassifierProvider with
+ member _.GetClassifier buffer =
+ buffer.Properties.GetOrCreateSingletonProperty(fun () -> FSharpInteractiveClassifier(buffer, registry, scanners, true))
+
+[)>]
+[]
+type internal FSharpInteractiveOutputClassifierProvider
+ []
+ (registry: IClassificationTypeRegistryService, scanners: ILexicalScannerFactory) =
+
+ interface IClassifierProvider with
+ member _.GetClassifier buffer =
+ buffer.Properties.GetOrCreateSingletonProperty(fun () -> FSharpInteractiveClassifier(buffer, registry, scanners, false))
diff --git a/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveCommandFilter.fs b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveCommandFilter.fs
new file mode 100644
index 00000000000..166d5c7c2a9
--- /dev/null
+++ b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveCommandFilter.fs
@@ -0,0 +1,153 @@
+// 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 Microsoft.VisualStudio
+open Microsoft.VisualStudio.Editor
+open Microsoft.VisualStudio.OLE.Interop
+open Microsoft.VisualStudio.Text
+open Microsoft.VisualStudio.Text.Editor
+open Microsoft.VisualStudio.TextManager.Interop
+open Microsoft.VisualStudio.Utilities
+
+/// What the user asked to send.
+type internal SendToInteractiveKind =
+ | Selection
+ | Line
+
+/// The shell delivers both send-to-interactive commands in the same group, so recognising one
+/// means checking the group and the identifier together.
+[]
+module private InteractiveCommand =
+
+ let (|SendSelection|SendLine|NotOurs|) (group: Guid, commandId: uint32) =
+ if group <> VSConstants.VsStd11 then NotOurs
+ elif commandId = uint32 VSConstants.VSStd11CmdID.ExecuteSelectionInInteractive then SendSelection
+ elif commandId = uint32 VSConstants.VSStd11CmdID.ExecuteLineInInteractive then SendLine
+ else NotOurs
+
+/// The text an editor command sends, and where it came from.
+type internal EditorSubmission =
+ {
+ Text: string
+
+ /// The file the text came from, so that the session reports diagnostics against it.
+ SourcePath: string
+
+ /// One-based, matching the line numbering the compiler reports.
+ StartLine: int
+ }
+
+module internal EditorSubmission =
+
+ let private lineOfCaret (view: ITextView) =
+ view.Caret.Position.BufferPosition.GetContainingLine()
+
+ /// The selection, or the caret's line when there is none. Sending a line also advances the
+ /// caret, which is what makes repeated Alt+Enter walk down a script.
+ let read (documentFactory: ITextDocumentFactoryService) (view: ITextView) kind =
+ let selection = view.Selection
+
+ let span, advanceCaret =
+ match kind with
+ | Line -> (lineOfCaret view).Extent, true
+ | Selection when selection.IsEmpty -> (lineOfCaret view).Extent, true
+ | Selection -> SnapshotSpan(selection.Start.Position, selection.End.Position), false
+
+ let text = span.GetText()
+
+ if String.IsNullOrWhiteSpace text then
+ ValueNone
+ else
+ let sourcePath =
+ match documentFactory.TryGetTextDocument view.TextBuffer with
+ | true, document -> document.FilePath
+ | _ -> ""
+
+ if advanceCaret then
+ let snapshot = view.TextSnapshot
+ let next = span.Start.GetContainingLine().LineNumber + 1
+
+ if next < snapshot.LineCount then
+ let start = snapshot.GetLineFromLineNumber(next).Start
+ view.Caret.MoveTo start |> ignore
+ view.Selection.Clear()
+
+ ValueSome
+ {
+ Text = text
+ SourcePath = sourcePath
+ StartLine = span.Start.GetContainingLine().LineNumber + 1
+ }
+
+/// Routes the editor's send-to-interactive commands to the F# Interactive window.
+type internal FSharpInteractiveCommandFilter
+ (provider: FSharpVsInteractiveWindowProvider, documentFactory: ITextDocumentFactoryService, view: ITextView) as this =
+
+ let mutable nextTarget: IOleCommandTarget | null = null
+
+ let send kind =
+ match EditorSubmission.read documentFactory view kind with
+ | ValueNone -> ()
+ | ValueSome submission -> provider.SubmitFromEditor(submission.Text, submission.SourcePath, submission.StartLine)
+
+ member _.AttachToViewAdapter(viewAdapter: IVsTextView) =
+ match viewAdapter.AddCommandFilter this with
+ | VSConstants.S_OK, next -> nextTarget <- next
+ | errorCode, _ -> ErrorHandler.ThrowOnFailure errorCode |> ignore
+
+ interface IOleCommandTarget with
+
+ member _.Exec(pguidCmdGroup, nCmdId, nCmdexecopt, pvaIn, pvaOut) =
+ match pguidCmdGroup, nCmdId with
+ | SendSelection ->
+ send Selection
+ VSConstants.S_OK
+ | SendLine ->
+ send Line
+ VSConstants.S_OK
+ | NotOurs ->
+ match nextTarget with
+ | null -> VSConstants.E_FAIL
+ | target -> target.Exec(&pguidCmdGroup, nCmdId, nCmdexecopt, pvaIn, pvaOut)
+
+ member _.QueryStatus(pguidCmdGroup, cCmds, prgCmds, pCmdText) =
+ match pguidCmdGroup with
+ | group when group = VSConstants.VsStd11 ->
+ for i in 0 .. int cCmds - 1 do
+ match group, prgCmds[i].cmdID with
+ | SendSelection -> prgCmds[i].cmdf <- uint32 (OLECMDF.OLECMDF_SUPPORTED ||| OLECMDF.OLECMDF_ENABLED)
+ | SendLine ->
+ prgCmds[i].cmdf <-
+ uint32 (
+ OLECMDF.OLECMDF_SUPPORTED
+ ||| OLECMDF.OLECMDF_ENABLED
+ ||| OLECMDF.OLECMDF_DEFHIDEONCTXTMENU
+ )
+ | NotOurs -> ()
+
+ VSConstants.S_OK
+ | _ ->
+ match nextTarget with
+ | null -> VSConstants.E_FAIL
+ | target -> target.QueryStatus(&pguidCmdGroup, cCmds, prgCmds, pCmdText)
+
+[)>]
+[]
+[]
+type internal FSharpInteractiveCommandFilterProvider
+ []
+ (
+ provider: FSharpVsInteractiveWindowProvider,
+ documentFactory: ITextDocumentFactoryService,
+ editorFactory: IVsEditorAdaptersFactoryService
+ ) =
+
+ interface IWpfTextViewCreationListener with
+ member _.TextViewCreated(view) =
+ match editorFactory.GetViewAdapter view with
+ | null -> ()
+ | adapter -> FSharpInteractiveCommandFilter(provider, documentFactory, view).AttachToViewAdapter adapter
diff --git a/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveEvaluator.fs b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveEvaluator.fs
new file mode 100644
index 00000000000..ccb93fa35f9
--- /dev/null
+++ b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveEvaluator.fs
@@ -0,0 +1,223 @@
+// 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.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 =
+
+ 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: 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.
+[]
+type internal FSharpInteractiveEvaluator
+ (
+ host: InteractiveHostClient,
+ getOptions: unit -> InteractiveHostOptions,
+ scanners: ILexicalScannerFactory
+ ) =
+
+ 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
+
+ // 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.
+ let mutable nextSubmissionOrigin: struct (string * int) voption = ValueNone
+
+ // 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 writeLine (text: string) =
+ match currentWindow with
+ | null -> ()
+ | window -> window.OutputWriter.WriteLine 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 reportDiagnostics (result: FSharp.Compiler.Interactive.Protocol.ExecutionResult) =
+ match result.diagnostics with
+ | null -> ()
+ | diagnostics ->
+ for diagnostic in diagnostics do
+ writeErrorLine (ResultRendering.formatDiagnostic diagnostic)
+
+ match result.``exception`` with
+ | null -> ()
+ | failure ->
+ writeErrorLine failure.message
+
+ if not (String.IsNullOrWhiteSpace failure.stackTrace) then
+ writeErrorLine failure.stackTrace
+
+ let ensureSessionAsync () =
+ task {
+ match! host.EnsureStartedAsync(getOptions ()) with
+ | Result.Ok _ -> return true
+ | Result.Error message ->
+ writeErrorLine message
+ return false
+ }
+
+ let unsubscribe (subscription: IDisposable | null) =
+ match subscription with
+ | null -> ()
+ | subscription -> subscription.Dispose()
+
+ let reportSessionExit exitCode =
+ writeErrorLine $"{VFSIstrings.SR.sessionTerminationDetected()} (exit code {exitCode})"
+
+ 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.
+ member _.SetNextSubmissionOrigin(sourcePath: string, startLine: int) =
+ nextSubmissionOrigin <-
+ if String.IsNullOrEmpty sourcePath then
+ ValueNone
+ else
+ ValueSome(struct (sourcePath, startLine))
+
+ 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
+ unsubscribe startedSubscription
+
+ match window with
+ | null -> ()
+ | window ->
+ FSharpInteractiveWindows.add window
+ outputSubscription <- host.OutputReceived.Subscribe write
+ errorSubscription <- host.ErrorOutputReceived.Subscribe writeError
+ exitedSubscription <- host.ProcessExited.Subscribe reportSessionExit
+ startedSubscription <- host.SessionStarted.Subscribe reportSessionStart
+
+ 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 {
+ match! host.ResetAsync(getOptions ()) with
+ | Result.Ok _ -> return ExecutionResult true
+ | Result.Error message ->
+ writeErrorLine message
+ return ExecutionResult false
+ }
+
+ member _.CanExecuteCode(text) = SubmissionAnalysis.isComplete scanners text
+
+ member _.ExecuteCodeAsync(text) =
+ task {
+ let! started = ensureSessionAsync ()
+
+ match started, String.IsNullOrWhiteSpace text with
+ | false, _ -> return ExecutionResult false
+ | true, true -> return ExecutionResult true
+ | true, false ->
+ let origin = nextSubmissionOrigin
+ nextSubmissionOrigin <- ValueNone
+
+ let submit code =
+ match origin with
+ | ValueSome(struct (sourcePath, startLine)) -> host.ExecuteAsync(code, sourcePath, startLine)
+ | ValueNone -> host.ExecuteAsync code
+
+ match! submit (SubmissionAnalysis.withTerminator scanners 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() =
+ match currentWindow with
+ | null -> "> "
+ | window ->
+ match window.CurrentLanguageBuffer with
+ | null -> "> "
+ | buffer when buffer.CurrentSnapshot.LineCount > 1 -> "- "
+ | _ -> "> "
+
+ interface IDisposable with
+ member _.Dispose() =
+ if not disposed then
+ disposed <- true
+
+ match currentWindow with
+ | null -> ()
+ | window -> FSharpInteractiveWindows.remove window
+
+ unsubscribe outputSubscription
+ unsubscribe errorSubscription
+ unsubscribe exitedSubscription
+ unsubscribe startedSubscription
+ (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..0dc99fac429
--- /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 voption = ValueNone
+
+ let getProvider () =
+ match provider with
+ | ValueSome provider -> ValueSome provider
+ | ValueNone ->
+ match this.GetService(typeof) with
+ | :? IComponentModel as components ->
+ let resolved =
+ components.DefaultExportProvider.GetExportedValue()
+
+ provider <- ValueSome resolved
+ ValueSome resolved
+ | _ -> ValueNone
+
+ 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
+ | ValueSome provider ->
+ provider.Create(int id) |> ignore
+ VSConstants.S_OK
+ | ValueNone -> 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..66236ecc330
--- /dev/null
+++ b/vsintegration/src/FSharp.Interactive.Window/FSharpVsInteractiveWindowProvider.fs
@@ -0,0 +1,136 @@
+// 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.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
+
+/// Identities the window is registered and addressed by. Public so that they are declared once and
+/// referenced, rather than repeated by every component that needs them.
+module 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 = Guids.guidFsharpLanguageService
+
+ /// FSharp.Editor declares this too, but it sits above this project and cannot be referenced
+ /// from here, so the name is repeated rather than shared.
+ []
+ let FSharpContentTypeName = "F#"
+
+/// Reads the session settings the existing Tools, Options page writes.
+module internal InteractiveHostOptionsFactory =
+
+ /// 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 () =
+ {
+ InitialWorkingDirectory = startDirectory ()
+ UserArguments = SessionsProperties.fsiArgs
+ 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,
+ 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, scanners)
+ evaluator <- ValueSome created
+
+ let toolWindow =
+ windowFactory.Create(
+ InteractiveWindowGuids.ToolWindowId,
+ instanceId,
+ VFSIstrings.SR.fsharpInteractive (),
+ created,
+ __VSCREATETOOLWIN.CTW_fForceCreate
+ )
+
+ window <- toolWindow
+
+ toolWindow.SetLanguage(
+ InteractiveWindowGuids.FSharpLanguageServiceId,
+ contentTypeRegistry.GetContentType InteractiveWindowGuids.FSharpContentTypeName
+ )
+
+ let interactiveWindow = toolWindow.InteractiveWindow
+ interactiveWindow.TextView.Closed.Add(fun _ -> (created :> IDisposable).Dispose())
+ interactiveWindow.InitializeAsync() |> ignore
+ toolWindow
+
+ member this.Open(instanceId: int, focus: bool) =
+ let toolWindow =
+ match window with
+ | null -> this.Create instanceId
+ | existing -> existing
+
+ toolWindow.Show focus
+ toolWindow
+
+ /// Send text an editor command picked up, showing the window without taking focus from the
+ /// document the user is still typing in.
+ member this.SubmitFromEditor(text: string, sourcePath: string, startLine: int) =
+ let toolWindow = this.Open(0, focus = false)
+
+ evaluator
+ |> ValueOption.iter _.SetNextSubmissionOrigin(sourcePath, startLine)
+
+ toolWindow.InteractiveWindow.SubmitAsync [| text |] |> ignore
+
+ 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..460c11d66c9
--- /dev/null
+++ b/vsintegration/src/FSharp.Interactive.Window/InteractiveHost.fs
@@ -0,0 +1,648 @@
+// 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 InteractiveHostOptions =
+ {
+ /// 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
+
+ DebugMode: bool
+ LanguageVersionPreview: bool
+ 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 =
+ 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", hostExecutable)
+
+ /// 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) =
+ let executable = hostExecutable
+
+ let rec search (directory: DirectoryInfo | null) =
+ match directory with
+ | null -> findDotnetHost ()
+ | directory ->
+ let candidate = Path.Combine(directory.FullName, ".dotnet", executable)
+
+ if File.Exists candidate then
+ candidate
+ else
+ search directory.Parent
+
+ search (DirectoryInfo(Path.GetDirectoryName fsiPath))
+
+ let private tryOverride () =
+ match Environment.GetEnvironmentVariable OverrideVariable with
+ | path when not (String.IsNullOrWhiteSpace path) && File.Exists path ->
+ if Path.GetExtension(path).Equals(".dll", StringComparison.OrdinalIgnoreCase) then
+ let host = hostFor path
+
+ if File.Exists host then
+ ValueSome(
+ Result.Ok
+ {
+ Origin = FsiOrigin.Override
+ Executable = host
+ LeadingArguments = [ "exec"; path ]
+ }
+ )
+ else
+ ValueSome(Result.Error(VFSIstrings.SR.couldNotFindFsiExe host))
+ else
+ ValueSome(
+ Result.Ok
+ {
+ Origin = FsiOrigin.Override
+ Executable = path
+ LeadingArguments = []
+ }
+ )
+ | _ -> ValueNone
+
+ 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.Ok candidate) -> Result.Ok [ candidate ]
+ | ValueSome(Result.Error message) -> Result.Error message
+ | ValueNone ->
+
+ let host = findDotnetHost ()
+
+ if File.Exists host then
+ 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(origin: FsiOrigin, session: Process, pipe: Stream, rpc: JsonRpc, initialization: InitializeResult) =
+
+ member _.Origin = origin
+ 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 voption = ValueNone
+ let mutable disposed = false
+
+ let outputReceived = Event()
+ let errorOutputReceived = Event()
+ let processExited = 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.
+ 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) =
+ // .NET Framework has no Contains overload taking a comparison, hence IndexOf.
+ if
+ argument.IndexOf(" ", StringComparison.Ordinal) >= 0
+ && not (argument.StartsWith("\"", StringComparison.Ordinal))
+ then
+ $"\"{argument}\""
+ else
+ argument
+
+ let createStartInfo (options: InteractiveHostOptions) (candidate: FsiCandidate) (pipeName: string) =
+ let arguments = ResizeArray()
+ let addSwitch (switch: string) = arguments.Add(quoteIfNeeded switch)
+
+ for argument in candidate.LeadingArguments do
+ addSwitch argument
+
+ addSwitch "--nologo"
+ addSwitch $"--fsi-server-output-codepage:{Encoding.UTF8.CodePage}"
+ addSwitch $"--fsi-server-input-codepage:{Encoding.UTF8.CodePage}"
+ addSwitch $"--fsi-server-lcid:{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.DebugMode then
+ addSwitch "--optimize-"
+ addSwitch "--debug+"
+
+ 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 = candidate.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
+
+ startInfo
+
+ /// 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
+
+ let outputLock = obj ()
+ let held = ResizeArray()
+ let mutable holding = 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
+
+ let heldText () =
+ lock outputLock (fun () -> String.Join("", held |> Seq.map (fun struct (_, text) -> text)))
+
+ let session = new Process(StartInfo = startInfo, EnableRaisingEvents = true)
+
+ // Without this a session that dies before the handshake leaves the connect below
+ // waiting forever. Attached before the start, so an exit that comes first is not missed.
+ use exitedDuringConnect = new CancellationTokenSource()
+
+ session.Exited.Add(fun _ ->
+ try
+ exitedDuringConnect.Cancel()
+ 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)
+
+ // 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)
+
+ try
+ do! pipe.ConnectAsync connectCancellation.Token
+
+ let rpc = new JsonRpc(new HeaderDelimitedMessageHandler(pipe, new JsonMessageFormatter()))
+ rpc.StartListening()
+
+ let! handshake =
+ rpc.InvokeWithCancellationAsync(Methods.Initialize, cancellationToken = cancellationToken)
+
+ let remote = RemoteSession(candidate.Origin, session, pipe, rpc, handshake)
+
+ session.Exited.Add(fun _ ->
+ let wasCurrent =
+ lock stateLock (fun () ->
+ match current with
+ | ValueSome running when obj.ReferenceEquals(running, remote) ->
+ current <- ValueNone
+ true
+ | _ -> false)
+
+ if wasCurrent then
+ processExited.Trigger(
+ try
+ session.ExitCode
+ with _ ->
+ 0
+ ))
+
+ release ()
+ return Result.Ok remote
+ with e ->
+ pipe.Dispose()
+
+ try
+ if not session.HasExited then
+ session.Kill()
+ with _ ->
+ ()
+
+ // 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 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)
+ }
+
+ /// 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
+
+ member _.ErrorOutputReceived = errorOutputReceived.Publish
+
+ /// 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))
+
+ member _.EvaluatingProcessId =
+ lock stateLock (fun () -> current |> ValueOption.map (fun session -> session.EvaluatingProcessId))
+
+ member _.Initialization =
+ lock stateLock (fun () -> current |> ValueOption.map (fun session -> session.Initialization))
+
+ member private _.TryCurrent() =
+ lock stateLock (fun () -> current |> ValueOption.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
+ | ValueSome running -> return Result.Ok running
+ | ValueNone ->
+ do! startGate.WaitAsync cancellationToken
+
+ try
+ match this.TryCurrent() with
+ | ValueSome running -> return Result.Ok running
+ | ValueNone ->
+ match! startAsync options cancellationToken with
+ | Result.Error message -> return Result.Error message
+ | Result.Ok started ->
+ let previous =
+ lock stateLock (fun () ->
+ if disposed then
+ ValueNone
+ else
+ let previous = current
+ current <- ValueSome started
+ ValueSome previous)
+
+ match previous with
+ | ValueNone ->
+ started.Dispose()
+ return Result.Error "The interactive window was closed while the session was starting."
+ | ValueSome previous ->
+ previous |> ValueOption.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 <- ValueNone
+ previous)
+
+ previous |> ValueOption.iter (fun session -> session.Dispose())
+
+ this.EnsureStartedAsync(options, ?cancellationToken = cancellationToken)
+
+ member private this.InvokeAsync(method: string, parameters: obj, cancellationToken) =
+ task {
+ match this.TryCurrent() with
+ | ValueNone -> return Result.Error "No F# Interactive session is running."
+ | ValueSome 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
+ | ValueNone -> return false
+ | ValueSome 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 <- ValueNone
+ previous)
+
+ previous |> ValueOption.iter (fun session -> session.Dispose())
+ startGate.Dispose()
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
new file mode 100644
index 00000000000..36e88076d90
--- /dev/null
+++ b/vsintegration/src/FSharp.Interactive.Window/SubmissionAnalysis.fs
@@ -0,0 +1,158 @@
+// 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
+
+/// 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 =
+
+ let private ordinalSet (items: string seq) = HashSet(items, StringComparer.Ordinal)
+
+ /// Tokens after which more input is always expected.
+ let private continuationTokens =
+ ordinalSet
+ [
+ "="
+ "->"
+ "<-"
+ ":"
+ ","
+ ";"
+ "|"
+ "||"
+ "&&"
+ "+"
+ "-"
+ "*"
+ "/"
+ "%"
+ "**"
+ "@"
+ "^"
+ "|>"
+ "<|"
+ ">>"
+ "<<"
+ "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 = ordinalSet [ "("; "["; "{"; "[|"; "[<"; "{|" ]
+
+ let private closing = ordinalSet [ ")"; "]"; "}"; "|]"; ">]"; "|}" ]
+
+ let private (|Opening|Closing|Ordinary|) token =
+ if opening.Contains token then Opening
+ elif closing.Contains token then Closing
+ else Ordinary
+
+ type private Scan =
+ {
+ /// Outside strings and comments.
+ OpenBrackets: int
+ InsideMultiLineConstruct: bool
+ LastToken: string voption
+ EndsWithTerminator: bool
+ }
+
+ let private scan (scanners: ILexicalScannerFactory) (text: string) =
+ let lines = text.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n')
+
+ let scanner = scanners.CreateScanner()
+ let tokens = ResizeArray()
+ let mutable openBrackets = 0
+ let mutable lastToken = ValueNone
+
+ for line in lines do
+ 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 = scanner.EndsInsideMultiLineConstruct
+ LastToken = lastToken
+ EndsWithTerminator =
+ match lastToken with
+ | ValueSome token -> String.Equals(token, ";;", StringComparison.Ordinal)
+ | ValueNone -> false
+ }
+
+ 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 (scanners: ILexicalScannerFactory) (text: string) =
+ if String.IsNullOrWhiteSpace text then
+ // The window submits an empty one to start a session.
+ true
+ else
+ 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 (scanners: ILexicalScannerFactory) (text: string) =
+ if endsWithTerminator scanners 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 @@
+