diff --git a/release/package.json b/release/package.json index 41ae1496..72b6462c 100644 --- a/release/package.json +++ b/release/package.json @@ -614,6 +614,11 @@ "markdownDescription": "An array of additional command line parameters to pass to FSI when it is started. See [the Microsoft documentation](https://docs.microsoft.com/en-us/dotnet/fsharp/language-reference/fsharp-interactive-options) for an exhaustive list.", "type": "array" }, + "FSharp.fsiUsesFSACDirectoryForCompilerTool": { + "default": false, + "markdownDescription": "EXPERIMENTAL: adds an --compiltertool:/path/to/FSAC argument to the FSI parameters so the extensions that FSAC ships are conviniently loaded in the FSI session", + "type": "bool" + }, "FSharp.fsiSdkFilePath": { "default": "", "description": "The path to the F# Interactive tool used by Ionide-FSharp (When using .NET SDK scripts)", diff --git a/src/Components/Fsi.fs b/src/Components/Fsi.fs index eb93f739..e9109878 100644 --- a/src/Components/Fsi.fs +++ b/src/Components/Fsi.fs @@ -386,8 +386,60 @@ module Fsi = match dotnet with | Ok dotnet -> let! fsiSetting = LanguageService.fsiSdk () + let! (_, fsacPath) = LanguageService.SdkResolution.resolvedFsacPathForAmbientSdkTargetFramework () + + let fsacPath = + if fsacPath.EndsWith ".dll" then + let lastIndex = + [ fsacPath.LastIndexOf "/"; fsacPath.LastIndexOf @"\\" ] + |> Seq.sortDescending + |> Seq.tryHead + + match lastIndex with + | Some lastIndex when lastIndex > 0 -> fsacPath.Substring(0, lastIndex) + | None + | Some _ -> fsacPath + else + fsacPath + let fsiArg = defaultArg fsiSetting "fsi" - return dotnet, [| yield fsiArg; yield! parms |] + + let parms = + let useFsacDirectoryAsCompilerTool = + "FSharp.fsiUsesFSACDirectoryForCompilerTool" |> Configuration.get false + + let p = + node.path.join (VSCodeExtension.ionidePluginPath (), "watcher", "watcher.fsx") + + let fsiParams = + let userParams = + "FSharp.fsiExtraParameters" + |> Configuration.get Array.empty + |> List.ofArray + + if useFsacDirectoryAsCompilerTool then + userParams @ [ $"--compilertool:{fsacPath}" ] + else + userParams + + let fsiParams = + let addWatcher = "FSharp.addFsiWatcher" |> Configuration.get false + + if addWatcher then + [ "--load:" + p ] @ fsiParams + else + fsiParams + + if Environment.isWin then + // these flags are added to work around issues with the vscode terminal shell on windows + [ "--fsi-server-input-codepage:28591"; "--fsi-server-output-codepage:65001" ] + @ fsiParams + else + fsiParams + + let args = [| fsiArg; yield! parms |] + + return dotnet, args | Error msg -> return failwith msg } diff --git a/src/Core/LanguageService.fs b/src/Core/LanguageService.fs index ab5d84a8..e69d3f62 100644 --- a/src/Core/LanguageService.fs +++ b/src/Core/LanguageService.fs @@ -668,6 +668,117 @@ Consider: client <- Some cl cl + module SdkResolution = + let fsacNetcorePath = "FSharp.fsac.netCoreDllPath" |> Configuration.get "" + + let tfmForSdkVersion (v: SemVer) = + match int v.major, int v.minor with + | 3, 1 -> "netcoreapp3.1" + | 3, 0 -> "netcoreapp3.0" + | 2, 1 -> "netcoreapp2.1" + | 2, 0 -> "netcoreapp2.0" + | n, _ -> $"net{n}.0" + + /// given a set of tfms and a target tfm, find the first of the set that satisfies the target. + /// if no target is found, use the 'latest' tfm + /// e.g. [net6.0, net7.0] + net8.0 -> net7.0 + /// e.g. [net6.0, net7.0] + net7.0 -> net7.0 + let findBestTFM (availableTFMs: string seq) (tfm: string) = + let tfmToSemVer (t: string) = + t.Replace("netcoreapp", "").Replace("net", "").Split([| '.' |], 2) + |> fun ver -> semver.parse (!! $"{ver[0]}.{ver[1]}.0") + + let tfmMap = + availableTFMs + |> Seq.choose (fun tfm -> + match tfmToSemVer tfm with + | Some v -> Some(tfm, v) + | None -> None) + |> Seq.sortBy (fun (_, v) -> v.major, v.minor) + + printfn $"choosing from among %A{tfmMap}" + + match tfmToSemVer tfm with + | None -> + printfn "unable to parse target tfm, using latest" + Seq.last availableTFMs + | Some ver -> + tfmMap + |> Seq.skipWhile (fun (_, v) -> (semver.compare (!!v, !!ver)) = enum -1) // skip while fsac tfm is less than target tfm + |> Seq.tryHead // get first fsac tfm that is greater than or equal to target tfm + |> Option.map fst + |> Option.defaultWith (fun () -> Seq.last availableTFMs) + + let probePathForTFMs (basePath: string) (tfm: string) = + let availableTFMs = + node.fs.readdirSync (!!basePath) |> Seq.filter (fun p -> p.StartsWith "net") // there are loose files in the basePath, ignore those + + printfn $"Available FSAC TFMs: %A{availableTFMs}" + + if availableTFMs |> Seq.contains tfm then + printfn "TFM match found" + tfm, node.path.join (basePath, tfm, "fsautocomplete.dll") + else + // find best-matching + let tfm = findBestTFM availableTFMs tfm + tfm, node.path.join (basePath, tfm, "fsautocomplete.dll") + + let isNetFolder (folder: string) = + printfn $"checking folder %s{folder}" + let baseName = node.path.basename folder + + baseName.StartsWith("net") + && let stat = node.fs.statSync (!!folder) in + stat.isDirectory () + + /// locates the FSAC dll and TFM for that dll given a host TFM + let fsacPathForTfm (tfm: string) : string * string = + match fsacNetcorePath with + | null + | "" -> + // user didn't specify a path, so use FSAC from our extension + let binPath = node.path.join (VSCodeExtension.ionidePluginPath (), "bin") + probePathForTFMs binPath tfm + | userSpecified -> + if userSpecified.EndsWith ".dll" then + let tfm = node.path.basename (node.path.dirname userSpecified) + tfm, userSpecified + else + // if dir has tfm folders, probe + let filesAndFolders = + node.fs.readdirSync (!!userSpecified) + |> Seq.map (fun child -> node.path.join ([| userSpecified; child |])) + + printfn $"candidates: %A{filesAndFolders}" + + if filesAndFolders |> Seq.exists isNetFolder then + // tfm directories found, probe this directory like we would our own bin path + probePathForTFMs userSpecified tfm + else + // no tfm paths, try to use `fsautocomplete.dll` from this directory + let tfm = node.path.basename (node.path.dirname userSpecified) + tfm, node.path.join (userSpecified, "fsautocomplete.dll") + + let sdkVersionAndTargetFramework () = + promise { + let! sdkVersionAtRootPath = sdkVersion () + + match sdkVersionAtRootPath with + | Error e -> + printfn $"Error finding dotnet version: {e}" + return failwith "Error finding dotnet version, do you have dotnet installed and on the PATH?" + | Ok sdkVersion -> + printfn "Parsed SDK version at root path: %s" sdkVersion.raw + let sdkTfm = tfmForSdkVersion sdkVersion + return sdkVersion, sdkTfm + } + + let resolvedFsacPathForAmbientSdkTargetFramework () = + promise { + let! _, tfm = sdkVersionAndTargetFramework () + return fsacPathForTfm tfm + } + let getOptions (c: ExtensionContext) : JS.Promise = promise { let openTelemetryEnabled = "FSharp.openTelemetry.enabled" |> Configuration.get false @@ -713,144 +824,46 @@ Consider: let fsacAttachDebugger = "FSharp.fsac.attachDebugger" |> Configuration.get false - let fsacNetcorePath = "FSharp.fsac.netCoreDllPath" |> Configuration.get "" let fsacSilencedLogs = "FSharp.fsac.silencedLogs" |> Configuration.get [||] let verbose = "FSharp.verboseLogging" |> Configuration.get false - /// given a set of tfms and a target tfm, find the first of the set that satisfies the target. - /// if no target is found, use the 'latest' tfm - /// e.g. [net6.0, net7.0] + net8.0 -> net7.0 - /// e.g. [net6.0, net7.0] + net7.0 -> net7.0 - let findBestTFM (availableTFMs: string seq) (tfm: string) = - let tfmToSemVer (t: string) = - t.Replace("netcoreapp", "").Replace("net", "").Split([| '.' |], 2) - |> fun ver -> semver.parse (!! $"{ver[0]}.{ver[1]}.0") - - let tfmMap = - availableTFMs - |> Seq.choose (fun tfm -> - match tfmToSemVer tfm with - | Some v -> Some(tfm, v) - | None -> None) - |> Seq.sortBy (fun (_, v) -> v.major, v.minor) - - printfn $"choosing from among %A{tfmMap}" - - match tfmToSemVer tfm with - | None -> - printfn "unable to parse target tfm, using latest" - Seq.last availableTFMs - | Some ver -> - tfmMap - |> Seq.skipWhile (fun (_, v) -> (semver.compare (!!v, !!ver)) = enum -1) // skip while fsac tfm is less than target tfm - |> Seq.tryHead // get first fsac tfm that is greater than or equal to target tfm - |> Option.map fst - |> Option.defaultWith (fun () -> Seq.last availableTFMs) - - let probePathForTFMs (basePath: string) (tfm: string) = - let availableTFMs = - node.fs.readdirSync (!!basePath) |> Seq.filter (fun p -> p.StartsWith "net") // there are loose files in the basePath, ignore those - - printfn $"Available FSAC TFMs: %A{availableTFMs}" - - if availableTFMs |> Seq.contains tfm then - printfn "TFM match found" - tfm, node.path.join (basePath, tfm, "fsautocomplete.dll") - else - // find best-matching - let tfm = findBestTFM availableTFMs tfm - tfm, node.path.join (basePath, tfm, "fsautocomplete.dll") - - let isNetFolder (folder: string) = - printfn $"checking folder %s{folder}" - let baseName = node.path.basename folder - - baseName.StartsWith("net") - && let stat = node.fs.statSync (!!folder) in - stat.isDirectory () - - /// locates the FSAC dll and TFM for that dll given a host TFM - let fsacPathForTfm (tfm: string) : string * string = - match fsacNetcorePath with - | null - | "" -> - // user didn't specify a path, so use FSAC from our extension - let binPath = node.path.join (VSCodeExtension.ionidePluginPath (), "bin") - probePathForTFMs binPath tfm - | userSpecified -> - if userSpecified.EndsWith ".dll" then - let tfm = node.path.basename (node.path.dirname userSpecified) - tfm, userSpecified - else - // if dir has tfm folders, probe - let filesAndFolders = - node.fs.readdirSync (!!userSpecified) - |> Seq.map (fun child -> node.path.join ([| userSpecified; child |])) - - printfn $"candidates: %A{filesAndFolders}" - - if filesAndFolders |> Seq.exists isNetFolder then - // tfm directories found, probe this directory like we would our own bin path - probePathForTFMs userSpecified tfm - else - // no tfm paths, try to use `fsautocomplete.dll` from this directory - let tfm = node.path.basename (node.path.dirname userSpecified) - tfm, node.path.join (userSpecified, "fsautocomplete.dll") - - let tfmForSdkVersion (v: SemVer) = - match int v.major, int v.minor with - | 3, 1 -> "netcoreapp3.1" - | 3, 0 -> "netcoreapp3.0" - | 2, 1 -> "netcoreapp2.1" - | 2, 0 -> "netcoreapp2.0" - | n, _ -> $"net{n}.0" - let discoverDotnetArgs () = promise { - - let! sdkVersionAtRootPath = sdkVersion () - - match sdkVersionAtRootPath with - | Error e -> - printfn $"Error finding dotnet version: {e}" - return failwith "Error finding dotnet version, do you have dotnet installed and on the PATH?" - | Ok sdkVersion -> - printfn "Parsed SDK version at root path: %s" sdkVersion.raw - let sdkTfm = tfmForSdkVersion sdkVersion - printfn "Parsed SDK version to tfm: %s" sdkTfm - let fsacTfm, fsacPath = fsacPathForTfm sdkTfm - printfn "Parsed TFM to fsac path: %s" fsacPath - - let userDotnetArgs = "FSharp.fsac.dotnetArgs" |> Configuration.get [||] - - let hasUserRollForward = - userDotnetArgs - |> Array.tryFindIndex (fun a -> a = "--roll-forward") - |> Option.map (fun _ -> true) - |> Option.defaultValue false - - let hasUserFxVersion = - userDotnetArgs - |> Array.tryFindIndex (fun a -> a = "--fx-version") - |> Option.map (fun _ -> true) - |> Option.defaultValue false - - let shouldApplyImplicitRollForward = - not (hasUserFxVersion || hasUserRollForward) && sdkTfm <> fsacTfm // if the SDK doesn't match one of our FSAC TFMs, then we're in compat mode - - let args = userDotnetArgs - - let envVariables = - [ if shouldApplyImplicitRollForward then - "DOTNET_ROLL_FORWARD", box "LatestMajor" - match sdkVersion.prerelease with - | null -> () - | pres when Seq.length pres > 0 -> "DOTNET_ROLL_FORWARD_TO_PRERELEASE", box 1 - | _ -> () ] - - return args, envVariables, fsacPath, sdkVersion + let! sdkVersion, sdkTfm = SdkResolution.sdkVersionAndTargetFramework () + printfn "Parsed SDK version to tfm: %s" sdkTfm + let fsacTfm, fsacPath = SdkResolution.fsacPathForTfm sdkTfm + printfn "Parsed TFM to fsac path: %s" fsacPath + + let userDotnetArgs = "FSharp.fsac.dotnetArgs" |> Configuration.get [||] + + let hasUserRollForward = + userDotnetArgs + |> Array.tryFindIndex (fun a -> a = "--roll-forward") + |> Option.map (fun _ -> true) + |> Option.defaultValue false + + let hasUserFxVersion = + userDotnetArgs + |> Array.tryFindIndex (fun a -> a = "--fx-version") + |> Option.map (fun _ -> true) + |> Option.defaultValue false + + let shouldApplyImplicitRollForward = + not (hasUserFxVersion || hasUserRollForward) && sdkTfm <> fsacTfm // if the SDK doesn't match one of our FSAC TFMs, then we're in compat mode + + let args = userDotnetArgs + + let envVariables = + [ if shouldApplyImplicitRollForward then + "DOTNET_ROLL_FORWARD", box "LatestMajor" + match sdkVersion.prerelease with + | null -> () + | pres when Seq.length pres > 0 -> "DOTNET_ROLL_FORWARD_TO_PRERELEASE", box 1 + | _ -> () ] + + return args, envVariables, fsacPath, sdkVersion } /// Converts true to 1 and false to 0