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/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 2357fbcdff4..5bbba9104bd 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -3,6 +3,7 @@ * Suggest active-pattern cases from unopened modules and offer valid Add Open and qualification edits. ([PR #20719](https://github.com/dotnet/fsharp/pull/20719)) * Add maintainer-requested F# Apex Visual Studio integration tests on DartLab-1ES CloudTest, using PR pipeline definitions pinned to an explicitly requested commit SHA. ([PR #20625](https://github.com/dotnet/fsharp/pull/20625)) * 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)) * Added a **Tools > Options > F# Tools > Compiler** option, **"Use the .NET SDK F# compiler for builds"**, on by default. Visual Studio uses the .NET SDK F# compiler when the project supplies SDK paths, matching `dotnet build`. Otherwise, it uses the bundled .NET Framework compiler. Turn the option off to use the bundled compiler for all projects. Changes are saved on Apply or OK; Cancel discards unsaved changes. Command-line builds are unaffected. ([Issue #20484](https://github.com/dotnet/fsharp/issues/20484), [PR #20485](https://github.com/dotnet/fsharp/pull/20485)) * F# types, modules, members and values now appear in the GitHub Copilot Chat `#` mention picker, and attach their declaration source as context. ([PR #20409](https://github.com/dotnet/fsharp/pull/20409)) @@ -22,6 +23,7 @@ * Make Alt+F1 (momentary toggle) work for inlay hints. ([PR #19421](https://github.com/dotnet/fsharp/pull/19421)) * Fix doubled F# diagnostics in tooltips. ([Issue #16360](https://github.com/dotnet/fsharp/issues/16360)) * Fix `NotSupportedException` in the memory-mapped-file optimization when copying `ReadOnlyMemory` into `MemoryMappedFileViewStream`. ([Issue #20263](https://github.com/dotnet/fsharp/issues/20263)) +* Handle unavailable document and classification information in the F# Interactive window with the updated Visual Studio SDK. ([PR #20565](https://github.com/dotnet/fsharp/pull/20565)) * Reduce allocations in the VS project options reactor: the command-line options and project options caches and the mailbox reply payloads now hold struct tuples, and `IProjectSite.CompilationBinOutputPath` returns `string voption` picked with a new `Array.tryPickV`. ([PR #20413](https://github.com/dotnet/fsharp/pull/20413)) * Build a single-file project's `OtherOptions` reference flags with one array comprehension instead of two `Array.ofSeq` calls and an `Array.append`. ([PR #20499](https://github.com/dotnet/fsharp/pull/20499)) * Keep concurrent completion requests from mixing their suggestion lists. ([PR #20719](https://github.com/dotnet/fsharp/pull/20719)) diff --git a/eng/Packages.props b/eng/Packages.props index 40d6eacd576..9df1b009c2c 100644 --- a/eng/Packages.props +++ b/eng/Packages.props @@ -76,6 +76,7 @@ + + + diff --git a/eng/Versions.props b/eng/Versions.props index 1a2c3909716..61624df7119 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -101,7 +101,7 @@ via Microsoft.CodeAnalysis.EditorFeatures / .LanguageServices.ExternalAccess 5.11.0). Full NU1109 rationale lives there, next to the 18.9.x pins. --> 18.12.46 - 0.1.800-beta + 5.6.0-2.26151.2 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..a712da4bbda 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,11 @@ + @@ -40,8 +45,41 @@ + + <_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..2058df38e2c 100644 --- a/src/fsi/interactiveProtocol.fs +++ b/src/fsi/interactiveProtocol.fs @@ -38,6 +38,10 @@ module Methods = [] let Shutdown = "fsi/shutdown" +module CommandLine = + [] + let ServerOption = "--fsi-server-jsonrpc:" + [] type InitializeResult = { @@ -67,7 +71,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 +82,7 @@ type ExecuteFileRequest = { path: string } [] type SetPathsRequest = { - includePaths: string[] + includePaths: string[] | null workingDirectory: string } @@ -123,8 +127,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..641be95627b 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,20 @@ interactiveProtocol.fs + + LexicalScanning.fs + + + InteractiveLexicalScanner.fs + + + SubmissionAnalysis.fs + + + SubmissionOrigins.fs + + + diff --git a/tests/FSharp.Compiler.Interactive.Server.Tests/FsiJsonRpcServerTests.fs b/tests/FSharp.Compiler.Interactive.Server.Tests/FsiJsonRpcServerTests.fs index 47476113f6f..8eca9f1eb6c 100644 --- a/tests/FSharp.Compiler.Interactive.Server.Tests/FsiJsonRpcServerTests.fs +++ b/tests/FSharp.Compiler.Interactive.Server.Tests/FsiJsonRpcServerTests.fs @@ -667,6 +667,22 @@ 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`` () = + 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..076ae12a8c0 --- /dev/null +++ b/tests/FSharp.Compiler.Interactive.Server.Tests/SubmissionAnalysisTests.fs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +module FSharp.Compiler.Interactive.Server.Tests.SubmissionAnalysisTests + +open Xunit + +open Microsoft.VisualStudio.FSharp.Editor +open Microsoft.VisualStudio.FSharp.Interactive + +let private scanners = FSharpLexicalScannerFactory() :> ILexicalScannerFactory + +let private scenario (completes: bool) (name: string) (texts: string list) : obj[] list = + texts |> List.map (fun text -> [| box name; box completes; box text |]) + +let private submits = scenario true +let private takesAnotherLine = scenario false + +let scenarios: obj[] seq = + [ + submits "an explicit terminator always submits, even mid-construct" [ "1 + 1;;"; "let f x =\n x + 1\n;;"; "let xs = [1; 2];;" ] + submits "a finished expression submits without a terminator" [ "1 + 1"; "printfn \"hi\""; "let x = 40"; "let f a b =\n a + b" ] + takesAnotherLine "a line that visibly continues" [ "let x ="; "fun x ->"; "1 +"; "if true then" ] + takesAnotherLine "an unclosed bracket" [ "printfn (\"a\""; "let xs = [1; 2"; "let xs = [| 1; 2"; "let r = {| A = 1" ] + submits "closed brackets do not hold the submission open" [ "printfn (\"a\")"; "let xs = [1; 2]"; "let xs = [| 1; 2 |]" ] + takesAnotherLine "an unterminated string or comment" [ "let s = \"abc"; "(* comment" ] + submits "brackets and terminators inside a string do not count" [ "let s = \"([{\""; "let s = \"a;;b\"" ] + submits "a string literal can end a submission: the last token is the literal, not the '=' before it" [ "let greeting = \"hello\"" ] + takesAnotherLine "a terminator inside a string is not a terminator" [ "let s = \";;" ] + submits "a comment does not change what the last token was" [ "let x = 1 // ([\n"; "(* c *) let x = 1" ] + takesAnotherLine "a comment does not change what the last token was" [ "let x = // nothing yet" ] + submits "an empty submission, which the window uses to start a session that has gone away" [ ""; " \n " ] + ] + |> Seq.concat + +[] +[] +let ``Enter submits only what is visibly finished`` (scenario: string) (completes: bool) (text: string) = + Assert.True((SubmissionAnalysis.isComplete scanners text = completes), $"{scenario}: <<{text}>>") + +[] +[] +[] +[] +let ``the terminator is added only when it is missing`` (text: string) (expected: string) = + Assert.Equal(expected, SubmissionAnalysis.withTerminator scanners text) diff --git a/tests/FSharp.Compiler.Interactive.Server.Tests/SubmissionOriginsTests.fs b/tests/FSharp.Compiler.Interactive.Server.Tests/SubmissionOriginsTests.fs new file mode 100644 index 00000000000..17d0efb3051 --- /dev/null +++ b/tests/FSharp.Compiler.Interactive.Server.Tests/SubmissionOriginsTests.fs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +module FSharp.Compiler.Interactive.Server.Tests.SubmissionOriginsTests + +open Xunit + +open Microsoft.VisualStudio.FSharp.Interactive + +let private originOf (path: string, line: int) = ValueSome(struct (path, line)) + +[] +let ``submissions sent in a row each take their own origin`` () = + let origins = SubmissionOrigins() + origins.Add("a", "A.fsx", 10) + origins.Add("b", "B.fsx", 20) + + Assert.Equal(originOf ("A.fsx", 10), origins.Take "a") + Assert.Equal(originOf ("B.fsx", 20), origins.Take "b") + +[] +let ``text typed into the window takes no origin and leaves the sent ones alone`` () = + let origins = SubmissionOrigins() + origins.Add("let x =\r\n 1\r\n", "A.fsx", 3) + + Assert.Equal(ValueNone, origins.Take "typed by hand") + Assert.Equal(originOf ("A.fsx", 3), origins.Take "let x =\n 1") + +[] +let ``a submission that never ran does not keep a later one from its origin`` () = + let origins = SubmissionOrigins() + origins.Add("never ran", "A.fsx", 1) + origins.Add("ran", "B.fsx", 2) + + Assert.Equal(originOf ("B.fsx", 2), origins.Take "ran") + Assert.Equal(ValueNone, origins.Take "never ran") + +[] +let ``the same text sent twice takes the origins in the order it was sent`` () = + let origins = SubmissionOrigins() + origins.Add("go", "A.fsx", 1) + origins.Add("go", "B.fsx", 2) + + Assert.Equal(originOf ("A.fsx", 1), origins.Take "go") + Assert.Equal(originOf ("B.fsx", 2), origins.Take "go") diff --git a/vsintegration/Vsix/Directory.Build.props b/vsintegration/Vsix/Directory.Build.props index d81bbf2613f..9260319bb57 100644 --- a/vsintegration/Vsix/Directory.Build.props +++ b/vsintegration/Vsix/Directory.Build.props @@ -8,6 +8,7 @@ $(VSRootSuffix) $(ArtifactsDir)bin\fscAnyCpu\$(Configuration)\net472\ + $(ArtifactsDir)bin\fsi\$(Configuration)\$(FSharpNetCoreProductTargetFramework)\fsi.dll true publish\ true diff --git a/vsintegration/Vsix/VisualFSharpFull/Properties/launchSettings.json b/vsintegration/Vsix/VisualFSharpFull/Properties/launchSettings.json index 7638dfe6832..f96bd2e9b42 100644 --- a/vsintegration/Vsix/VisualFSharpFull/Properties/launchSettings.json +++ b/vsintegration/Vsix/VisualFSharpFull/Properties/launchSettings.json @@ -12,7 +12,8 @@ "FSharpPreferAnyCpuTools": "true", "Fsc_NetFramework_ToolPath": "$(FSharpCompilerPathForDebuggingLocally)", "Fsc_NetFramework_AnyCpu_ToolExe": "fscAnyCpu.exe", - "FSHARP_OTEL_EXPORT": "" + "FSHARP_OTEL_EXPORT": "", + "FSHARP_INTERACTIVE_PATH": "$(FSharpInteractivePathForDebuggingLocally)" } }, "OTEL Export": { @@ -27,7 +28,8 @@ "FSharpPreferAnyCpuTools": "true", "Fsc_NetFramework_ToolPath": "$(FSharpCompilerPathForDebuggingLocally)", "Fsc_NetFramework_AnyCpu_ToolExe": "fscAnyCpu.exe", - "FSHARP_OTEL_EXPORT": "http://127.0.0.1:4317" + "FSHARP_OTEL_EXPORT": "http://127.0.0.1:4317", + "FSHARP_INTERACTIVE_PATH": "$(FSharpInteractivePathForDebuggingLocally)" } } } 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..4c9f00f8257 100644 --- a/vsintegration/Vsix/VisualFSharpFull/VisualFSharpDebug.csproj +++ b/vsintegration/Vsix/VisualFSharpFull/VisualFSharpDebug.csproj @@ -82,6 +82,25 @@ Tools true + + + + 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..5870a4c0f29 100644 --- a/vsintegration/src/FSharp.Editor/Commands/FsiCommandService.fs +++ b/vsintegration/src/FSharp.Editor/Commands/FsiCommandService.fs @@ -38,43 +38,18 @@ type internal FsiCommandFilter(serviceProvider: System.IServiceProvider) = interface IOleCommandTarget with member x.Exec(pguidCmdGroup, nCmdId, nCmdexecopt, pvaIn, pvaOut) = 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 +60,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..a0100d6ec81 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Commands/InteractiveLexicalScanner.fs @@ -0,0 +1,64 @@ +// 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 + +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 + let token, nextState = lineTokenizer.ScanToken state + state <- nextState + + match token with + | Some token -> + tokens.Add + { + Kind = kindOf token.ColorClass + Start = token.LeftColumn + Length = token.FullMatchedLength + } + | None -> scanning <- false + + 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..29144bcca64 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Commands/InteractiveLexicalScannerExport.fs @@ -0,0 +1,11 @@ +// 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 + +[)>] +type internal FSharpLexicalScannerFactoryExport() = + inherit FSharpLexicalScannerFactory() diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj index f5d6a8e0b04..a671aa6d421 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj @@ -103,6 +103,8 @@ + + @@ -168,11 +170,13 @@ + + diff --git a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs index ab876679a77..007b6bfddd8 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs @@ -360,8 +360,15 @@ 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 + 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..f836d7d99ea --- /dev/null +++ b/vsintegration/src/FSharp.Interactive.Window/FSharp.Interactive.Window.fsproj @@ -0,0 +1,59 @@ + + + + + + Library + true + $(OtherFlags) --subsystemversion:6.00 + 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..62633ac4598 --- /dev/null +++ b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveClassifier.fs @@ -0,0 +1,121 @@ +// 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.Language.StandardClassification +open Microsoft.VisualStudio.Text +open Microsoft.VisualStudio.Text.Classification +open Microsoft.VisualStudio.Utilities + +type internal FSharpInteractiveClassifier + (buffer: ITextBuffer, registry: IClassificationTypeRegistryService, scanners: ILexicalScannerFactory, carriesStateAcrossLines: bool) = + + let classificationFor kind = + match kind with + | LexicalKind.Keyword -> ValueSome PredefinedClassificationTypeNames.Keyword + | LexicalKind.Comment -> ValueSome PredefinedClassificationTypeNames.Comment + | LexicalKind.String -> ValueSome PredefinedClassificationTypeNames.String + | LexicalKind.Number -> ValueSome PredefinedClassificationTypeNames.Number + | LexicalKind.Operator -> ValueSome PredefinedClassificationTypeNames.Operator + | LexicalKind.Identifier -> ValueSome PredefinedClassificationTypeNames.Identifier + | LexicalKind.PreprocessorKeyword -> ValueSome PredefinedClassificationTypeNames.PreprocessorKeyword + | LexicalKind.InactiveCode -> ValueSome PredefinedClassificationTypeNames.ExcludedCode + | LexicalKind.Other -> ValueNone + |> ValueOption.bind (fun name -> + match registry.GetClassificationType name with + | null -> ValueNone + | classification -> ValueSome classification) + + let mutable ownershipConfirmed = false + + let isOurs () = + if not ownershipConfirmed then + ownershipConfirmed <- + match InteractiveWindowExtensions.GetInteractiveWindow buffer with + | null -> FSharpInteractiveWindows.ownsOutputBuffer buffer + | window -> window.Evaluator :? FSharpInteractiveEvaluator + + ownershipConfirmed + + let changed = + Event, ClassificationChangedEventArgs>() + + 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 result = List() + + if isOurs () then + let snapshot = span.Snapshot + + 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.IsWithin text then + let tokenSpan = + SnapshotSpan(snapshot, line.Start.Position + token.Start, token.Length) + + if tokenSpan.IntersectsWith span then + classificationFor token.Kind + |> ValueOption.iter (fun classification -> result.Add(ClassificationSpan(tokenSpan, classification))) + + result :> IList<_> + +[)>] +[] +type internal FSharpInteractiveInputClassifierProvider + [] + (registry: IClassificationTypeRegistryService, scanners: ILexicalScannerFactory) = + + interface IClassifierProvider with + member _.GetClassifier buffer = + buffer.Properties.GetOrCreateSingletonProperty(fun () -> FSharpInteractiveClassifier(buffer, registry, scanners, carriesStateAcrossLines = true)) + +[)>] +[] +type internal FSharpInteractiveOutputClassifierProvider + [] + (registry: IClassificationTypeRegistryService, scanners: ILexicalScannerFactory) = + + interface IClassifierProvider with + member _.GetClassifier buffer = + buffer.Properties.GetOrCreateSingletonProperty(fun () -> FSharpInteractiveClassifier(buffer, registry, scanners, carriesStateAcrossLines = 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..a64eaa1c43d --- /dev/null +++ b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveCommandFilter.fs @@ -0,0 +1,146 @@ +// 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 + +type internal SendToInteractiveKind = + | Selection + | Line + + member kind.Status = + match kind with + | Selection -> OLECMDF.OLECMDF_SUPPORTED ||| OLECMDF.OLECMDF_ENABLED + | Line -> + OLECMDF.OLECMDF_SUPPORTED + ||| OLECMDF.OLECMDF_ENABLED + ||| OLECMDF.OLECMDF_DEFHIDEONCTXTMENU + +[] +module private InteractiveCommand = + + [] + let (|SendToInteractive|_|) (group: Guid, commandId: uint32) = + match group = VSConstants.VsStd11, enum (int commandId) with + | true, VSConstants.VSStd11CmdID.ExecuteSelectionInInteractive -> ValueSome Selection + | true, VSConstants.VSStd11CmdID.ExecuteLineInInteractive -> ValueSome Line + | _ -> ValueNone + +type internal EditorSubmission = + { + Text: string + SourcePath: string + OneBasedStartLine: int + } + +module internal EditorSubmission = + + let readAndAdvanceCaret (documentFactory: ITextDocumentFactoryService) (view: ITextView) (kind: SendToInteractiveKind) = + let selection = view.Selection + let sendsLine = kind.IsLine || selection.IsEmpty + + let span = + if sendsLine then + view.Caret.Position.BufferPosition.GetContainingLine().Extent + else + SnapshotSpan(selection.Start.Position, selection.End.Position) + + let text = span.GetText() + + if String.IsNullOrWhiteSpace text then + ValueNone + else + let sourcePath = + match documentFactory.TryGetTextDocument view.TextBuffer with + | true, document -> + match document with + | null -> "" + | document -> document.FilePath + | _ -> "" + + let snapshot = view.TextSnapshot + let line = span.Start.GetContainingLine().LineNumber + + if sendsLine && line + 1 < snapshot.LineCount then + view.Caret.MoveTo(snapshot.GetLineFromLineNumber(line + 1).Start) |> ignore + selection.Clear() + + ValueSome + { + Text = text + SourcePath = sourcePath + OneBasedStartLine = line + 1 + } + +type internal FSharpInteractiveCommandFilter + (provider: FSharpVsInteractiveWindowProvider, documentFactory: ITextDocumentFactoryService, view: ITextView) = + + let mutable nextTarget: IOleCommandTarget | null = null + + member this.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 + | SendToInteractive kind -> + EditorSubmission.readAndAdvanceCaret documentFactory view kind + |> ValueOption.iter (fun submission -> + provider.SubmitFromEditor(submission.Text, submission.SourcePath, submission.OneBasedStartLine)) + + VSConstants.S_OK + | _ -> + match nextTarget with + | null -> VSConstants.E_FAIL + | target -> target.Exec(&pguidCmdGroup, nCmdId, nCmdexecopt, pvaIn, pvaOut) + + member _.QueryStatus(pguidCmdGroup, cCmds, prgCmds, pCmdText) = + let group = pguidCmdGroup + + let statuses = + Array.init (int cCmds) (fun i -> + match group, prgCmds[i].cmdID with + | SendToInteractive kind -> ValueSome kind.Status + | _ -> ValueNone) + + let result = + if statuses.Length > 0 && statuses |> Array.forall _.IsSome then + VSConstants.S_OK + else + match nextTarget with + | null -> VSConstants.E_FAIL + | target -> target.QueryStatus(&pguidCmdGroup, cCmds, prgCmds, pCmdText) + + statuses + |> Array.iteri (fun i status -> status |> ValueOption.iter (fun flags -> prgCmds[i].cmdf <- uint32 flags)) + + result + +[)>] +[] +[] +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..96126d54c81 --- /dev/null +++ b/vsintegration/src/FSharp.Interactive.Window/FSharpInteractiveEvaluator.fs @@ -0,0 +1,167 @@ +// 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.IO +open System.Threading.Tasks + +open Microsoft.VisualStudio.InteractiveWindow +open Microsoft.VisualStudio.Text + +/// The window package tags its input buffers with the window, but not its output buffer. +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}" + + let formatSessionStart (session: RemoteSession) = + let handshake = session.Initialization + $"F# Interactive {handshake.fsiVersion} from {session.Origin.Description} on {handshake.frameworkDescription}, in {handshake.workingDirectory}" + +[] +type internal FSharpInteractiveEvaluator + (host: InteractiveHostClient, getOptions: unit -> InteractiveHostOptions, scanners: ILexicalScannerFactory) = + + let mutable currentWindow: IInteractiveWindow | null = null + let mutable subscriptions: IDisposable list = [] + let mutable disposed = false + + let origins = SubmissionOrigins() + + let output () = + match currentWindow with + | null -> TextWriter.Null + | window -> window.OutputWriter + + let errorOutput () = + match currentWindow with + | null -> TextWriter.Null + | window -> window.ErrorOutputWriter + + let writeErrorLine (text: string) = errorOutput().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 startedAsync (start: InteractiveHostOptions -> Task>) = + task { + match! start (getOptions ()) with + | Result.Ok _ -> return ExecutionResult true + | Result.Error message -> + writeErrorLine message + return ExecutionResult false + } + + let unsubscribe () = + for subscription in subscriptions do + subscription.Dispose() + + subscriptions <- [] + + member _.AddSubmissionOrigin(text: string, sourcePath: string, startLine: int) = + if not (String.IsNullOrEmpty sourcePath) then + origins.Add(text, sourcePath, startLine) + + interface IInteractiveEvaluator with + + member _.CurrentWindow + with get () = currentWindow + and set window = + currentWindow <- window + unsubscribe () + + match window with + | null -> () + | window -> + FSharpInteractiveWindows.add window + + subscriptions <- + [ + host.OutputReceived.Subscribe(fun text -> output().Write text) + host.ErrorOutputReceived.Subscribe(fun text -> errorOutput().Write text) + host.SessionExitedUnexpectedly.Subscribe(fun exitCode -> + writeErrorLine $"{VFSIstrings.SR.sessionTerminationDetected ()} (exit code {exitCode})") + host.SessionStarted.Subscribe(fun session -> output().WriteLine(ResultRendering.formatSessionStart session)) + ] + + member _.InitializeAsync() = + startedAsync (fun options -> host.EnsureStartedAsync options) + + member _.ResetAsync(_initialize) = + startedAsync (fun options -> host.ResetAsync options) + + member _.CanExecuteCode(text) = + SubmissionAnalysis.isComplete scanners text + + member _.ExecuteCodeAsync(text) = + task { + let origin = origins.Take text + + let! started = startedAsync (fun options -> host.EnsureStartedAsync options) + + if not started.IsSuccessful || String.IsNullOrWhiteSpace text then + return started + else + match! host.ExecuteAsync(SubmissionAnalysis.withTerminator scanners text, origin) 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 () + (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..c387f521b27 --- /dev/null +++ b/vsintegration/src/FSharp.Interactive.Window/FSharpVsInteractiveWindowPackage.fs @@ -0,0 +1,43 @@ +// 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 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" + + [] + let OutputWindowIdString = "34E76E81-EE4A-11D0-AE2E-00A0C90FFFC3" + +[] +[] +[] +type internal FSharpVsInteractiveWindowPackage() = + inherit AsyncPackage() + + interface IVsToolWindowFactory with + + member this.CreateToolWindow(toolWindowType: byref, id: uint32) = + if toolWindowType <> InteractiveWindowGuids.ToolWindowId then + VSConstants.E_FAIL + else + match this.GetService typeof with + | :? IComponentModel as components -> + components.DefaultExportProvider.GetExportedValue().Create(int id) + |> ignore + + VSConstants.S_OK + | _ -> 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..18ddf6f543c --- /dev/null +++ b/vsintegration/src/FSharp.Interactive.Window/FSharpVsInteractiveWindowProvider.fs @@ -0,0 +1,121 @@ +// 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 + +module InteractiveWindowGuids = + + /// Persisted in users' layouts: never change. + [] + let ToolWindowIdString = "6B0F0D9E-1B4A-4C4E-9E2D-6F3B2A5C7D18" + + let ToolWindowId = Guid ToolWindowIdString + + let FSharpLanguageServiceId = Guids.guidFsharpLanguageService + + /// Must equal FSharpConstants.FSharpContentTypeName (FSharp.Editor). + [] + let FSharpContentTypeName = "F#" + +module internal InteractiveHostOptionsFactory = + + let private solutionDirectory () = + ThreadHelper.ThrowIfNotOnUIThread() + + try + match ServiceProvider.GlobalProvider.GetService typeof with + | :? IVsSolution as solution -> + match solution.GetSolutionInfo() with + | hr, directory, _, _ when ErrorHandler.Succeeded hr && Directory.Exists directory -> ValueSome directory + | _ -> ValueNone + | _ -> ValueNone + with _ -> + ValueNone + + let create () = + { + InitialWorkingDirectory = + solutionDirectory () + |> ValueOption.defaultWith (fun () -> Environment.GetFolderPath Environment.SpecialFolder.UserProfile) + UserArguments = SessionsProperties.fsiArgs + DebugMode = SessionsProperties.fsiDebugMode + LanguageVersionPreview = SessionsProperties.fsiPreview + UICultureLcid = Thread.CurrentThread.CurrentUICulture.LCID + } + +[)>] +[] +type internal FSharpVsInteractiveWindowProvider + [] + (windowFactory: IVsInteractiveWindowFactory, contentTypeRegistry: IContentTypeRegistryService, scanners: ILexicalScannerFactory) = + + let mutable window: IVsInteractiveWindow | null = null + let mutable evaluator: FSharpInteractiveEvaluator voption = ValueNone + + member _.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() + + if obj.ReferenceEquals(window, toolWindow) then + window <- null + + match evaluator with + | ValueSome current when obj.ReferenceEquals(current, created) -> evaluator <- ValueNone + | _ -> ()) + + 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 + + member this.SubmitFromEditor(text: string, sourcePath: string, startLine: int) = + let toolWindow = this.Open(0, focus = false) + + evaluator |> ValueOption.iter _.AddSubmissionOrigin(text, sourcePath, startLine) + + toolWindow.InteractiveWindow.SubmitAsync [| text |] |> ignore diff --git a/vsintegration/src/FSharp.Interactive.Window/InteractiveHost.fs b/vsintegration/src/FSharp.Interactive.Window/InteractiveHost.fs new file mode 100644 index 00000000000..c1d90f8e9ee --- /dev/null +++ b/vsintegration/src/FSharp.Interactive.Window/InteractiveHost.fs @@ -0,0 +1,525 @@ +// 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.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 = + { + /// `dotnet fsi` takes its SDK from the `global.json` nearest this folder. + InitialWorkingDirectory: string + UserArguments: string + DebugMode: bool + LanguageVersionPreview: bool + UICultureLcid: int + } + +[] +type internal FsiOrigin = + | Override + | Sdk + | VisualStudio + + static member OverrideVariable = "FSHARP_INTERACTIVE_PATH" + + member this.Description = + match this with + | Override -> FsiOrigin.OverrideVariable + | Sdk -> ".NET SDK" + | VisualStudio -> "Visual Studio" + +type internal FsiCandidate = + { + Origin: FsiOrigin + Executable: string + LeadingArguments: string list + } + + member this.DescribeEarlyExit(output: string, exitCode: int) = + if output.IndexOf("install or update .NET", StringComparison.Ordinal) >= 0 then + $"The F# Interactive from {this.Origin.Description} needs a .NET runtime that is not installed; see the message above." + else + let commandLine = String.Join(" ", this.Executable :: this.LeadingArguments) + + $"The F# Interactive from {this.Origin.Description} ({commandLine}) exited with code {exitCode} before the session was established." + +module internal FsiLocator = + + let private hostExecutable = "dotnet.exe" + + let private hostsOnSearchPath () = + match Environment.GetEnvironmentVariable "PATH" with + | null -> Seq.empty + | searchPath -> + searchPath.Split([| Path.PathSeparator |], StringSplitOptions.RemoveEmptyEntries) + |> Seq.choose (fun directory -> + try + Some(Path.Combine(directory.Trim(' ', '"'), hostExecutable)) + with :? ArgumentException -> + None) + + let private machineWideHost () = + let programFiles = + match Environment.GetEnvironmentVariable "ProgramW6432" with + | path when not (String.IsNullOrEmpty path) -> path + | _ -> Environment.GetFolderPath Environment.SpecialFolder.ProgramFiles + + Path.Combine(programFiles, "dotnet", hostExecutable) + + let private findDotnetHost () = + seq { + Environment.GetEnvironmentVariable "DOTNET_HOST_PATH" + yield! hostsOnSearchPath () + } + |> Seq.tryFind File.Exists + |> Option.defaultWith machineWideHost + + /// Laid out by Microsoft.FSharp.Compiler.MSBuild.csproj and VisualFSharpDebug.csproj. + let private bundledRelativePath = Path.Combine("Tools", "Interactive", "fsi.dll") + + let private repositoryHostOrDefault (fsiPath: string) = + let rec search (directory: DirectoryInfo | null) = + match directory with + | null -> findDotnetHost () + | directory -> + let candidate = Path.Combine(directory.FullName, ".dotnet", hostExecutable) + + if File.Exists candidate then + candidate + else + search directory.Parent + + search (DirectoryInfo(Path.GetDirectoryName fsiPath)) + + let private candidate origin (executable: string) leadingArguments = + { + Origin = origin + Executable = executable + LeadingArguments = leadingArguments + } + + let private onHost origin (host: string) leadingArguments = + if File.Exists host then + Result.Ok(candidate origin host leadingArguments) + else + Result.Error(VFSIstrings.SR.couldNotFindFsiExe host) + + let private tryOverride () = + match Environment.GetEnvironmentVariable FsiOrigin.OverrideVariable with + | path when not (String.IsNullOrWhiteSpace path) && File.Exists path -> + if Path.GetExtension(path).Equals(".dll", StringComparison.OrdinalIgnoreCase) then + ValueSome(onHost FsiOrigin.Override (repositoryHostOrDefault path) [ "exec"; path ]) + else + ValueSome(Result.Ok(candidate FsiOrigin.Override path [])) + | _ -> 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(candidate FsiOrigin.VisualStudio host [ "exec"; fsi ]) + else + ValueNone + + let candidates () = + match tryOverride () with + | ValueSome overridden -> overridden |> Result.map List.singleton + | ValueNone -> + let host = findDotnetHost () + + onHost FsiOrigin.Sdk host [ "fsi" ] + |> Result.map (fun sdk -> [ sdk; yield! tryBundled host |> ValueOption.toList ]) + +module private SessionProcess = + + let quietly (action: unit -> unit) = + try + action () + with _ -> + () + + let kill (session: Process) = + quietly (fun () -> + if not session.HasExited then + session.Kill()) + + let pumpDrainMilliseconds = 200 + + let pumpWithoutWaitingForNewline (reader: StreamReader) (report: string -> unit) = + let read () = + let buffer = Array.zeroCreate 1024 + let mutable count = reader.Read(buffer, 0, buffer.Length) + + while count > 0 do + report (String(buffer, 0, count)) + count <- reader.Read(buffer, 0, buffer.Length) + + Thread((fun () -> quietly read), IsBackground = true).Start() + + let private quoteIfNeeded (argument: string) = + if + argument.IndexOf ' ' >= 0 + && not (argument.StartsWith("\"", StringComparison.Ordinal)) + then + $"\"{argument}\"" + else + argument + + let startInfo (options: InteractiveHostOptions) (candidate: FsiCandidate) (pipeName: string) (clientProcessId: int) = + let arguments = + [ + yield! candidate.LeadingArguments |> List.map quoteIfNeeded + "--nologo" + $"--fsi-server-output-codepage:{Encoding.UTF8.CodePage}" + $"--fsi-server-input-codepage:{Encoding.UTF8.CodePage}" + $"--fsi-server-lcid:{options.UICultureLcid}" + + if not (String.IsNullOrWhiteSpace options.UserArguments) then + options.UserArguments.Trim() + + if options.DebugMode then + "--optimize-" + "--debug+" + + if options.LanguageVersionPreview then + "--langversion:preview" + + // After the user's arguments: fsi takes the last occurrence of a --fsi-server-* switch. + $"{CommandLine.ServerOption}{pipeName}" + $"--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 + +[] +type internal RemoteSession(origin: FsiOrigin, session: Process, pipe: Stream, rpc: JsonRpc, initialization: InitializeResult) = + + member _.Origin = origin + member _.Rpc = rpc + member _.Initialization = initialization + + member _.ExitCode = + try + session.ExitCode + with _ -> + 0 + + member _.IsAlive = + try + not session.HasExited + with _ -> + false + + member _.Dispose() = + SessionProcess.quietly rpc.Dispose + SessionProcess.quietly pipe.Dispose + SessionProcess.kill session + SessionProcess.quietly session.Dispose + +[] +type private OutputHeldUntilHandshake(forward: bool -> string -> unit) = + + let held = ResizeArray() + let mutable holding = true + + member _.Report (isError: bool) (text: string) = + let passes = + lock held (fun () -> + if holding then + held.Add(struct (isError, text)) + + not holding) + + if passes then + forward isError text + + member _.Release() = + let pending = + lock held (fun () -> + holding <- false + let pending = held.ToArray() + held.Clear() + pending) + + for struct (isError, text) in pending do + forward isError text + + member _.Text = + lock held (fun () -> String.Join("", held |> Seq.map (fun struct (_, text) -> text))) + +[] +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 sessionExitedUnexpectedly = Event() + let sessionStarted = Event() + + let lastWorkingOrigin = Dictionary(StringComparer.OrdinalIgnoreCase) + + let forward isError (text: string) = + if isError then + errorOutputReceived.Trigger text + else + outputReceived.Trigger text + + let tryCurrent () = + lock stateLock (fun () -> current |> ValueOption.filter _.IsAlive) + + let takeCurrent () = + lock stateLock (fun () -> + let previous = current + current <- ValueNone + previous) + + let reportExit (remote: RemoteSession) = + let wasCurrent = + lock stateLock (fun () -> + match current with + | ValueSome running when obj.ReferenceEquals(running, remote) -> + current <- ValueNone + true + | _ -> false) + + if wasCurrent then + sessionExitedUnexpectedly.Trigger remote.ExitCode + + let startAttemptAsync (options: InteractiveHostOptions) (candidate: FsiCandidate) (cancellationToken: CancellationToken) = + task { + let unguessablePipeName = $"FSharpInteractive.{Guid.NewGuid():N}" + let output = OutputHeldUntilHandshake forward + + let session = + new Process(StartInfo = SessionProcess.startInfo options candidate unguessablePipeName clientProcessId, EnableRaisingEvents = true) + + // Before Start: Exited is not raised for handlers added after the exit. + use exitedDuringConnect = new CancellationTokenSource() + session.Exited.Add(fun _ -> SessionProcess.quietly exitedDuringConnect.Cancel) + + let started = + try + session.Start() + with _ -> + false + + if not started then + return Result.Error(false, VFSIstrings.SR.couldNotFindFsiExe candidate.Executable, ignore) + else + SessionProcess.pumpWithoutWaitingForNewline session.StandardOutput (output.Report false) + SessionProcess.pumpWithoutWaitingForNewline session.StandardError (output.Report true) + + use connectCancellation = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, exitedDuringConnect.Token) + + // No CurrentUserOnly on net472: the unguessable pipe name is the only guard. + let pipe = + new NamedPipeClientStream(".", unguessablePipeName, 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 _ -> reportExit remote) + output.Release() + return Result.Ok remote + with e -> + pipe.Dispose() + SessionProcess.kill session + + let exitedEarly = session.HasExited + + if exitedEarly then + do! Task.Delay SessionProcess.pumpDrainMilliseconds + + let detail = + if exitedEarly then + candidate.DescribeEarlyExit(output.Text, session.ExitCode) + else + e.Message + + return Result.Error(exitedEarly, detail, output.Release) + } + + 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 mutable remaining = + match lock lastWorkingOrigin (fun () -> lastWorkingOrigin.TryGetValue key) with + | true, origin -> candidates |> List.sortBy (fun candidate -> candidate.Origin <> origin) + | _ -> candidates + + // A loop because `let rec` inside `task` is FS3511. + let failures = ResizeArray() + let mutable started = ValueNone + + while started.IsNone && not remaining.IsEmpty do + let candidate = remaining.Head + remaining <- remaining.Tail + + match! startAttemptAsync options candidate cancellationToken with + | Result.Ok remote -> + lock lastWorkingOrigin (fun () -> lastWorkingOrigin[key] <- candidate.Origin) + sessionStarted.Trigger remote + started <- ValueSome remote + | Result.Error(exitedEarly, detail, release) -> + lock lastWorkingOrigin (fun () -> lastWorkingOrigin.Remove key |> ignore) + failures.Add detail + + if not exitedEarly || remaining.IsEmpty then + release () + remaining <- [] + + return + match started with + | ValueSome remote -> Result.Ok remote + | ValueNone -> Result.Error(String.Join(Environment.NewLine, failures)) + } + + let installUnlessExited (started: RemoteSession) = + let struct (previous, failure) = + lock stateLock (fun () -> + if disposed then + struct (ValueNone, ValueSome "The interactive window was closed while the session was starting.") + else + let previous = takeCurrent () + + if started.IsAlive then + current <- ValueSome started + struct (previous, ValueNone) + else + struct (previous, ValueSome $"F# Interactive exited with code {started.ExitCode} as the session started.")) + + previous |> ValueOption.iter _.Dispose() + + match failure with + | ValueNone -> Result.Ok started + | ValueSome message -> + started.Dispose() + Result.Error message + + member _.OutputReceived = outputReceived.Publish + + member _.ErrorOutputReceived = errorOutputReceived.Publish + + member _.SessionExitedUnexpectedly = sessionExitedUnexpectedly.Publish + + member _.SessionStarted = sessionStarted.Publish + + member _.EnsureStartedAsync(options, ?cancellationToken) : Task> = + let cancellationToken = defaultArg cancellationToken CancellationToken.None + + task { + match tryCurrent () with + | ValueSome running -> return Result.Ok running + | ValueNone -> + do! startGate.WaitAsync cancellationToken + + try + match tryCurrent () with + | ValueSome running -> return Result.Ok running + | ValueNone -> + let! outcome = startAsync options cancellationToken + return outcome |> Result.bind installUnlessExited + finally + startGate.Release() |> ignore + } + + member this.ResetAsync(options, ?cancellationToken) = + takeCurrent () |> ValueOption.iter _.Dispose() + this.EnsureStartedAsync(options, ?cancellationToken = cancellationToken) + + member _.ExecuteAsync(code: string, origin: struct (string * int) voption, ?cancellationToken) = + let request = + { + code = code + sourcePath = origin |> ValueOption.map (fun struct (path, _) -> path) |> ValueOption.toObj + startLine = + origin + |> ValueOption.map (fun struct (_, line) -> line) + |> ValueOption.toNullable + } + + task { + match tryCurrent () with + | ValueNone -> return Result.Error "No F# Interactive session is running." + | ValueSome session -> + try + let! result = + session.Rpc.InvokeWithParameterObjectAsync( + Methods.Execute, + request, + defaultArg cancellationToken CancellationToken.None + ) + + return Result.Ok result + with e -> + return Result.Error e.Message + } + + member _.InterruptAsync() = + task { + match 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() = + lock stateLock (fun () -> + disposed <- true + takeCurrent ()) + |> ValueOption.iter _.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..c7874a569dc --- /dev/null +++ b/vsintegration/src/FSharp.Interactive.Window/LexicalScanning.fs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Interactive + +[] +type internal LexicalKind = + | Keyword + | Comment + | String + | Number + | Operator + | Identifier + | PreprocessorKeyword + | InactiveCode + | Other + +[] +type internal LexicalToken = + { + Kind: LexicalKind + Start: int + Length: int + } + + member token.IsWithin(line: string) = + token.Start >= 0 && token.Start + token.Length <= line.Length + +type internal ILexicalScanner = + + abstract ScanLine: line: string * tokens: ResizeArray -> unit + + abstract EndsInsideMultiLineConstruct: bool + +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..40fc4b4d29b --- /dev/null +++ b/vsintegration/src/FSharp.Interactive.Window/SubmissionAnalysis.fs @@ -0,0 +1,92 @@ +// 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 + +module internal SubmissionAnalysis = + + let private ordinalSet (items: string seq) = + HashSet(items, StringComparer.Ordinal) + + let private continuationTokens = + [ + "= -> <- : , ; | || && + - * / % ** @ ^ |> <| >> << ->>" + "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" + ] + |> Seq.collect _.Split(' ') + |> ordinalSet + + let private opening = ordinalSet [ "("; "["; "{"; "[|"; "[<"; "{|" ] + + let private closing = ordinalSet [ ")"; "]"; "}"; "|]"; ">]"; "|}" ] + + let private literalPlaceholder = "\"\"" + + let private (|Opening|Closing|Ordinary|) token = + if opening.Contains token then Opening + elif closing.Contains token then Closing + else Ordinary + + type private Scan = + { + OpenBrackets: int + InsideMultiLineConstruct: bool + LastToken: string voption + } + + member scanned.EndsWithTerminator = + scanned.LastToken + |> ValueOption.exists (fun token -> String.Equals(token, ";;", StringComparison.Ordinal)) + + let private scan (scanners: ILexicalScannerFactory) (text: string) = + let scanner = scanners.CreateScanner() + let tokens = ResizeArray() + let mutable openBrackets = 0 + let mutable lastToken = ValueNone + + for line in text.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n') do + tokens.Clear() + scanner.ScanLine(line, tokens) + + for token in tokens do + match token.Kind with + | LexicalKind.Comment + | LexicalKind.InactiveCode -> () + | LexicalKind.String -> lastToken <- ValueSome literalPlaceholder + | _ when token.IsWithin line -> + let value = line.Substring(token.Start, token.Length) + + 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 + } + + let isComplete (scanners: ILexicalScannerFactory) (text: string) = + if String.IsNullOrWhiteSpace text then + true + else + match scan scanners text with + | scanned when scanned.EndsWithTerminator -> 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 (scan scanners text).EndsWithTerminator then + text + else + $"{text}\n;;" diff --git a/vsintegration/src/FSharp.Interactive.Window/SubmissionOrigins.fs b/vsintegration/src/FSharp.Interactive.Window/SubmissionOrigins.fs new file mode 100644 index 00000000000..ba9f0998a7a --- /dev/null +++ b/vsintegration/src/FSharp.Interactive.Window/SubmissionOrigins.fs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Interactive + +open System + +[] +type internal SubmissionOrigins() = + + let waiting = ResizeArray() + + static let normalized (text: string) = text.Replace("\r\n", "\n").Trim() + + member _.Add(text: string, sourcePath: string, startLine: int) = + lock waiting (fun () -> waiting.Add(struct (normalized text, sourcePath, startLine))) + + /// Drops the origins of submissions sent before this one that never ran. + member _.Take(text: string) : struct (string * int) voption = + let key = normalized text + + lock waiting (fun () -> + match waiting.FindIndex(fun (struct (queued, _, _)) -> String.Equals(queued, key, StringComparison.Ordinal)) with + | -1 -> ValueNone + | index -> + let struct (_, sourcePath, startLine) = waiting[index] + waiting.RemoveRange(0, index + 1) + ValueSome(struct (sourcePath, startLine))) 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 @@ + diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/ShellInProcess_Debugging.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/ShellInProcess_Debugging.cs index 2dac220006f..1286541583a 100644 --- a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/ShellInProcess_Debugging.cs +++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/ShellInProcess_Debugging.cs @@ -8,6 +8,7 @@ using System.IO; using System.Threading; using System.Threading.Tasks; +using Microsoft.VisualStudio.Shell; using Microsoft.VisualStudio.Shell.Interop; namespace Microsoft.VisualStudio.Extensibility.Testing; @@ -117,6 +118,7 @@ public async Task GetConfiguredBreakpointLinesAsync(CancellationToken can private static string BreakpointSummary(EnvDTE.Debugger dbg) { + ThreadHelper.ThrowIfNotOnUIThread(); var items = new List(); foreach (EnvDTE.Breakpoint bp in dbg.Breakpoints) { diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess.cs index 7b330eddc6f..957e4295d1b 100644 --- a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess.cs +++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess.cs @@ -212,16 +212,14 @@ private async Task> WaitForBuildToFinishAsync(IVsOutputWindo await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); var buildManager = await GetRequiredGlobalServiceAsync(cancellationToken); - using var semaphore = new SemaphoreSlim(1); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var solutionEvents = new UpdateSolutionEvents(buildManager); - await semaphore.WaitAsync(); - - void HandleUpdateSolutionDone(bool succeeded, bool modified, bool canceled) => semaphore.Release(); + void HandleUpdateSolutionDone(bool succeeded, bool modified, bool canceled) => completion.TrySetResult(succeeded); solutionEvents.OnUpdateSolutionDone += HandleUpdateSolutionDone; try { - await semaphore.WaitAsync(); + await completion.Task.WithCancellation(cancellationToken); } finally { diff --git a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess_Files.cs b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess_Files.cs index f639decdb82..e64f373c394 100644 --- a/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess_Files.cs +++ b/vsintegration/tests/FSharp.Editor.IntegrationTests/InProcess/SolutionExplorerInProcess_Files.cs @@ -66,7 +66,11 @@ private async Task GetAbsolutePathForProjectRelativeFilePathAsync(string var dte = await GetRequiredGlobalServiceAsync(cancellationToken); var solution = dte.Solution; - var project = solution.Projects.Cast().First(x => x.Name == projectName); + var project = solution.Projects.Cast().First(x => + { + ThreadHelper.ThrowIfNotOnUIThread(); + return x.Name == projectName; + }); var projectPath = Path.GetDirectoryName(project.FullName); return Path.Combine(projectPath, relativeFilePath); } @@ -77,11 +81,19 @@ private async Task GetAbsolutePathForProjectRelativeFilePathAsync(string var solution = (await GetRequiredGlobalServiceAsync(cancellationToken)).Solution; var projects = solution.Projects.Cast(); - var project = projects.FirstOrDefault(x => x.Name == projectName); + var project = projects.FirstOrDefault(x => + { + ThreadHelper.ThrowIfNotOnUIThread(); + return x.Name == projectName; + }); var projectPath = Path.GetDirectoryName(project.FullName); var fullFilePath = Path.Combine(projectPath, relativeFilePath); var projectItems = project.ProjectItems.Cast(); - var document = projectItems.FirstOrDefault(d => d.get_FileNames(1).Equals(fullFilePath)); + var document = projectItems.FirstOrDefault(d => + { + ThreadHelper.ThrowIfNotOnUIThread(); + return d.get_FileNames(1).Equals(fullFilePath); + }); return document; }