Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ea576d9
Add the F# Interactive window on the JSON-RPC protocol
xperiandri Aug 30, 2026
aa66b32
Put the interactive window into the VSIX
xperiandri Aug 30, 2026
4bf9615
Point the editor's interactive commands at the new window
xperiandri Aug 30, 2026
fba4db2
Let the window be pointed at an F# Interactive that speaks the protocol
xperiandri Aug 30, 2026
430d7a8
Use string interpolation instead of sprintf
xperiandri Aug 30, 2026
c97a10f
Compare strings with an explicit StringComparison
xperiandri Aug 30, 2026
76561d6
Declare the window's identities publicly and reuse the language servi…
xperiandri Aug 30, 2026
059f37e
Prefer ValueOption in the new code
xperiandri Aug 30, 2026
51dfbd3
Build the strings by interpolation
xperiandri Aug 30, 2026
10c3eaf
Finish the ValueOption conversion
xperiandri Aug 30, 2026
5261b9c
Check nullness in the interactive window
xperiandri Aug 30, 2026
2801cc0
Test for null by matching
xperiandri Aug 30, 2026
b858719
Test for null by matching in the editor command filter
xperiandri Aug 30, 2026
e128feb
Match instead of laddering through if and elif
xperiandri Aug 30, 2026
028ddb0
Name the command and bracket cases with active patterns
xperiandri Aug 30, 2026
c2527d6
Share the server option name with the hosts that pass it
xperiandri Aug 30, 2026
0df5dba
Compare platform names with an explicit StringComparison again
xperiandri Aug 30, 2026
ac974dd
Point the F5 experimental instance at the locally built fsi
xperiandri Aug 30, 2026
fbde35f
Colour the interactive window's input lexically
xperiandri Aug 30, 2026
19d3731
Colour the session's output too
xperiandri Aug 30, 2026
583c62c
Recognise the output buffer as ours
xperiandri Aug 30, 2026
6e7d767
Name the host process on the session's command line
xperiandri Sep 20, 2026
b66a5e0
Start the window's session where `dotnet fsi` would, and only on .NET
xperiandri Sep 20, 2026
39b6a99
Put the window's server switches after the user's arguments
xperiandri Sep 21, 2026
5aeed62
Leave the lexer to the editor: the window no longer compiles against …
xperiandri Sep 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .claude/rules/CcrOptout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
paths:
- "**"
---

@../../.github/instructions/CcrOptout.instructions.md
3 changes: 3 additions & 0 deletions VisualFSharp.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@
<Project Path="vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj">
<BuildType Solution="Proto|*" Project="Release" />
</Project>
<Project Path="vsintegration/src/FSharp.Interactive.Window/FSharp.Interactive.Window.fsproj">
<BuildType Solution="Proto|*" Project="Release" />
</Project>
<Project Path="vsintegration/src/FSharp.ProjectSystem.PropertyPages/FSharp.ProjectSystem.PropertyPages.vbproj">
<BuildType Solution="Proto|*" Project="Release" />
</Project>
Expand Down
296 changes: 296 additions & 0 deletions docs/ide/FSI-Modern-Interactive-Window-Plan.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/release-notes/.VisualStudio/18.vNext.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
### Added

