I recently had occasion to write some F# scripts on Linux. I ran into a few hiccups getting my environment set up and had trouble finding documented solutions, so I thought I'd share what worked for me.
My machine is running Zorin OS 15, which is based on Ubuntu 18.04 LTS. As long as you're running a distribution that supports snaps, your experience will likely be similar to mine.
tl;dr
Here's one way to get up and running with F# scripting on Linux:
- Install the .NET Core SDK snap with
sudo snap install dotnet-sdk --classic
- Add
/snap/dotnet-sdk/current
to$PATH
- Install the VS Code snap with
sudo snap install code --classic
- Install the Ionide extension with
code --install-extension ionide.ionide-fsharp
- Add the following to VS Code's
settings.json
file:"FSharp.dotNetRoot": "/snap/dotnet-sdk/current"
and"FSharp.useSdkScripts": true
- Use Paket to manage NuGet packages
- Change the
paket.dependencies
file'sstorage
setting fromnone
topackages
That's the quick and dirty version. Read on for the why and the how, as well as some nifty bonus tips. 💡
Installing .NET Core
F# runs on .NET, so my first step is to install the recently released .NET Core 3.1 LTS. The .NET Core downloads page links to some docs that recommend installation using a package manager. This is a fine solution, as is Docker, but I prefer to use snap packages whenever I can. It turns out there is an official snap for the .NET Core SDK. The following command installs it:
sudo snap install dotnet-sdk --classic
Cool! That seemed straightforward enough. Let's try out the dotnet CLI.
$ dotnet --version
Command 'dotnet' not found, but can be installed with:
sudo snap install dotnet-sdk
Hmmm. Pretty sure I did that already. Maybe I need to reboot or something?
reboots, tries again...
Nope, no luck. I wonder if the dotnet CLI was installed to a location that isn't in my path. Come to think of it, what is in my path?
$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
Okay, so /snap/bin
is already in my path, but apparently, the dotnet
command isn't there. I wonder what else is in the /snap
directory...
$ ls -1 /snap
bin
code
core
core18
dotnet-sdk
node
README
slack
supertuxkart
Aha! Aside from the fact that you now know what fun snaps I've installed, this is interesting information. Let's see what's in that dotnet-sdk
directory.
$ ls -1 /snap/dotnet-sdk/
54
57
current
$ ls -1 /snap/dotnet-sdk/current
command-dotnet.wrapper
dotnet
dotnet-runtime
dotnet-sdk-3.0.100-linux-x64.tar.gz
etc
host
lib
LICENSE.txt
meta
packs
sdk
shared
snap
templates
ThirdPartyNotices.txt
usr
var
$ /snap/dotnet-sdk/current/dotnet --version
3.1.100
Bingo! There's our dotnet CLI executable (or at least a symbolic link to it). Now I just need to modify my path so that my shell will be able to find it whenever I type dotnet
.
I'm pretty sure the code in ~/.profile
runs when I log into my machine, so that seems like as good a place as any to make sure the dotnet CLI location is added to my path. Here's a one-time script to modify ~/.profile
appropriately:
echo "
# set PATH so it includes dotnet core sdk snap and tools
if [ -d /snap/dotnet-sdk/current ]
then
PATH=\"\$PATH:/snap/dotnet-sdk/current\"
fi
if [ -d \$HOME/.dotnet/tools ]
then
PATH=\"\$PATH:\$HOME/.dotnet/tools\"
fi" >> ~/.profile
Now I can finally type dotnet
to invoke the dotnet CLI. We're in business!
A quick aside: there is a simpler way to make the dotnet
command available without modifying the path. Snaps have the concept of an alias, which allows us to specify how we're going to invoke a snap on the command line. This is useful when the unique name of the snap (in this case, dotnet-sdk
) is different from how we expect to invoke it (in this case, dotnet
).
For example, the NodeJS snap adds helpful aliases by default:
$ snap aliases
Command Alias Notes
node.npm npm -
node.npx npx -
node.yarn yarn -
node.yarnpkg yarnpkg -
At the time of writing, there is an open issue in the backlog of the dotnet CLI repository to add a dotnet
snap alias on install. For now, we can add one manually with the following command:
sudo snap alias dotnet-sdk.dotnet dotnet
This allows us to type dotnet
to invoke the dotnet CLI, and it only took one command to get us there - no path wrangling needed. Nice!
Now for the bad news. When I tried adding a dotnet
snap alias, everything worked great on the command line, but the F# tooling in VS Code failed. I wasn't able to make it work, so I went back to the slightly-less-elegant solution of modifying my path. If you are able to make the F# tooling work with the snap alias, please let me know!
In any case, .NET Core is now installed and operational, so let's move on.
Configuring VS Code
I'll use VS Code with the Ionide extension. This seems to be the de facto tool set for cross-platform F# development. I've used it on Windows and really enjoyed it.
Let's install the VS Code snap, which, fortunately, works out of the box:
sudo snap install code --classic
Then we can install Ionide from the command line:
code --install-extension ionide.ionide-fsharp
Voilà ! An F# development environment.
Mostly.
Ionide is not without its quirks. Occasionally, when I open VS Code, a .ionide
directory is created in my workspace, regardless of whether the workspace contains any F#. At the time of writing, there is an open issue in the Ionide repository to address this behavior. For now, I'll work around it by disabling the Ionide extension globally, then enabling it manually for each F# workspace. This can be done from the "Extensions" pane in VS Code.
Another Ionide quirk affects one of my favorite features: CodeLens type signatures. Ionide can help us understand our code by showing the type signature of each function. Consider the following. If, somewhere in the code, add
is called with two integer parameters, we would see this (the comments are visible in the editor, but not part of the source code):
let add x y = x + y // int -> int -> int
When I first installed Ionide and opened an F# script file, the CodeLens type signatures were noticeably absent. All I saw was the code itself:
let add x y = x + y
After a bit of digging, I came upon two Ionide settings that needed to be explicitly set. The dotNetRoot
setting fixes the CodeLens problem by telling Ionide where to find the dotnet CLI, and the useSdkScripts
setting instructs Ionide to use the .NET Core version of FSharp Interactive (dotnet fsi
). I'll add these to my VS Code settings.json
file:
"FSharp.dotNetRoot": "/snap/dotnet-sdk/current",
"FSharp.useSdkScripts": true
Now we've got a first-class F# development environment. Let's get scripting!
Managing dependencies
From here on, we'll discuss F# scripting in general, not limited to Linux.
Although F# is a compiled language suitable for large projects, it works just as well as a scripting language. An F# script has a .fsx
extension rather than a .fs
extension, and stands alone - it does not make use of .NET project files (i.e. *.fsproj
, *.csproj
). If we want to use a library in our script, we have to download it and reference its path explicitly.
The dotnet CLI can manage NuGet packages, but it assumes that we're working with full-fledged .NET projects. What we really need is a repeatable way to download NuGet packages to the location of our choosing so that we can reference them in our script. Fortunately, the F# community has developed a dependency manager that solves this problem: Paket.
To see how Paket works, we'll follow the "Get started" guide and work through an example. Let's say that we have a bunch of data in a JSON file and we're writing an F# script to process that data. To deserialize our JSON data, we'll use the Newtonsoft.Json library.
Right now, all we have is a directory with our data and our script:
.
|--data.json
`--script.fsx
Paket is available as a dotnet CLI tool. We have the option to install it globally, but for this example, we'll scope it to our codebase so that it's an explicit dependency. The following command will create a .config
directory with a skeleton dotnet-tools.json
manifest file:
dotnet new tool-manifest
Here's our directory so far:
.
|--.config
| `--dotnet-tools.json
|--data.json
`--script.fsx
The following command will install the latest stable version of Paket locally and record that version in the manifest:
dotnet tool install paket
Now we can use Paket inside our codebase with dotnet paket
. So far so good!
Paket uses a paket.dependencies
file to specify the NuGet packages we want. We'll create this file with the following command:
dotnet paket init
The default paket.dependencies
file looks like this:
source https://api.nuget.org/v3/index.json
storage: none
framework: netcore3.0, netstandard2.0, netstandard2.1
If we want the latest stable version of a NuGet package, we add the word nuget
followed by the name of the package - no version necessary:
source https://api.nuget.org/v3/index.json
storage: none
framework: netcore3.0, netstandard2.0, netstandard2.1
nuget Newtonsoft.Json
Now we're finally ready to download the Newtonsoft.Json NuGet package. "Learn how to use Paket" indicates we can do so with the following command:
dotnet paket install
Note that this command creates a paket.lock
file, and it does include a version number for each package, allowing us to share our code and restore exactly the same dependencies on another machine:
STORAGE: NONE
RESTRICTION: || (== netcoreapp3.0) (== netstandard2.0) (== netstandard2.1)
NUGET
remote: https://api.nuget.org/v3/index.json
Newtonsoft.Json (12.0.3)
Here's what our directory looks like now:
.
|--.config
| `--dotnet-tools.json
|--paket-files
| `--paket.restore.cached
|--data.json
|--paket.dependencies
|--paket.lock
`--script.fsx
Hey, where are the NuGet packages?
Let's take another look at paket.dependencies
. The default setting storage: none
instructs Paket to download NuGet packages to a global cache. This is efficient for full-fledged .NET projects, but it isn't helpful for scripting. We need to know exactly where to find Newtonsoft.Json.dll
relative to our script.
According to the Paket documentation, we can instruct Paket to use a local packages
directory instead of a global cache by changing the storage
setting from none
to packages
. Here's our paket.dependencies
file after making the change:
source https://api.nuget.org/v3/index.json
storage: packages
framework: netcore3.0, netstandard2.0, netstandard2.1
nuget Newtonsoft.Json
Let's try one more time:
dotnet paket install
Success! We have a packages
directory containing the Newtonsoft.Json NuGet package:
.
|--.config
| `--dotnet-tools.json
|--packages
| `--Newtonsoft.Json
| `--[lots of stuff in here]
|--paket-files
| `--paket.restore.cached
|--data.json
|--paket.dependencies
|--paket.lock
`--script.fsx
To reference it, we can add some code like this at the top of our script:
#r "packages/Newtonsoft.Json/lib/netstandard2.0/Newtonsoft.Json.dll"
And away we go! With all of that setup behind us, we can focus on our code, bringing the power and elegance of F# to our scripting tasks.
But before we close, there are a few things that can make our lives a bit easier.
Bonus tip: restoring dependencies
The example above took the perspective of setting up Paket for the first time, but what should we do when we've committed our code to source control and cloned it on another machine?
Fortunately, all of our dependencies - including Paket itself - are explicitly specified in the configuration files we created: dotnet-tools.json
, paket.dependencies
, and paket.lock
. We can use the dotnet CLI to restore Paket locally with the following command:
dotnet tool restore
Then we can use Paket to restore NuGet packages:
dotnet paket restore
These steps can be combined into a single restore.sh
shell script that we execute any time we pull down a new version of our F# script:
dotnet tool restore
dotnet paket restore
Bonus tip: ignoring generated files
As we worked through the example above, we created quite a few files dedicated to dependency management. Some of these should be committed to source control along with our code, such as dotnet-tools.json
, paket.dependencies
, and paket.lock
. Some will be generated when we install or restore dependencies, and can thus be ignored by source control.
If we add the following lines to our .gitignore
file, we can safely commit everything else:
.ionide/
.paket/
paket-files/
packages/
Bonus tip: setting the current directory
Here's a little tip I picked up from the excellent F# for Fun and Profit by Scott Wlaschin. His series on low-risk ways to use F# at work has some helpful example scripts that use this technique.
When I'm writing a script, I often find that I need to read the contents of a file on disk, such as the data.json
file in our example above. I can put the input file in the same directory as the script, but sometimes I invoke the script from a completely different directory. This makes coding the path to the input file a challenge.
If I can control the current working directory at runtime, I can code a reliable path to the input file. F# has a built-in constant that can help us to accomplish this. The following code sets the current working directory to the F# script's directory:
System.IO.Directory.SetCurrentDirectory(__SOURCE_DIRECTORY__)
Bonus tip: you're awesome
I hope you found this post helpful. Whether you develop on Linux, Mac, or Windows, have fun with F#!
Top comments (2)
Thanks a lot for sharing your experience, Ed! I was having a really hard time trying to configure all the stuff on Ubuntu and your walkthrough was extremely helpful and easy to follow. Helped me a lot!
I've been using F# on and off for years now, and every time I want to write a one-off .fsx script I get bogged down with setup. It's very disappointing.
These directions are some of the best I've seen, and I'm on a Mac. You might consider using generate_load_scripts: true instead of changing the storage, but that's a detail that's up to you.