* Code-fixes for FS3888 (compiler-semantic attribute on the `.fs` but not the `.fsi`): copy the attribute into the `.fsi`, or remove it from the `.fs`. ([Issue #19560](https://github.com/dotnet/fsharp/issues/19560), [PR #19880](https://github.com/dotnet/fsharp/pull/19880))
* The F# Interactive window is rebuilt on the Interactive Window package and talks to `dotnet fsi` over its JSON-RPC server mode: the input and output are coloured, Enter submits only a complete interaction, diagnostics carry positions in the submitted file, and the session starts in the solution folder, so the SDK that `global.json` names supplies the compiler. ([PR #20565](https://github.com/dotnet/fsharp/pull/20565))
* Expand `<inheritdoc/>` 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
Expand Down
8 changes: 8 additions & 0 deletions eng/Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@
<PackageVersion Include="Microsoft.VisualStudio.Extensibility.Sdk" Version="17.13.39620" />
<PackageVersion Include="Microsoft.VisualStudio.Extensibility.Testing.SourceGenerator" Version="$(MicrosoftVisualStudioExtensibilityTestingVersion)" />
<PackageVersion Include="Microsoft.VisualStudio.Extensibility.Testing.Xunit" Version="$(MicrosoftVisualStudioExtensibilityTestingVersion)" />
<!-- The REPL window itself. Visual Studio installs it as a prerequisite component, and both
C# Interactive and Python Interactive are built on it. -->
<PackageVersion Include="Microsoft.VisualStudio.InteractiveWindow" Version="4.0.0" />
<PackageVersion Include="Microsoft.VisualStudio.Language.Intellisense" Version="$(VisualStudioEditorPackagesVersion)" />
<PackageVersion Include="Microsoft.VisualStudio.Language.StandardClassification" Version="$(VisualStudioEditorPackagesVersion)" />
<!-- Pinned to 17.13.9 (not the projects' 17.13.6-preview floor) because Protocol.Internal 17.13.9,
Expand All @@ -88,6 +91,11 @@
<PackageVersion Include="Microsoft.VisualStudio.ProjectSystem.Managed" Version="2.3.6152103" />
<PackageVersion Include="Microsoft.VisualStudio.ProjectSystem.Query" Version="17.13.66" />
<PackageVersion Include="Microsoft.VisualStudio.Shell.Design" Version="18.9.493" />
<!-- Supplies AsyncPackage and the tool window base types. Pinned to the version the Roslyn
IDE packages already resolve here transitively. -->
<PackageVersion Include="Microsoft.VisualStudio.Shell.15.0" Version="18.9.918" />
<!-- Hosts the interactive window as a Visual Studio tool window. -->
<PackageVersion Include="Microsoft.VisualStudio.VsInteractiveWindow" Version="4.0.0" />
<PackageVersion Include="Microsoft.VisualStudio.Shell.Immutable.10.0" Version="10.0.30319" />
<PackageVersion Include="Microsoft.VisualStudio.Shell.Immutable.11.0" Version="11.0.50727" />
<PackageVersion Include="Microsoft.VisualStudio.Shell.Immutable.15.0" Version="15.0.25123-Dev15Preview" />
Expand Down
13 changes: 9 additions & 4 deletions src/fsi/interactiveProtocol.fs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ module Methods =
[<Literal>]
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 =
[<Literal>]
let ServerOption = "--fsi-server-jsonrpc:"

[<CLIMutable>]
type InitializeResult =
{
Expand Down Expand Up @@ -67,7 +72,7 @@ type ExecuteRequest =
/// with <c>startLine</c> this makes diagnostics point at the user's own source rather than
/// at a position within the submission.
/// </summary>
sourcePath: string
sourcePath: string | null

startLine: System.Nullable<int>
}
Expand All @@ -78,7 +83,7 @@ type ExecuteFileRequest = { path: string }
[<CLIMutable>]
type SetPathsRequest =
{
includePaths: string[]
includePaths: string[] | null
workingDirectory: string
}

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@
<Compile Include="$(FSharpSourcesRoot)\fsi\interactiveProtocol.fs">
<Link>interactiveProtocol.fs</Link>
</Compile>
<!-- The submission rule belongs to the Visual Studio window, but depends only on a lexer, which
the window is handed by the editor: the two are exercised together here rather than needing
a Windows-only test host. -->
<Compile Include="$(MSBuildThisFileDirectory)..\..\vsintegration\src\FSharp.Interactive.Window\LexicalScanning.fs">
<Link>LexicalScanning.fs</Link>
</Compile>
<Compile Include="$(MSBuildThisFileDirectory)..\..\vsintegration\src\FSharp.Editor\Commands\InteractiveLexicalScanner.fs">
<Link>InteractiveLexicalScanner.fs</Link>
</Compile>
<Compile Include="$(MSBuildThisFileDirectory)..\..\vsintegration\src\FSharp.Interactive.Window\SubmissionAnalysis.fs">
<Link>SubmissionAnalysis.fs</Link>
</Compile>
<Compile Include="SubmissionAnalysisTests.fs" />
<Compile Include="FsiServerHarness.fs" />
<Compile Include="FsiJsonRpcServerTests.fs" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")

[<Fact>]
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
//-------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
[
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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}>>")

[<Fact>]
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];;"

[<Fact>]
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"

[<Fact>]
let ``a line that visibly continues takes another line`` () =
assertIncomplete "let x ="
assertIncomplete "fun x ->"
assertIncomplete "1 +"
assertIncomplete "if true then"

[<Fact>]
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"

[<Fact>]
let ``closed brackets do not hold the submission open`` () =
assertComplete "printfn (\"a\")"
assertComplete "let xs = [1; 2]"
assertComplete "let xs = [| 1; 2 |]"

[<Fact>]
let ``an unterminated string or comment takes another line`` () =
assertIncomplete "let s = \"abc"
assertIncomplete "(* comment"

[<Fact>]
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\""

[<Fact>]
let ``a string literal can end a submission`` () =
// The last token here is the literal, not the '=' before it.
assertComplete "let greeting = \"hello\""

[<Fact>]
let ``a terminator inside a string is not a terminator`` () =
assertIncomplete "let s = \";;"

[<Fact>]
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"

[<Fact>]
let ``an empty submission is allowed through`` () =
// The window uses an empty submission to start a session that has gone away.
assertComplete ""
assertComplete " \n "

[<Fact>]
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\"")
2 changes: 2 additions & 0 deletions vsintegration/Vsix/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
<VSSDKTargetPlatformRegRootSuffix>$(VSRootSuffix)</VSSDKTargetPlatformRegRootSuffix>
<!-- Path to the locally built F# compiler for use during VSIX debugging -->
<FSharpCompilerPathForDebuggingLocally>$(ArtifactsDir)bin\fscAnyCpu\$(Configuration)\net472\</FSharpCompilerPathForDebuggingLocally>
<!-- Same for F# Interactive: the installed SDK's fsi does not know the JSON-RPC option yet -->
<FSharpInteractivePathForDebuggingLocally>$(ArtifactsDir)bin\fsi\$(Configuration)\net472\fsi.exe</FSharpInteractivePathForDebuggingLocally>
<CreateVsixContainer>true</CreateVsixContainer>
<PublishUrl>publish\</PublishUrl>
<Install>true</Install>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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)"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
<Action Type="Ngen" Path="FSharp.ProjectSystem.FSharp.dll" />
<Action Type="Ngen" Path="FSharp.ProjectSystem.PropertyPages.dll" />
<Action Type="Ngen" Path="FSharp.VS.FSI.dll" />
<Action Type="Ngen" Path="FSharp.Interactive.Window.dll" />
</Actions>
</Installer>
<Dependencies>
Expand All @@ -48,10 +49,12 @@
<Asset Type="Microsoft.VisualStudio.VsPackage" d:Source="File" Path="FSharp.LanguageService.Base.pkgdef" />
<Asset Type="Microsoft.VisualStudio.VsPackage" d:Source="File" Path="FSharp.LanguageService.pkgdef" />
<Asset Type="Microsoft.VisualStudio.VsPackage" d:Source="File" Path="FSharp.Editor.pkgdef" />
<Asset Type="Microsoft.VisualStudio.VsPackage" d:Source="File" Path="FSharp.Interactive.Window.pkgdef" />

<Asset Type="Microsoft.VisualStudio.MefComponent" d:Source="Project" d:ProjectName="FSharp.Editor" Path="|FSharp.Editor|" />
<Asset Type="Microsoft.VisualStudio.Analyzer" d:Source="Project" d:ProjectName="FSharp.Editor" Path="|FSharp.Editor|" />
<Asset Type="Microsoft.VisualStudio.MefComponent" d:Source="Project" d:ProjectName="ProjectSystem.Base" Path="|ProjectSystem.Base|" />
<Asset Type="Microsoft.VisualStudio.MefComponent" d:Source="Project" d:ProjectName="FSharp.Interactive.Window" Path="|FSharp.Interactive.Window|" />

<Asset Type="Microsoft.VisualStudio.ItemTemplate" d:Source="Project" Path="ItemTemplates" d:TargetPath="|AppConfig;TemplateProjectOutputGroup|" d:ProjectName="AppConfig" d:VsixSubPath="ItemTemplates" />
<Asset Type="Microsoft.VisualStudio.ItemTemplate" d:Source="Project" Path="ItemTemplates" d:TargetPath="|CodeFile;TemplateProjectOutputGroup|" d:ProjectName="CodeFile" d:VsixSubPath="ItemTemplates" />
Expand All @@ -63,6 +66,7 @@
</Assets>
<Prerequisites>
<Prerequisite Id="Microsoft.VisualStudio.Component.CoreEditor" Version="[|%CurrentProject%;GetVSGeneralVersion|,)" DisplayName="Visual Studio core editor" />
<Prerequisite Id="Microsoft.VisualStudio.InteractiveWindow" Version="[4.0.0.0,5.0.0.0)" DisplayName="Interactive Window" />
</Prerequisites>
</PackageManifest>

Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,15 @@
<Private>True</Private>
</ProjectReference>

<ProjectReference Include="$(FSharpSourcesRoot)\..\vsintegration\src\FSharp.Interactive.Window\FSharp.Interactive.Window.fsproj">
<Name>FSharp.Interactive.Window</Name>
<IncludeOutputGroupsInVSIX>BuiltProjectOutputGroup%3bGetCopyToOutputDirectoryItems%3bPkgDefProjectOutputGroup%3bSatelliteDllsProjectOutputGroup%3b</IncludeOutputGroupsInVSIX>
<IncludeOutputGroupsInVSIXLocalOnly>DebugSymbolsProjectOutputGroup%3b</IncludeOutputGroupsInVSIXLocalOnly>
<Ngen>true</Ngen>
<NgenPriority>2</NgenPriority>
<Private>True</Private>
</ProjectReference>

<ProjectReference Include="$(FSharpSourcesRoot)\..\vsintegration\src\FSharp.UIResources\FSharp.UIResources.csproj">
<Project>{c4586a06-1402-48bc-8e35-a1b8642f895b}</Project>
<Name>FSharp.UIResources</Name>
Expand Down
Loading
Loading